From 49b4f0eb7f2af88557ad1a171249c83176e4b505 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Thu, 17 Sep 2026 12:06:06 -0700 Subject: [PATCH 01/42] feat(mxc): add UI policy, credentials, relay lifecycle, and proxy auth Signed-off-by: Drew Newberry --- .../build-openshell-mxc-windows/SKILL.md | 39 +- .../build-openshell-mxc-windows/reference.md | 10 +- .github/workflows/windows-msvc.yml | 3 + AGENTS.md | 1 + CONTRIBUTING.md | 29 +- Cargo.lock | 15 + architecture/compute-runtimes.md | 30 +- architecture/gateway.md | 14 +- architecture/sandbox.md | 10 + architecture/security-policy.md | 33 +- architecture/windows-msvc-build.md | 24 +- crates/openshell-cli/src/commands/common.rs | 41 + crates/openshell-cli/src/commands/gateway.rs | 17 + crates/openshell-cli/src/main.rs | 80 +- crates/openshell-cli/src/run.rs | 79 +- .../sandbox_create_lifecycle_integration.rs | 137 ++ crates/openshell-core/src/policy.rs | 27 + .../src/provider_credentials.rs | 12 + crates/openshell-driver-docker/src/lib.rs | 1 + .../openshell-driver-kubernetes/src/driver.rs | 1 + crates/openshell-driver-mxc/Cargo.toml | 9 + crates/openshell-driver-mxc/README.md | 122 +- .../examples/README-openclaw-forward.txt | 140 ++ .../e2e-policies/openclaw-gateway.yaml | 49 + .../examples/e2e-policies/ws-agent.yaml | 42 + .../examples/install-nodejs-openclaw.ps1 | 157 ++ .../examples/mxc-github-provider-profile.yml | 29 + .../examples/mxc-openclaw-gateway.toml | 55 + .../examples/mxc-openclaw-isolation.toml | 44 + .../examples/mxc-openclaw-localnet.toml | 33 + .../mxc-provider-credential-policy.yaml | 36 + .../mxc-provider-credential-probe.ps1 | 175 ++ .../examples/mxc-provider-credential.toml | 18 + .../examples/mxc-ws-agent.rs | 622 +++++ .../examples/mxc-ws-gateway.toml | 66 + .../examples/openclaw-capture.mjs | 202 ++ .../examples/run-mxc-e2e.ps1 | 19 +- .../examples/run-openclaw-forward-test.ps1 | 722 ++++++ .../examples/run-provider-credential-test.ps1 | 461 ++++ .../examples/run-ws-agent-test.ps1 | 747 ++++++ .../src/control_channel.rs | 410 ++++ crates/openshell-driver-mxc/src/driver.rs | 2168 +++++++++++++++-- crates/openshell-driver-mxc/src/lib.rs | 10 +- crates/openshell-driver-mxc/src/mxc.rs | 306 ++- crates/openshell-driver-mxc/src/policy.rs | 128 +- .../src/policy_map/loss.rs | 2 +- .../src/policy_map/map.rs | 182 +- .../src/policy_map/mod.rs | 4 +- crates/openshell-driver-mxc/src/relay.rs | 686 ++++++ .../tests/openclaw_appcontainer_compat.rs | 58 + .../tests/openclaw_environment_cleanup.ps1 | 68 + .../tests/policy_mapper_examples.rs | 82 +- .../tests/policy_mapper_matrix.rs | 165 +- .../tests/wxc_exec_real.rs | 186 +- crates/openshell-driver-podman/src/driver.rs | 1 + crates/openshell-driver-vm/src/driver.rs | 1 + crates/openshell-gateway/Cargo.toml | 1 + crates/openshell-gateway/src/lib.rs | 35 +- crates/openshell-policy/src/lib.rs | 199 +- crates/openshell-sdk/src/client.rs | 2 + crates/openshell-server/src/compute/mod.rs | 588 ++++- crates/openshell-server/src/credentials.rs | 5 + crates/openshell-server/src/grpc/mod.rs | 1 + crates/openshell-server/src/grpc/policy.rs | 548 ++++- crates/openshell-server/src/grpc/provider.rs | 70 + crates/openshell-server/src/grpc/sandbox.rs | 165 +- .../openshell-server/src/grpc/validation.rs | 43 +- crates/openshell-server/src/lib.rs | 100 +- crates/openshell-server/src/storage_proto.rs | 13 +- crates/openshell-server/src/test_support.rs | 1 + .../openshell-supervisor-network/src/host.rs | 164 +- .../src/identity.rs | 2 +- .../openshell-supervisor-network/src/opa.rs | 16 + .../openshell-supervisor-network/src/proxy.rs | 76 + crates/openshell-supervisor-relay/Cargo.toml | 42 + crates/openshell-supervisor-relay/src/imp.rs | 1090 +++++++++ crates/openshell-supervisor-relay/src/main.rs | 32 + .../tests/control_channel_contract.rs | 666 +++++ crates/openshell-tui/src/lib.rs | 1 + docs/reference/gateway-config.mdx | 41 +- docs/reference/policy-schema.mdx | 40 + docs/sandboxes/manage-sandboxes.mdx | 26 +- docs/sandboxes/policies.mdx | 17 +- proto/compute_driver.proto | 4 + proto/openshell.proto | 14 + proto/sandbox.proto | 30 + sdk/go/openshell/v1/fake/sandbox.go | 4 + sdk/go/openshell/v1/fake/sandbox_test.go | 13 + .../v1/internal/converter/coverage_test.go | 1 + .../openshell/v1/internal/converter/health.go | 1 + .../v1/internal/converter/health_test.go | 8 +- .../openshell/v1/internal/converter/policy.go | 54 + .../v1/internal/converter/policy_test.go | 42 + sdk/go/openshell/v1/types/health.go | 7 +- sdk/go/openshell/v1/types/policy.go | 32 +- sdk/go/proto/openshellv1/openshell.pb.go | 53 +- sdk/go/proto/sandboxv1/sandbox.pb.go | 453 ++-- skills/generate-sandbox-policy/SKILL.md | 30 +- skills/generate-sandbox-policy/examples.md | 2 +- skills/openshell-cli/SKILL.md | 13 +- tasks/scripts/windows-msvc.ps1 | 48 +- tasks/windows.toml | 9 +- 102 files changed, 12789 insertions(+), 821 deletions(-) create mode 100644 crates/openshell-driver-mxc/examples/README-openclaw-forward.txt create mode 100644 crates/openshell-driver-mxc/examples/e2e-policies/openclaw-gateway.yaml create mode 100644 crates/openshell-driver-mxc/examples/e2e-policies/ws-agent.yaml create mode 100644 crates/openshell-driver-mxc/examples/install-nodejs-openclaw.ps1 create mode 100644 crates/openshell-driver-mxc/examples/mxc-github-provider-profile.yml create mode 100644 crates/openshell-driver-mxc/examples/mxc-openclaw-gateway.toml create mode 100644 crates/openshell-driver-mxc/examples/mxc-openclaw-isolation.toml create mode 100644 crates/openshell-driver-mxc/examples/mxc-openclaw-localnet.toml create mode 100644 crates/openshell-driver-mxc/examples/mxc-provider-credential-policy.yaml create mode 100644 crates/openshell-driver-mxc/examples/mxc-provider-credential-probe.ps1 create mode 100644 crates/openshell-driver-mxc/examples/mxc-provider-credential.toml create mode 100644 crates/openshell-driver-mxc/examples/mxc-ws-agent.rs create mode 100644 crates/openshell-driver-mxc/examples/mxc-ws-gateway.toml create mode 100644 crates/openshell-driver-mxc/examples/openclaw-capture.mjs create mode 100644 crates/openshell-driver-mxc/examples/run-openclaw-forward-test.ps1 create mode 100644 crates/openshell-driver-mxc/examples/run-provider-credential-test.ps1 create mode 100644 crates/openshell-driver-mxc/examples/run-ws-agent-test.ps1 create mode 100644 crates/openshell-driver-mxc/src/control_channel.rs create mode 100644 crates/openshell-driver-mxc/src/relay.rs create mode 100644 crates/openshell-driver-mxc/tests/openclaw_appcontainer_compat.rs create mode 100644 crates/openshell-driver-mxc/tests/openclaw_environment_cleanup.ps1 create mode 100644 crates/openshell-supervisor-relay/Cargo.toml create mode 100644 crates/openshell-supervisor-relay/src/imp.rs create mode 100644 crates/openshell-supervisor-relay/src/main.rs create mode 100644 crates/openshell-supervisor-relay/tests/control_channel_contract.rs diff --git a/.agents/skills/build-openshell-mxc-windows/SKILL.md b/.agents/skills/build-openshell-mxc-windows/SKILL.md index 32647a1cbc..1120e6cfcc 100644 --- a/.agents/skills/build-openshell-mxc-windows/SKILL.md +++ b/.agents/skills/build-openshell-mxc-windows/SKILL.md @@ -17,6 +17,7 @@ Windows MSVC for the supported deliverables: - `openshell-gateway.exe` - `openshell.exe` +- `openshell-supervisor-relay.exe` (Windows-only MXC workload relay) It intentionally does not make Windows a Docker, Kubernetes, Podman, or VM runtime host. @@ -115,7 +116,7 @@ The lane targets a Windows host with Visual Studio Build Tools and rustup. | Visual C++ ARM64 tools | `vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.ARM64 -property installationPath` | Required for native ARM64 check, build, and tests and for x64-to-ARM64 check/build. Tests always require a native runner. | | Visual C++ ARM64 Spectre-mitigated libraries | `vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Runtimes.ARM64.Spectre -property installationPath` | Required by `regorus` through `msvc_spectre_libs`; the build fails when the selected MSVC toolset lacks `lib\spectre\arm64`. | | Visual C++ Clang tools | `vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Llvm.Clang -property installationPath` | Provides host-native `libclang.dll` for `bindgen` and `clang-cl.exe` for ARM64 crypto dependencies such as `aws-lc-sys`. On ARM64, the wrapper uses `VC\Tools\Llvm\Arm64\bin`. | -| Visual C++ CMake tools | `vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.CMake.Project -property installationPath` | Provides CMake and Ninja for bundled Z3 and other native dependencies. The x64-to-ARM64 path adds Ninja to `PATH`; Z3 uses MSVC's Visual Studio generator. | +| Visual C++ CMake tools | `vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.CMake.Project -property installationPath` | Provides CMake and Ninja for native dependencies. The x64-to-ARM64 path adds Ninja to `PATH`; Z3 uses an architecture-specific prebuilt release. | | Windows SDK | `where.exe rc.exe` from a Developer PowerShell | Install an SDK containing target libraries and ARM64 tools. | | Rust via rustup | `rustc --version` | Add each target being validated: `x86_64-pc-windows-msvc` and/or `aarch64-pc-windows-msvc`. The wrapper also adds the selected target. | | mise | `mise --version` | Used as a task runner only. | @@ -135,6 +136,8 @@ from this skill. | `CARGO_TARGET_DIR` | `target` under repo root | Override Cargo output location. Use a short absolute path when x64-to-ARM64 builds approach Windows path-length limits. | | `Z3_LIBRARY_PATH_OVERRIDE` | unset | Directory containing an x64 system `libz3.lib`; not valid for ARM64. | | `Z3_SYS_Z3_HEADER` | unset | Full `z3.h` path required with a system Z3 library. | +| `Z3_SYS_Z3_VERSION` | `4.16.0` | Pinned official prebuilt Z3 release selected by the wrapper. | +| `READ_ONLY_GITHUB_TOKEN` | unset | Optional token for the Z3 release lookup; GitHub Actions supplies `github.token`. | | `RUSTC_WRAPPER` | inherited | The wrapper resolves an available command to an absolute path. If it is unavailable, the wrapper warns and continues without compiler caching. | Legacy fork variables such as `OPENSHELL_UPSTREAM`, @@ -155,6 +158,16 @@ mise run --skip-tools windows:test:x64 mise run --skip-tools windows:test:unsupported:x64 ``` +The two `windows:test:mxc-real:*` tasks are host-specific and mutually +exclusive on a single host (each rejects the other architecture -- see the +table below): run `windows:test:mxc-real:x64` on an x64 host, or +`windows:test:mxc-real:arm64` on an ARM64 host, as part of validating this +subsystem -- run the one matching your host architecture, not both, and not +neither. Both are skip-safe (they print a SKIP reason and exit 0 when +`wxc-exec` or the matching backend isn't available), so running the +arch-appropriate task is always safe even without real MXC hardware. Neither +is part of `windows:ci`'s ordered contract, so invoke it explicitly. + For full validation, detect the Windows host architecture first and choose the native lane dynamically: @@ -163,12 +176,14 @@ $arch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture switch ($arch.ToString()) { "X64" { mise run --skip-tools windows:ci + mise run --skip-tools windows:test:mxc-real:x64 } "Arm64" { mise run --skip-tools windows:check:arm64 mise run --skip-tools windows:build:arm64 mise run --skip-tools windows:test:arm64 mise run --skip-tools windows:test:unsupported:arm64 + mise run --skip-tools windows:test:mxc-real:arm64 mise run --skip-tools windows:artifacts } default { @@ -203,8 +218,7 @@ jobs in the current mirror push run, or push a new mirrored commit. The binaries The ARM64 check/build steps in this x64-host contract are cross-builds. The wrapper discovers and adds host-native LLVM and Ninja to `PATH`, requires the ARM64 compiler and Spectre-mitigated libraries, lets ARM64 crypto crates select -`clang-cl`, and builds bundled Z3 with native MSVC `cl.exe` and the Visual -Studio generator. +`clang-cl`, and downloads the official prebuilt ARM64 Z3 static library. On ARM64 hosts, validate the native ARM64 check, build, and test path. The wrapper rejects test targets that do not match the host architecture, so x64 @@ -241,6 +255,8 @@ crypto dependency builds. | `windows:test:arm64` | Runs native ARM64 workspace tests with `--no-fail-fast` and the same package exclusions. Rejects non-ARM64 hosts. | | `windows:test:unsupported:x64` | Re-runs focused `openshell-gateway` tests for unsupported Windows driver behavior. | | `windows:test:unsupported:arm64` | Re-runs the same focused contracts natively on ARM64. Rejects non-ARM64 hosts. | +| `windows:test:mxc-real:x64` | Runs the serial, ignored real-`wxc-exec` integration suite natively on x64 through the MSVC wrapper. Rejects non-x64 hosts. | +| `windows:test:mxc-real:arm64` | Runs the same real-`wxc-exec` suite natively on ARM64. Rejects non-ARM64 hosts. | | `windows:artifacts` | Reports size and SHA256 for release artifacts that exist. | | `windows:ci` | Runs the full ordered x64-host Windows CI lane, plus ARM64 check/build when not skipped. | @@ -310,13 +326,16 @@ Useful log files: | `test-aarch64-pc-windows-msvc.log` | Full native ARM64 workspace test output. | | `test-x86_64-pc-windows-msvc-unsupported-*.log` | Focused unsupported-driver contract output. | | `test-aarch64-pc-windows-msvc-unsupported-*.log` | Focused native ARM64 contract output. | - -The first check builds bundled Z3 from source through `z3-sys`. Cargo stores the -native build output in its target tree, so the Windows target cache reuses it. -The resulting release executables do not require `libz3.dll`. The artifact -report computes SHA256 through .NET directly and does not rely on the -`Get-FileHash` module being available inside the mise-launched Windows -PowerShell process. +| `test-x86_64-pc-windows-msvc-mxc-real.log` | Native x64 real-MXC integration output. | +| `test-aarch64-pc-windows-msvc-mxc-real.log` | Native ARM64 real-MXC integration output. | + +The first check downloads the pinned official Z3 archive for the target +architecture through `z3-sys`. GitHub Actions authenticates the lookup with its +read-only workflow token; local users can set `READ_ONLY_GITHUB_TOKEN` if an +unauthenticated lookup is rate-limited. Cargo stores the extracted library in +its target tree, so the Windows target cache reuses it. The artifact report +computes SHA256 through .NET directly and does not rely on the `Get-FileHash` +module being available inside the mise-launched Windows PowerShell process. ## Common Fix Patterns diff --git a/.agents/skills/build-openshell-mxc-windows/reference.md b/.agents/skills/build-openshell-mxc-windows/reference.md index 99acac6034..bc278664dc 100644 --- a/.agents/skills/build-openshell-mxc-windows/reference.md +++ b/.agents/skills/build-openshell-mxc-windows/reference.md @@ -68,11 +68,11 @@ file. For ARM64, verify the Visual Studio instance contains the ARM64 MSVC tools, ARM64 Spectre-mitigated libraries, Clang tools, CMake tools, and a Windows SDK. Clang supplies host-native `libclang.dll` for `bindgen` and `clang-cl.exe` for -ARM64 crypto dependencies such as `aws-lc-sys`. Native builds use the normal -bundled-Z3 CMake path. An x64-to-ARM64 check/build discovers and adds -host-native Ninja to `PATH`, builds bundled Z3 with native MSVC `cl.exe` and -the Visual Studio generator, and lets the crypto crates select `clang-cl`. Use -a short `CARGO_TARGET_DIR` if Windows path-length limits are reached. +ARM64 crypto dependencies such as `aws-lc-sys`. Native and x64-to-ARM64 builds +use the official prebuilt Z3 4.16.0 static library for the target architecture. +An x64-to-ARM64 check/build discovers and adds host-native Ninja to `PATH`, +while the crypto crates select `clang-cl`. Use a short `CARGO_TARGET_DIR` if +Windows path-length limits are reached. ## Unsupported Driver Rules diff --git a/.github/workflows/windows-msvc.yml b/.github/workflows/windows-msvc.yml index 76ba5075e2..00c41d9ca9 100644 --- a/.github/workflows/windows-msvc.yml +++ b/.github/workflows/windows-msvc.yml @@ -56,6 +56,7 @@ jobs: rust_target: aarch64-pc-windows-msvc runs-on: ${{ matrix.runner }} env: + READ_ONLY_GITHUB_TOKEN: ${{ github.token }} SCCACHE_GHA_ENABLED: "true" SCCACHE_GHA_VERSION: windows-msvc-${{ matrix.arch }} SCCACHE_CLIENT_SIDE: "1" @@ -121,6 +122,7 @@ jobs: rust_target: aarch64-pc-windows-msvc runs-on: ${{ matrix.runner }} env: + READ_ONLY_GITHUB_TOKEN: ${{ github.token }} SCCACHE_GHA_ENABLED: "true" SCCACHE_GHA_VERSION: windows-msvc-${{ matrix.arch }} SCCACHE_CLIENT_SIDE: "1" @@ -178,6 +180,7 @@ jobs: rust_target: aarch64-pc-windows-msvc runs-on: ${{ matrix.runner }} env: + READ_ONLY_GITHUB_TOKEN: ${{ github.token }} SCCACHE_GHA_ENABLED: "true" SCCACHE_GHA_VERSION: windows-msvc-${{ matrix.arch }} SCCACHE_CLIENT_SIDE: "1" diff --git a/AGENTS.md b/AGENTS.md index 954ffe1147..215779f673 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,6 +59,7 @@ These pipelines connect skills into end-to-end workflows. Individual skill files | `crates/openshell-driver-podman/` | Podman compute driver | In-process `ComputeDriver` backend for local Podman sandbox containers | | `crates/openshell-driver-vm/` | VM compute driver | Standalone libkrun-backed `ComputeDriver` subprocess (embeds its own rootfs + runtime) | | `crates/openshell-driver-mxc/` | Microsoft MXC compute driver | In-process Windows AppContainer and isolation-session compute backend | +| `crates/openshell-supervisor-relay/` | MXC supervisor relay | **Windows-only** standalone binary the MXC driver spawns inside a ProcessContainer/isolation session in place of `agent_command`; launches the real target process, exposes a JSON control channel (launch/shutdown/forward) over its own inherited stdin/stdout, and bridges dynamic TCP forwards (`openshell forward service`) to it | | `crates/openshell-prover/` | Policy prover | Policy verification and proof generation | | `crates/openshell-server-macros/` | Server macros | Compile-time helpers for gateway RPC authorization | | `crates/openshell-supervisor-middleware/` | Middleware runtime | Generic middleware registry, remote service integration, and chain execution | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8c519da904..931d97500f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -340,14 +340,19 @@ cargo build -p openshell-prover --features bundled-z3 For x86-64 and ARM64 Windows MSVC builds, use one of these Z3 paths: -- Bundled Z3 (the default for `windows:*` tasks): `z3-sys` builds Z3 from - source and links it into the OpenShell binaries. This keeps the release - executables independent of `libz3.dll` when they are copied to another host. +- Prebuilt Z3 (the default for `windows:*` tasks): `z3-sys` downloads the + pinned Z3 4.16.0 GitHub release for the target architecture on the first + build. Cargo reuses the extracted archive from its target directory. Windows + CI authenticates the GitHub API request with `READ_ONLY_GITHUB_TOKEN` and + preserves the archive in the architecture-specific Cargo target cache. For + cold local builds, you may set `READ_ONLY_GITHUB_TOKEN` to avoid anonymous + GitHub API rate limits. - System Z3: point `Z3_LIBRARY_PATH_OVERRIDE` at the directory containing the - target-compatible MSVC Z3 library and `Z3_SYS_Z3_HEADER` at the full path to - `z3.h`. The `windows:*` tasks use this path automatically when - `Z3_LIBRARY_PATH_OVERRIDE` is set. Deploy the matching `libz3.dll` with any - binaries built against a dynamic system Z3. + target-compatible MSVC Z3 library and `Z3_SYS_Z3_HEADER` at the full path to `z3.h`. + The `windows:*` tasks use this path automatically when `Z3_LIBRARY_PATH_OVERRIDE` + is set. +- Bundled Z3: for direct Cargo builds, pass `--features bundled-z3` so `z3-sys` + builds Z3 from source. `openshell-prover` itself has no `bindgen`/`libclang` dependency, so building just this crate does not require `LIBCLANG_PATH`: @@ -360,17 +365,17 @@ cargo build -p openshell-prover --target x86_64-pc-windows-msvc --features bundl To build the full set of Windows binaries, including `openshell-gateway.exe` and `openshell.exe`, use the `windows:build:x64` mise task instead of a -single-crate `cargo build`. It builds Z3 from source (bundled) by default. A full -build also compiles crates that use `bindgen` (e.g. the MXC driver on Windows), -so it requires `libclang.dll`; if LLVM is not on the default search path, set -`LIBCLANG_PATH` to the directory containing `libclang.dll`: +single-crate `cargo build`. It downloads the pinned prebuilt Z3 release by default. A +full build also compiles crates that use `bindgen` (e.g. the MXC driver on +Windows), so it requires `libclang.dll`; if LLVM is not on the default search +path, set `LIBCLANG_PATH` to the directory containing `libclang.dll`: ```powershell $env:LIBCLANG_PATH='C:\Program Files\Microsoft Visual Studio\2022\\VC\Tools\Llvm\x64\bin' mise run --skip-tools windows:build:x64 ``` -To use a local x64 Z3 release instead of the bundled build, set +To use a local x64 Z3 release instead of the prebuilt download, set `Z3_LIBRARY_PATH_OVERRIDE` and `Z3_SYS_Z3_HEADER` before running the task: ```powershell diff --git a/Cargo.lock b/Cargo.lock index 05afb6dab3..431b79335a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4170,6 +4170,7 @@ dependencies = [ name = "openshell-driver-mxc" version = "0.0.0" dependencies = [ + "anyhow", "base64", "futures", "noyalib", @@ -4177,6 +4178,7 @@ dependencies = [ "openshell-ocsf", "openshell-policy", "openshell-supervisor-network", + "rand 0.9.4", "rustls", "serde", "serde_json", @@ -4184,6 +4186,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tokio-stream", + "tokio-tungstenite 0.26.2", "tonic", "tracing", "uuid", @@ -4716,6 +4719,18 @@ dependencies = [ "uuid", ] +[[package]] +name = "openshell-supervisor-relay" +version = "0.0.0" +dependencies = [ + "anyhow", + "base64", + "futures", + "serde_json", + "tokio", + "tokio-tungstenite 0.26.2", +] + [[package]] name = "openshell-tui" version = "0.0.0" diff --git a/architecture/compute-runtimes.md b/architecture/compute-runtimes.md index 3018e6447d..8f7c5e5080 100644 --- a/architecture/compute-runtimes.md +++ b/architecture/compute-runtimes.md @@ -32,8 +32,8 @@ launches and monitors the policy-constrained workload itself. `compute_driver.proto` is the supported gateway/driver extension boundary. At initialization the gateway snapshots the driver's identity, version, -default image, gateway-lifecycle preference, and -`driver_reports_runtime_readiness` from `GetCapabilities`. The gateway includes +default image, gateway-lifecycle preference, runtime-readiness ownership, and +complete UI-policy enforcement support from `GetCapabilities`. The gateway includes the canonical `SandboxPolicy` in `DriverSandboxSpec.policy` for validation and creation. Drivers that enforce policy outside the standard supervisor fetch later revisions through `GetSandboxConfig` and acknowledge them through @@ -106,6 +106,18 @@ The capability RPC reports driver identity, version, and the default sandbox image used by the gateway. GPU availability stays driver-local and is validated when a sandbox create request asks for GPU resources. +UI policy is capability-negotiated at the configured driver-instance boundary. +`supports_ui_policy = true` means the driver completely enforces every field in +the current portable `SandboxPolicy.ui` contract; partial implementations must +report false. When `ui` is explicitly present, including as `{}`, the gateway +rejects create before the driver validation RPC or provisioning unless this +capability is true. An absent section bypasses this gate and preserves the +runtime's existing behavior. The startup snapshot is also exposed through +gateway info so clients can discover the selected runtime's support. UI cannot +be supplied by a gateway-global policy because it is applied at startup. When a +global dynamic policy is active, effective-policy reads retain the UI block from +the sandbox's creation policy. + The gateway records driver identity and version from the startup capability response. Elevated gateway info reports that initialized driver snapshot instead of re-querying drivers on each request. @@ -156,6 +168,20 @@ in-tree driver cannot silently depend on a server-only API. ## Stop and Start Lifecycle +On Windows, the MXC driver can wrap the workload in +`openshell-supervisor-relay`. Its inherited stdin/stdout control channel carries +the launch environment, shutdown requests, and multiplexed dynamic forwards. +The gateway accepts driver-reported readiness only after the configured target +port is reachable. Stop/delete interrupt readiness waits and await process +termination; they must not publish success while owned processes remain. + +With governed egress enabled, MXC denies direct Internet access and allows +host loopback. Proxy-aware workloads receive per-sandbox authenticated +`HTTP_PROXY`/`HTTPS_PROXY` URLs and public CA trust material. The host CONNECT +proxy enforces OpenShell network policy, but this configuration does not isolate +unrelated host-loopback services. See the MXC driver README for compatibility +settings and the remaining policy limitations. + The gateway persists lifecycle intent before mutating compute: ```text diff --git a/architecture/gateway.md b/architecture/gateway.md index fdf0c15f78..bccebfd9f4 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -347,6 +347,12 @@ Public RPC contracts and durable protobuf formats have separate ownership. The ` `ReportEndpointStatus` is a sandbox-authenticated public gateway RPC. Its request, response, and `EndpointObservation` messages belong only to the public closure. `EndpointStatus` and `EndpointResult` also belong to the durable closure because `Sandbox.status.endpoint_statuses` persists them. The repeated status field uses a new wire tag; stored sandboxes without it decode with an empty endpoint list and retain their lifecycle fields. A fixed payload encoded with the earlier sandbox schema verifies that no database rewrite is required. +`DeleteSandboxRequest` adds optional identity and resource-version preconditions +at tags 4 and 5; the workspace selector retains tag 3. Omitted preconditions +preserve existing deletion behavior. Matching clients and servers are required +when relying on these checks: an older server can ignore unknown fields. These +request-only additions do not change any durable storage payload. + The removed `NetworkBinary.harness` field remains reserved by number and name, so protobuf implementations cannot reuse its wire slot or source identifier. The durable-policy compatibility decoder reads the former boolean before Prost @@ -622,9 +628,11 @@ leave an ambiguous final dynamic-token state or a deleted custom profile that is still referenced by a sandbox. Policy and runtime settings are delivered together through the effective sandbox -config path. A gateway-global policy can override sandbox-scoped policy. The -sandbox supervisor polls for config revisions and hot-reloads dynamic policy -when the policy engine accepts the update. +config path. A gateway-global policy can override sandbox-scoped dynamic policy. +Startup-only UI remains anchored to each sandbox's creation policy, and global +policy writes containing UI are rejected. The sandbox supervisor polls for +config revisions and hot-reloads dynamic policy when the policy engine accepts +the update. External supervisor middleware registration is operator-owned configuration under `[[openshell.supervisor.middleware]]`. At startup the gateway connects to diff --git a/architecture/sandbox.md b/architecture/sandbox.md index 859181b279..430b78c642 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -35,6 +35,16 @@ only when the set is already empty; any other outcome fails the spawn. 6. It opens a supervisor session back to the gateway for connect, exec, file sync, config polling, and log push. +## Deletion Authority + +The public delete API resolves a sandbox name to its immutable metadata ID and +holds that ID's lifecycle lock through the pre-mutation check. Callers that +already observed a sandbox may also supply its expected ID and resource +version. OpenShell revalidates those preconditions under the lifecycle and +gateway-global locks, then returns `ABORTED` without changing durable state or +calling the compute driver if either value drifted. An unguarded delete retains +the interactive CLI's existing name-based behavior. + ## Isolation Layers OpenShell uses overlapping controls rather than a single sandbox primitive: diff --git a/architecture/security-policy.md b/architecture/security-policy.md index 78599e41d9..655886d87c 100644 --- a/architecture/security-policy.md +++ b/architecture/security-policy.md @@ -8,19 +8,43 @@ per-request egress decisions. For the field-by-field YAML reference, use [Policy Schema Reference](../docs/reference/policy-schema.mdx). +## Windows MXC Static Enforcement + +On native Windows, the MXC driver cannot rely on Linux Landlock or setuid. It +maps portable static filesystem and UI controls from `SandboxPolicy` into MXC +configuration. UI controls are available only with the MXC +`process_container` backend; all omitted UI fields retain deny-by-default +values, and other compute runtimes reject an explicit UI policy. MXC ignores +clipboard and input-injection fields when graphical UI is disabled, so the +mapper rejects those otherwise-unenforceable combinations. + ## Policy Areas | Area | Enforcement | |---|---| | Filesystem | Landlock restricts read-only and read-write paths. | | Process | The supervisor launches the agent as an unprivileged user with reduced capabilities. | +| UI | Within an explicit UI section, omitted display, clipboard, and input-injection fields deny. The MXC driver's OpenShell `process_container` backend (MXC containment `processcontainer`) can selectively enable them. Other configured backends reject the entire explicit section before provisioning. | | Network | The proxy evaluates destination, port, calling binary, and optional L7 rules. | | Provider access | Attached provider profiles contribute endpoint and binary rules; credentials remain bound to profile-authorized endpoints. | | Runtime settings | Typed settings are delivered with policy and can be global or sandbox scoped. | -Filesystem and process policy are startup-time controls. Network policy is +Filesystem, process, and UI policy are startup-time controls. Network policy is dynamic and can be hot-reloaded when the new policy validates successfully. +The UI schema names portable capabilities rather than Windows primitives: +graphical output, directional clipboard access, and synthetic input. The +configured compute driver advertises whether it completely enforces this +contract. Any explicit section, including `{}`, is rejected before driver +validation or provisioning when that capability is false. The MXC +`process_container` mapper translates the fields to MXC's top-level `ui` object +under its `processcontainer` containment value and treats omitted fields inside +the section as deny. That object is common to MXC's 0.8 stable and 0.9 +development schemas. Both schema lines reject it for `isolation_session`, so +that backend advertises no support and the mapper also rejects it in depth. +Linux, macOS, and other non-MXC paths advertise no support: explicit UI policy +fails closed, while an absent section leaves their runtime behavior unchanged. + Before applying Landlock, the supervisor enriches baseline filesystem paths that the runtime needs. Missing baseline paths are skipped so one absent runtime path does not weaken the whole ruleset. When GPU devices are present, GPU baseline @@ -223,8 +247,11 @@ generation, and whether the previous policy is active. Static controls, such as filesystem allowlists and process identity, require a new sandbox because they are applied before the child process starts. -Gateway-global policy can override sandbox-scoped policy. Use it sparingly -because it changes the effective access model for every sandbox on the gateway. +Gateway-global policy can override sandbox-scoped dynamic policy. Use it +sparingly because it changes the effective access model for every sandbox on +the gateway. A global policy cannot contain `ui`; effective-policy reads retain +the per-sandbox UI contract recorded at creation so a global update cannot +misrepresent startup enforcement. ## Policy Advisor diff --git a/architecture/windows-msvc-build.md b/architecture/windows-msvc-build.md index 6fb816b448..8795e78776 100644 --- a/architecture/windows-msvc-build.md +++ b/architecture/windows-msvc-build.md @@ -110,13 +110,15 @@ packaging-asset tests; its cross-platform Python, Markdown, license, and documentation checks still run. Test tasks require the Rust target architecture to match the Windows host, so an ARM64 test result is native coverage rather than x64 emulation coverage. -By default it enables bundled Z3 for reproducible Windows builds. `z3-sys` -builds Z3 from source and links it into the release binaries, so copying -`openshell-gateway.exe` and `openshell.exe` does not create an undeclared -`libz3.dll` runtime dependency. When `Z3_LIBRARY_PATH_OVERRIDE` points at a -directory containing `libz3.lib`, the wrapper uses that system Z3 instead and -requires `Z3_SYS_Z3_HEADER` to point at the full path to `z3.h`. Binaries built -against a dynamic system Z3 must be deployed with the matching `libz3.dll`. +By default it enables the `z3-sys` prebuilt-release feature and pins Z3 4.16.0. +On a clean target directory, `z3-sys` downloads the official static library for +the selected Windows architecture instead of compiling Z3 through +CMake/MSBuild. GitHub Actions supplies its read-only workflow token for the +release lookup, and the Cargo target cache preserves the extracted library for +subsequent runs. When `Z3_LIBRARY_PATH_OVERRIDE` points at a directory +containing `libz3.lib`, the wrapper uses that system Z3 instead and requires +`Z3_SYS_Z3_HEADER` to point at the full path to `z3.h`. Local clean builds use +the unauthenticated GitHub API unless `READ_ONLY_GITHUB_TOKEN` is set. GitHub Actions layers the Cargo target cache with sccache's GitHub Actions backend. The target cache lets Cargo skip intact dependency builds; sccache @@ -136,10 +138,10 @@ Spectre-mitigated libraries, host-native Clang tools, CMake tools, and an ARM64-capable Windows SDK. Clang provides `libclang.dll` for `bindgen` and `clang-cl.exe` for ARM64 crypto dependencies. During x64-to-ARM64 check/build, the wrapper discovers and adds the Visual Studio-bundled Ninja to `PATH` for -native dependencies. Bundled Z3 uses native MSVC `cl.exe` with the Visual -Studio generator, while the crypto crates select `clang-cl`. Artifact hashing -uses .NET SHA256 directly because module autoloading in the mise-launched -Windows PowerShell process is not guaranteed. +native dependencies. Z3 uses the official prebuilt ARM64 static library, so it +does not inherit compiler settings from those native dependencies. Artifact +hashing uses .NET SHA256 directly because module autoloading in the +mise-launched Windows PowerShell process is not guaranteed. The wrapper defaults Cargo compilation to four jobs. Set `OPENSHELL_WINDOWS_BUILD_JOBS` to a positive integer to override that limit. diff --git a/crates/openshell-cli/src/commands/common.rs b/crates/openshell-cli/src/commands/common.rs index 0f12c3df4e..29afe306af 100644 --- a/crates/openshell-cli/src/commands/common.rs +++ b/crates/openshell-cli/src/commands/common.rs @@ -878,6 +878,47 @@ pub fn parse_env_pairs(items: &[String]) -> Result> { Ok(map) } +/// Resolve `--env-from KEY[=ENVVAR]` values from the CLI process environment. +/// +/// This keeps environment values out of process arguments while preserving the +/// same sandbox environment validation as `--env KEY=VALUE`. +pub fn parse_env_from_pairs(items: &[String]) -> Result> { + let mut map = HashMap::new(); + + for item in items { + let (key, env_name) = match item.split_once('=') { + Some((key, env_name)) => (key.trim(), env_name.trim()), + None => (item.trim(), item.trim()), + }; + if !is_valid_env_name(key) { + return Err(miette::miette!( + "--env-from key must match [A-Za-z_][A-Za-z0-9_]*; got '{key}'" + )); + } + if key.starts_with("OPENSHELL_") { + return Err(miette::miette!( + "--env-from keys starting with OPENSHELL_ are reserved; got '{key}'" + )); + } + if !is_valid_env_name(env_name) { + return Err(miette::miette!( + "--env-from source must match [A-Za-z_][A-Za-z0-9_]*; got '{env_name}'" + )); + } + if map.contains_key(key) { + return Err(miette::miette!("duplicate --env-from sandbox key '{key}'")); + } + let value = std::env::var(env_name).map_err(|_| { + miette::miette!( + "--env-from source environment variable '{env_name}' is not set or is not valid Unicode" + ) + })?; + map.insert(key.to_string(), value); + } + + Ok(map) +} + /// Resolve `--secret-material-env KEY[=ENVVAR]` values from the CLI process /// environment (`ENVVAR` defaults to `KEY`) so secrets never transit argv. pub fn parse_secret_material_env_pairs(items: &[String]) -> Result> { diff --git a/crates/openshell-cli/src/commands/gateway.rs b/crates/openshell-cli/src/commands/gateway.rs index 0e1fe92426..6073ac3a4b 100644 --- a/crates/openshell-cli/src/commands/gateway.rs +++ b/crates/openshell-cli/src/commands/gateway.rs @@ -45,6 +45,7 @@ struct ComputeDriverInfoView { struct ComputeDriverCapabilitiesView { driver_name: String, driver_version: String, + supports_ui_policy: bool, } /// Show gateway status. @@ -392,6 +393,7 @@ pub async fn gateway_info( capabilities: ComputeDriverCapabilitiesView { driver_name: capabilities.driver_name, driver_version: capabilities.driver_version, + supports_ui_policy: capabilities.supports_ui_policy, }, } }) @@ -446,6 +448,15 @@ fn print_compute_driver_info(drivers: &[ComputeDriverInfoView]) { "Driver version:".dimmed(), driver.capabilities.driver_version ); + println!( + " {} {}", + "UI policy:".dimmed(), + if driver.capabilities.supports_ui_policy { + "supported" + } else { + "unsupported" + } + ); } } @@ -464,6 +475,7 @@ fn gateway_info_to_json(view: &GatewayInfoView) -> serde_json::Value { "capabilities": { "driver_name": &driver.capabilities.driver_name, "driver_version": &driver.capabilities.driver_version, + "supports_ui_policy": driver.capabilities.supports_ui_policy, }, })) .collect::>(), @@ -1822,6 +1834,7 @@ mod tests { capabilities: ComputeDriverCapabilitiesView { driver_name: "podman".to_string(), driver_version: "0.0.75".to_string(), + supports_ui_policy: false, }, }], }; @@ -1840,6 +1853,10 @@ mod tests { json["compute_drivers"][0]["capabilities"]["driver_version"], "0.0.75" ); + assert_eq!( + json["compute_drivers"][0]["capabilities"]["supports_ui_policy"], + false + ); } #[test] diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index fdec4ce7e5..5f9d3b2935 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -1272,7 +1272,7 @@ enum SandboxCommands { name: Option, /// Create the sandbox from a named sandbox template. - #[arg(long, conflicts_with_all = ["from", "gpu", "cpu", "memory", "driver_config_json", "envs"])] + #[arg(long, conflicts_with_all = ["from", "gpu", "cpu", "memory", "driver_config_json", "envs", "env_from"])] template: Option, /// Sandbox source: a community sandbox name (e.g., `ollama`), a rootfs @@ -1396,6 +1396,13 @@ enum SandboxCommands { #[arg(long = "env", value_name = "KEY=VALUE")] envs: Vec, + /// Set a sandbox environment variable from the CLI process environment. + /// + /// Format: `KEY[=ENVVAR]`. When `ENVVAR` is omitted, `KEY` is used. + /// The value does not appear in the CLI process arguments. Repeatable. + #[arg(long = "env-from", value_name = "KEY[=ENVVAR]")] + env_from: Vec, + /// Suppress warnings when --env values look like credentials. #[arg(long = "no-credential-warnings")] no_credential_warnings: bool, @@ -1477,8 +1484,16 @@ enum SandboxCommands { names: Vec, /// Delete all sandboxes. - #[arg(long, conflicts_with = "names")] + #[arg(long, conflicts_with_all = ["names", "expected_id", "expected_resource_version"])] all: bool, + + /// Delete only if the current sandbox has this exact immutable ID. + #[arg(long = "expected-id", value_name = "ID")] + expected_id: Option, + + /// Delete only if the current sandbox has this resource version. + #[arg(long = "expected-resource-version", value_name = "VERSION")] + expected_resource_version: Option, }, /// Stop a sandbox while preserving its workspace. @@ -3067,6 +3082,7 @@ async fn run_async() -> Result<()> { no_auto_providers, labels, envs, + env_from, no_credential_warnings, approval_mode, output, @@ -3104,7 +3120,14 @@ async fn run_async() -> Result<()> { } // Parse --env flags into a HashMap. - let env_map = run::parse_env_pairs(&envs)?; + let mut env_map = run::parse_env_pairs(&envs)?; + for (key, value) in run::parse_env_from_pairs(&env_from)? { + if env_map.insert(key.clone(), value).is_some() { + return Err(miette::miette!( + "duplicate sandbox environment key '{key}' supplied through --env and --env-from" + )); + } + } run::warn_credential_env_vars(&env_map, no_credential_warnings); // Parse --upload specs into [(local_path, sandbox_path, git_ignore)]. @@ -3261,11 +3284,18 @@ async fn run_async() -> Result<()> { ) .await?; } - SandboxCommands::Delete { names, all } => { + SandboxCommands::Delete { + names, + all, + expected_id, + expected_resource_version, + } => { run::sandbox_delete( endpoint, &names, all, + expected_id.as_deref(), + expected_resource_version, &cli.workspace, &tls, &ctx.name, @@ -4805,6 +4835,48 @@ mod tests { )); } + #[test] + fn sandbox_delete_accepts_identity_preconditions() { + let cli = Cli::try_parse_from([ + "openshell", + "sandbox", + "delete", + "demo", + "--expected-id", + "sb-123", + "--expected-resource-version", + "17", + ]) + .expect("identity-guarded sandbox delete should parse"); + + assert!(matches!( + cli.command, + Some(Commands::Sandbox { + command: Some(SandboxCommands::Delete { + ref names, + all: false, + expected_id: Some(ref expected_id), + expected_resource_version: Some(17), + }) + }) if names == &["demo"] && expected_id == "sb-123" + )); + } + + #[test] + fn sandbox_delete_all_rejects_identity_preconditions() { + assert!( + Cli::try_parse_from([ + "openshell", + "sandbox", + "delete", + "--all", + "--expected-id", + "sb-123", + ]) + .is_err() + ); + } + #[test] fn sandbox_list_accepts_output_json() { let cli = Cli::try_parse_from(["openshell", "sandbox", "list", "-o", "json"]) diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index 243e10d1bf..673d4a9da3 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -4,8 +4,8 @@ //! CLI command implementations. pub use crate::commands::common::{ - PolicyGetView, parse_credential_expiry_cli_value, parse_env_pairs, parse_key_value_pairs, - parse_secret_material_env_pairs, warn_credential_env_vars, + PolicyGetView, parse_credential_expiry_cli_value, parse_env_from_pairs, parse_env_pairs, + parse_key_value_pairs, parse_secret_material_env_pairs, warn_credential_env_vars, }; use crate::commands::common::{ ProvisioningDisplay, ProvisioningStep, confirm_global_setting_delete, @@ -391,7 +391,9 @@ async fn finalize_sandbox_create_session( } let names = [sandbox_name.to_string()]; - if let Err(err) = sandbox_delete(server, &names, false, workspace, tls, gateway).await { + if let Err(err) = + sandbox_delete(server, &names, false, None, None, workspace, tls, gateway).await + { if let Ok(exit_code) = session_result.as_ref() { return Err(miette::miette!( "sandbox command exited with status {exit_code}, but ephemeral cleanup failed: {err}" @@ -3171,14 +3173,35 @@ fn labels_display(labels: &HashMap) -> String { } /// Delete a sandbox by name, or all sandboxes when `all` is true. +#[allow(clippy::too_many_arguments)] // user-facing CLI command with explicit identity guards pub async fn sandbox_delete( server: &str, names: &[String], all: bool, + expected_id: Option<&str>, + expected_resource_version: Option, workspace: &str, tls: &TlsOptions, gateway: &str, ) -> Result<()> { + if (expected_id.is_some() || expected_resource_version.is_some()) && (all || names.len() != 1) { + return Err(miette!( + "--expected-id and --expected-resource-version require exactly one sandbox name" + )); + } + if expected_id.is_some_and(str::is_empty) { + return Err(miette!("--expected-id must not be empty")); + } + if expected_resource_version.is_some() && expected_id.is_none() { + return Err(miette!( + "--expected-resource-version requires --expected-id" + )); + } + if expected_resource_version == Some(0) { + return Err(miette!( + "--expected-resource-version must be greater than zero" + )); + } let mut client = grpc_client(server, tls).await?; let names_to_delete: Vec = if all { @@ -3229,6 +3252,8 @@ pub async fn sandbox_delete( .delete_sandbox(DeleteSandboxRequest { name: name.clone(), workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), + expected_sandbox_id: expected_id.unwrap_or_default().to_string(), + expected_resource_version: expected_resource_version.unwrap_or_default(), }) .await { @@ -5996,7 +6021,7 @@ mod tests { use super::{ PolicyGetView, ProvisioningStep, build_sandbox_resource_limits, format_endpoint, format_log_line, git_sync_files, has_main_process_result, parse_cli_setting_value, - parse_credential_expiry_cli_value, parse_driver_config_json, + parse_credential_expiry_cli_value, parse_driver_config_json, parse_env_from_pairs, parse_secret_material_env_pairs, policy_revision_list_json, policy_revision_to_json, provisioning_timeout_message, ready_false_condition_message, resolve_from, rootfs_tar_sources_supported_for_gateway, sandbox_should_persist, sandbox_upload_plan, @@ -6201,6 +6226,52 @@ mod tests { )); } + #[test] + fn parse_env_from_pairs_reads_named_and_same_name_environment_variables() { + let _named = EnvVarGuard::set("NAV_PARSE_ENV_FROM_NAMED", "named-value"); + let _same_name = EnvVarGuard::set("NAV_PARSE_ENV_FROM_SAME", "same-name-value"); + + let parsed = parse_env_from_pairs(&[ + "SANDBOX_NAMED=NAV_PARSE_ENV_FROM_NAMED".to_string(), + "NAV_PARSE_ENV_FROM_SAME".to_string(), + ]) + .expect("parse"); + assert_eq!( + parsed.get("SANDBOX_NAMED"), + Some(&"named-value".to_string()) + ); + assert_eq!( + parsed.get("NAV_PARSE_ENV_FROM_SAME"), + Some(&"same-name-value".to_string()) + ); + } + + #[test] + fn parse_env_from_pairs_rejects_missing_invalid_reserved_and_duplicate_keys() { + let _missing = EnvVarGuard::unset("NAV_PARSE_ENV_FROM_MISSING"); + let _present = EnvVarGuard::set("NAV_PARSE_ENV_FROM_PRESENT", "value"); + + for (input, expected) in [ + ("TARGET=NAV_PARSE_ENV_FROM_MISSING", "is not set"), + ("1BAD=NAV_PARSE_ENV_FROM_PRESENT", "key must match"), + ("TARGET=BAD-NAME", "source must match"), + ("OPENSHELL_RESERVED=NAV_PARSE_ENV_FROM_PRESENT", "reserved"), + ] { + let error = parse_env_from_pairs(&[input.to_string()]).expect_err("must reject"); + assert!( + error.to_string().contains(expected), + "unexpected error: {error}" + ); + } + + let error = parse_env_from_pairs(&[ + "TARGET=NAV_PARSE_ENV_FROM_PRESENT".to_string(), + "TARGET=NAV_PARSE_ENV_FROM_PRESENT".to_string(), + ]) + .expect_err("duplicate must reject"); + assert!(error.to_string().contains("duplicate --env-from")); + } + #[test] fn parse_secret_material_env_pairs_reads_value_from_named_environment_variable() { let _guard = EnvVarGuard::set("NAV_PARSE_SME_NAMED", "pem-material"); diff --git a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs index d3cccefb76..d5500eb7ff 100644 --- a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs +++ b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs @@ -60,6 +60,7 @@ fn selected_workspace( #[derive(Clone, Default)] struct SandboxState { deleted_names: Arc>>>, + delete_requests: Arc>>, create_requests: Arc>>, fail_delete_sandbox_message: Arc>>, vm_error_after_started: Arc, @@ -330,6 +331,11 @@ impl OpenShell for TestOpenShell { request: tonic::Request, ) -> Result, Status> { let request = request.into_inner(); + self.state + .delete_requests + .lock() + .await + .push(request.clone()); self.state .deleted_names .lock() @@ -1383,6 +1389,10 @@ async fn deleted_names(server: &TestServer) -> Vec> { server.openshell.state.deleted_names.lock().await.clone() } +async fn delete_requests(server: &TestServer) -> Vec { + server.openshell.state.delete_requests.lock().await.clone() +} + async fn create_requests(server: &TestServer) -> Vec { server.openshell.state.create_requests.lock().await.clone() } @@ -1480,6 +1490,8 @@ async fn sandbox_delete_continues_after_entry_failure() { &server.endpoint, &["failing-sandbox".to_string(), "later-sandbox".to_string()], false, + None, + None, "default", &tls, "openshell", @@ -1501,6 +1513,79 @@ async fn sandbox_delete_continues_after_entry_failure() { ); } +#[tokio::test] +async fn sandbox_delete_forwards_identity_preconditions() { + let server = run_server().await; + let tls = test_tls(&server); + + run::sandbox_delete( + &server.endpoint, + &["guarded-sandbox".to_string()], + false, + Some("sb-123"), + Some(17), + "default", + &tls, + "openshell", + ) + .await + .expect("identity-guarded delete should succeed"); + + let requests = delete_requests(&server).await; + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].name, "guarded-sandbox"); + assert_eq!(requests[0].expected_sandbox_id, "sb-123"); + assert_eq!(requests[0].expected_resource_version, 17); +} + +#[tokio::test] +async fn sandbox_delete_rejects_preconditions_for_multiple_names_before_rpc() { + let server = run_server().await; + let tls = test_tls(&server); + + let error = run::sandbox_delete( + &server.endpoint, + &["sandbox-a".to_string(), "sandbox-b".to_string()], + false, + Some("sb-123"), + None, + "default", + &tls, + "openshell", + ) + .await + .expect_err("identity preconditions must be single-sandbox only"); + + assert!( + error + .to_string() + .contains("require exactly one sandbox name") + ); + assert!(delete_requests(&server).await.is_empty()); +} + +#[tokio::test] +async fn sandbox_delete_rejects_resource_version_without_immutable_identity() { + let server = run_server().await; + let tls = test_tls(&server); + + let error = run::sandbox_delete( + &server.endpoint, + &["guarded-sandbox".to_string()], + false, + None, + Some(17), + "default", + &tls, + "openshell", + ) + .await + .expect_err("resource version without immutable ID must fail closed"); + + assert!(error.to_string().contains("requires --expected-id")); + assert!(delete_requests(&server).await.is_empty()); +} + #[tokio::test] async fn sandbox_create_keeps_command_sessions_by_default() { let server = run_server().await; @@ -2813,6 +2898,16 @@ async fn run_cli_sandbox_create_with_xdg( xdg_dir: &TempDir, name: &str, extra_args: &[&str], +) -> std::process::Output { + run_cli_sandbox_create_with_xdg_and_env(server, xdg_dir, name, extra_args, &[]).await +} + +async fn run_cli_sandbox_create_with_xdg_and_env( + server: &TestServer, + xdg_dir: &TempDir, + name: &str, + extra_args: &[&str], + environment: &[(&str, &str)], ) -> std::process::Output { let mut cmd = tokio::process::Command::new(env!("CARGO_BIN_EXE_openshell")); for (key, _) in std::env::vars().filter(|(k, _)| k.starts_with("OPENSHELL_")) { @@ -2831,6 +2926,7 @@ async fn run_cli_sandbox_create_with_xdg( "--no-auto-providers", ]) .args(extra_args) + .envs(environment.iter().copied()) .env("XDG_CONFIG_HOME", xdg_dir.path()) .env("HOME", xdg_dir.path()) .env("OPENSHELL_PROVISION_TIMEOUT", "5") @@ -2849,6 +2945,47 @@ async fn run_cli_sandbox_create( run_cli_sandbox_create_with_xdg(server, &xdg_dir, name, extra_args).await } +async fn run_cli_sandbox_create_with_env( + server: &TestServer, + name: &str, + extra_args: &[&str], + environment: &[(&str, &str)], +) -> std::process::Output { + let xdg_dir = tempfile::tempdir().unwrap(); + prepare_cli_xdg(server, &xdg_dir); + run_cli_sandbox_create_with_xdg_and_env(server, &xdg_dir, name, extra_args, environment).await +} + +#[tokio::test] +async fn sandbox_create_env_from_reaches_request() { + let server = run_server().await; + let value = "qualification-value-not-in-argv"; + + let output = run_cli_sandbox_create_with_env( + &server, + "env-from-test", + &["--env-from", "SANDBOX_VALUE=HOST_VALUE", "--output=json"], + &[("HOST_VALUE", value)], + ) + .await; + assert!( + output.status.success(), + "sandbox create failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let requests = create_requests(&server).await; + let environment = &requests[0] + .spec + .as_ref() + .expect("spec should be present") + .environment; + assert_eq!( + environment.get("SANDBOX_VALUE").map(String::as_str), + Some(value) + ); +} + async fn run_cli_sandbox_template_create( server: &TestServer, name: &str, diff --git a/crates/openshell-core/src/policy.rs b/crates/openshell-core/src/policy.rs index af474325c5..8bf2828698 100644 --- a/crates/openshell-core/src/policy.rs +++ b/crates/openshell-core/src/policy.rs @@ -113,6 +113,10 @@ impl TryFrom for SandboxPolicy { type Error = miette::Report; fn try_from(proto: ProtoSandboxPolicy) -> Result { + // UI capabilities are intentionally absent from the portable supervisor + // runtime. Non-Windows compute paths do not expose them, so even a + // schema-level UI allowance cannot grant a UI surface there. The MXC + // driver consumes the typed proto directly on Windows. // In cluster mode we always run with proxy networking so all egress // can be evaluated by OPA. let network = NetworkPolicy { @@ -192,6 +196,7 @@ impl From for ProcessPolicy { #[cfg(test)] mod tests { use super::*; + use crate::proto::{UiClipboardAccess, UiPolicy}; #[test] fn try_from_maps_known_compatibility_values() { @@ -233,4 +238,26 @@ mod tests { assert!(!is_valid_landlock_compatibility("nope")); assert!(!is_valid_landlock_compatibility("BestEffort")); } + + #[test] + fn portable_runtime_does_not_activate_ui_allowances() { + let converted = SandboxPolicy::try_from(ProtoSandboxPolicy { + version: 1, + ui: Some(UiPolicy { + allow_graphical_ui: true, + clipboard: UiClipboardAccess::All as i32, + allow_input_injection: true, + }), + ..Default::default() + }) + .expect("portable policy conversion succeeds"); + + assert_eq!(converted.version, 1); + assert!(matches!(converted.network.mode, NetworkMode::Proxy)); + assert!(converted.network.proxy.is_some()); + assert!(converted.filesystem.read_only.is_empty()); + assert!(converted.filesystem.read_write.is_empty()); + assert!(converted.process.run_as_user.is_none()); + assert!(converted.process.run_as_group.is_none()); + } } diff --git a/crates/openshell-core/src/provider_credentials.rs b/crates/openshell-core/src/provider_credentials.rs index f82fd066af..bc0cc2bd22 100644 --- a/crates/openshell-core/src/provider_credentials.rs +++ b/crates/openshell-core/src/provider_credentials.rs @@ -373,6 +373,18 @@ impl ProviderCredentialState { .revision } + /// Whether this snapshot contains endpoint-bound material that must be + /// resolved by a network proxy rather than exposed to the child process. + #[must_use] + pub fn requires_proxy_resolution(&self) -> bool { + let inner = self + .inner + .read() + .expect("provider credential state poisoned"); + !inner.static_credential_bindings.is_empty() + || !inner.current.dynamic_credentials.is_empty() + } + /// Remove a key from the credential snapshot's child env. /// /// Used when a sandbox-side service (e.g., metadata server) fails to start diff --git a/crates/openshell-driver-docker/src/lib.rs b/crates/openshell-driver-docker/src/lib.rs index 813c07f6c7..62ba8c9f69 100644 --- a/crates/openshell-driver-docker/src/lib.rs +++ b/crates/openshell-driver-docker/src/lib.rs @@ -667,6 +667,7 @@ impl DockerComputeDriver { }), rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, + supports_ui_policy: false, } } diff --git a/crates/openshell-driver-kubernetes/src/driver.rs b/crates/openshell-driver-kubernetes/src/driver.rs index b7114c0e99..7b33d4b963 100644 --- a/crates/openshell-driver-kubernetes/src/driver.rs +++ b/crates/openshell-driver-kubernetes/src/driver.rs @@ -579,6 +579,7 @@ impl KubernetesComputeDriver { }), rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, + supports_ui_policy: false, }) } diff --git a/crates/openshell-driver-mxc/Cargo.toml b/crates/openshell-driver-mxc/Cargo.toml index 5907e92a84..a85e709d4e 100644 --- a/crates/openshell-driver-mxc/Cargo.toml +++ b/crates/openshell-driver-mxc/Cargo.toml @@ -34,9 +34,18 @@ uuid = { workspace = true } [target.'cfg(target_os = "windows")'.dependencies] openshell-supervisor-network = { path = "../openshell-supervisor-network" } windows = { workspace = true } +# WebSocket relay embedded in the gateway for ProcessContainer host<->sandbox +# connectivity (see src/relay.rs). +tokio-tungstenite = { workspace = true } +futures = { workspace = true } +# Per-forward auth nonce for the relay (see src/relay.rs module docs). +rand = { workspace = true } [dev-dependencies] +anyhow = { workspace = true } tokio = { workspace = true } +tokio-tungstenite = { workspace = true } +futures = { workspace = true } # tempfile is not a workspace dependency; 3.27 is already resolved in Cargo.lock. tempfile = "3" # Used by Windows-only integration tests to parse policy YAML into the typed diff --git a/crates/openshell-driver-mxc/README.md b/crates/openshell-driver-mxc/README.md index 9c235b85c7..b8c52f9898 100644 --- a/crates/openshell-driver-mxc/README.md +++ b/crates/openshell-driver-mxc/README.md @@ -7,26 +7,29 @@ OpenShell compute driver backed by **Microsoft MXC** (`wxc-exec`) on Windows. This driver implements the gateway's ordinary in-process `ComputeDriver` contract and is linked into `openshell-gateway`. It sets `driver_reports_runtime_readiness`, so the gateway accepts driver-reported -readiness without a supervisor session. The canonical create-time -`SandboxPolicy` is carried by `DriverSandboxSpec.policy`. `process_container` launches a one-shot -AppContainer and is the default. The opt-in `isolation_session` backend uses the +readiness without a supervisor session. The gateway composes the create-time +effective `SandboxPolicy` and carries it on the driver-only copy of +`DriverSandboxSpec.policy`. `process_container` launches a one-shot AppContainer +and is the default. The opt-in `isolation_session` backend uses the state-aware `provision` → `start` → `exec` → `stop` → `deprovision` lifecycle. -The driver launches and monitors the configured workload itself and self-reports -readiness; there is no in-sandbox supervisor or `ConnectSupervisor` relay. +The driver launches and monitors the configured workload and self-reports +readiness. Optional `openshell-supervisor-relay` wrapping provides launch, +shutdown, and dynamic forwarding over an inherited stdin/stdout control channel; +it does not implement the Linux `ConnectSupervisor` protocol. ## Capability Matrix -| Capability | MXC driver | Closing it requires | -|---|---|---| -| Filesystem policy (read-write / read-only grants) | ✅ provision-time AppContainer shares | — | -| Governed egress (CONNECT proxy + OPA + L7) | Available behind `egress_proxy` on `process_container`; the driver starts a per-sandbox host CONNECT proxy, generates HTTPS MITM trust material, and injects the CA bundle into the sandbox process env | Gateway event-bus wiring follow-on | -| Network policy | Split into MXC `network.proxy` + trimmed OpenShell policy on `process_container`; `isolation_session` still rejects network config | MXC feedback item M1 for persistent sessions | -| Network middleware | ❌ rejected before launch because the MXC host proxy does not receive the gateway middleware registry | Gateway middleware-registry injection | -| Process policy (seccomp, uid/gid) | ❌ host-side governance design; OS isolation only | not pursued | -| Interactive exec/connect/forward | ❌ exec runs in-driver, no client attach | gateway interactive-exec surgery (follow-on) | -| Bundled agent image | ❌ no OCI image; relies on Windows host install | — | -| Restart durability | ❌ in-memory registry; restart orphans live sessions | follow-on | -| Concurrent sandboxes | ⚠️ isolation_session v1 is single-session | MXC backend feature | +| Capability | MXC driver | +|---|---| +| Filesystem policy | Read-only/read-write grants come only from `SandboxPolicy`. `process_container` enforces default-deny; `isolation_session` is an explicit grant-only compatibility mode. | +| UI policy | `process_container` advertises complete support and maps portable graphical UI, clipboard-direction, and input-injection controls to MXC; omitted fields inside an explicit section deny. `isolation_session` advertises no support, so the gateway rejects any explicit section before provisioning. | +| Network policy | With `egress_proxy = true` on `process_container`, split into MXC 0.8 loopback-only egress plus the full policy enforced by a per-sandbox OpenShell host CONNECT proxy. The driver injects proxy environment variables for proxy-aware clients; direct Internet access remains denied by MXC. Otherwise rejected synchronously. `isolation_session` remains fail-closed. | +| Provider credentials | The child receives revision-scoped placeholders and non-secret provider environment only. The per-sandbox host proxy retains the resolver and substitutes credentials only for their bound endpoints. | +| Process policy | Unsupported; MXC supplies OS isolation only. | +| Dynamic forwarding | Supported through `openshell-supervisor-relay`; interactive exec/connect remain unsupported. | +| Network middleware | Rejected before launch until the host proxy receives the gateway middleware registry. | +| ETW/OCSF audit | Optional Windows Sandboxing ETW consumer attributes host events to OpenShell sandboxes and emits OCSF records. | +| Restart durability | Unsupported; the in-memory registry cannot recover live sessions. | The filesystem enforcement proof has two paths: @@ -45,8 +48,23 @@ backend = "process_container" default_configuration_id = "composable" pc_least_privilege = false pc_capabilities = [] -# Pattern-C governed egress. The address is a loopback seed; each sandbox -# receives a unique ephemeral proxy port. +# processContainer only: launch openshell-supervisor-relay instead of +# the per-sandbox command directly, giving the driver a control +# channel into the sandbox (launch handshake, dynamic `openshell forward +# service` bridging). target_port is the launched command's own listening +# port; 0 disables spawner wrapping (default -- the command runs directly). +pc_relay_spawner_path = "" +pc_relay_target_port = 0 +# processContainer only: env-inheritance tier for the launched process +# (safest first): default is a minimal Windows CreateProcessW bootstrap set +# (SYSTEMROOT/WINDIR/PATH/COMSPEC/LOCALAPPDATA); pc_minimal_env starts from an +# EMPTY env for runtimes that need a fully curated per-sandbox environment. +pc_minimal_env = false +# processContainer only: include "allowLocalNetwork": true in the MXC +# network section. This compatibility setting broadens network access and is +# not required by the BaseContainer qualification profile. +pc_allow_local_network = false +# Pattern C governed egress. Requires backend = "process_container". egress_proxy = false egress_proxy_addr = "" debug = false @@ -55,7 +73,7 @@ etw_audit = false When `egress_proxy` is enabled, `egress_proxy_addr` must be a loopback `IP:PORT` seed. The driver preserves the configured IP and allocates a unique -ephemeral port for each sandbox's `network.proxy` redirect. +ephemeral port for each sandbox's authenticated host CONNECT proxy. Supply workload settings for each sandbox. The public config is keyed by driver name; the gateway forwards only the inner `mxc` object to the driver: @@ -65,11 +83,11 @@ openshell sandbox create --name mxc-demo --policy demo.yaml ` --driver-config-json $config --env MODE=demo --no-tty ``` -The `command` array is required and preserves Windows argument boundaries. `cwd` is optional. Environment variables come from the standard sandbox and template environment maps; the driver never copies values from the gateway host environment. +The `command` array is required and preserves Windows argument boundaries. `cwd` is optional. Supply per-sandbox environment variables with `--env` or `--env-from`; gateway configuration does not carry workload commands or environment. Provider-owned keys override matching entries case-insensitively, but raw static values remain in the host proxy; MXC receives their revision-scoped placeholders. When governed egress is enabled, the driver replaces common TLS trust environment variables with paths to public proxy CA files staged under `/.openshell-proxy//`, and injects `HTTP_PROXY`/`HTTPS_PROXY` while clearing `NO_PROXY` so inherited bypass rules cannot skip policy enforcement. + +UI capability (Win32k syscalls, clipboard, input injection) is a `SandboxPolicy` concern, not gateway TOML -- see the Capability Matrix above and `docs/reference/policy-schema.mdx`'s `ui` section. Defaults to disabled (Win32k syscall lockdown) when a policy has no explicit `ui:` section; set `allow_graphical_ui: true` for agents that touch user32/gdi32 at startup even without opening a real window (e.g. Node.js-based targets like OpenClaw's gateway -- see `examples/e2e-policies/openclaw-gateway.yaml`). -The host CONNECT proxy enforces network policy when governed egress is enabled. -Live policy replacement or merge updates remain unsupported; delete and recreate -the sandbox to apply a different policy. +`egress_proxy_addr` must be a `127.0.0.1:PORT` address. The port acts only as a configuration seed: the driver reserves a unique ephemeral loopback port for every sandbox. MXC 0.8 denies direct Internet egress and permits `127.0.0.1/32`; the driver points proxy-aware clients at the per-sandbox listener using environment variables. The current policy permits all loopback ports, so sandboxes can also reach unrelated host services bound to loopback. Control-channel forwarding does not require the legacy reverse-WebSocket connections to fresh host ports; restricting the generated policy is separate hardening work. Do not treat this path as loopback-service isolation. Live policy replacement or merge updates remain unsupported; delete and recreate the sandbox to apply a different policy. When `etw_audit` is enabled, each gateway process owns a distinct real-time ETW session named from the stable `OpenShell-MXC-ETW` prefix, its process ID, and a @@ -98,6 +116,10 @@ activity, and correlation-vector links remain available for five seconds so already in-flight ETW records can arrive, but retired PID evidence cannot resolve them. Records without matching generation evidence remain unattributed. +Each sandbox receives a distinct proxy listener and a random per-sandbox credential through its proxy environment. Missing, incorrect, duplicate, or another sandbox's proxy credentials receive HTTP 407 before policy evaluation or forwarding. This authenticates requests to the OpenShell proxy; it does not restrict access to unrelated host-loopback services or authenticate individual processes inside a sandbox. Proxy credentials and command/environment payloads must not be logged. + +The MXC credential handoff is also fixed at sandbox creation. The gateway rejects expiring static provider credentials because the in-process MXC driver has no live credential-refresh channel. Dynamic token grants remain request-time operations in the host proxy. Recreate the sandbox after rotating or revoking a non-expiring static credential. + ## Prerequisites (live runs) - Windows 11 Insider build ≥ 26300.8553 @@ -116,11 +138,13 @@ The production driver maps the typed `SandboxPolicy` carried by the standard driver request to MXC configuration before it inserts a registry entry or invokes `wxc-exec`. Mapping failure therefore returns from `CreateSandbox` without leaving a partial sandbox. There is no in-process policy side channel -or MXC-specific gateway composition variant. +or MXC-specific gateway composition variant. Provider resolver state uses a +separate, create-scoped in-process handoff because it intentionally cannot be +represented in the public compute-driver protobuf. When `egress_proxy` is enabled, `EmbeddedPolicyMapper` uses `split_policy` -instead: MXC receives filesystem grants plus a loopback `network.proxy` -redirect, and the driver starts a host CONNECT proxy from the trimmed +instead: MXC receives filesystem grants plus loopback-only egress, +and the driver starts a host CONNECT proxy from the trimmed network-only `SandboxPolicy`. Policies containing `network_middlewares` are rejected synchronously until this host-proxy path can receive the gateway's built-in and remote middleware registry. The proxy uses the configured agent @@ -128,31 +152,34 @@ command as the static sandbox process identity because MXC does not expose Linux-style procfs socket ownership. For HTTPS L7 inspection, the host proxy generates a per-sandbox CA and injects `NODE_EXTRA_CA_CERTS`, `DENO_CERT`, `SSL_CERT_FILE`, `REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`, and `GIT_SSL_CAINFO` into the agent -process env. It does not add the generated CA directory to MXC read-only grants: -released `wxc-exec` BaseContainer builds require `WRITE_DAC` on every such -grant and reject the user-owned proxy temp directory. Instead, the driver adds -the sandbox-unique directory as an internal read-write share so HTTPS clients -can read the injected paths. The directory contains only public CA certificates; +process env. In curated-environment mode, the driver stages the public CA files +under the authorized `/.openshell-proxy/` directory. Other +environment modes grant the sandbox's unique public-CA directory as an internal +read-write share. The directory contains only public CA certificates; the ephemeral CA private key remains in the host proxy's memory. The driver seeds only `SYSTEMROOT`, `WINDIR`, `PATH`, `COMSPEC`, and `LOCALAPPDATA` from the gateway host before applying sandbox and TLS overrides, so required Windows bootstrap values remain available without exposing the gateway's full -environment. The development export surface remains the -[`policy-to-mxc`](examples/policy-to-mxc.rs) example; there is no production -`openshell policy export-mxc` subcommand yet. +environment unless the gateway explicitly opts into another environment mode. + +When governed egress is disabled, any network rule fails closed during sandbox creation. -If governed egress is disabled, any network rule fails closed rather than launching without an enforcement path. +Parity and matrix tests under [`tests/`](tests/) cover the mapper on the Windows MSVC lane. The real-MXC lane also dry-runs every clipboard direction against the installed schema. The driver performs this mapping automatically; there is no separate policy-export command or example. -Parity and matrix tests under [`tests/`](tests/) cover the mapper on the Windows MSVC lane. The driver performs this mapping automatically; there is no separate policy-export command or example. +## Provider credential example -## Packaging the demo for the demo box +[`examples/run-provider-credential-test.ps1`](examples/run-provider-credential-test.ps1) +creates an MXC sandbox with an attached GitHub provider. Its policy explicitly +allows the graphical UI subsystem required by Windows PowerShell while denying +clipboard access and input injection; the existing policy mapper translates +that portable section to MXC's `ui` object. The probe verifies that the sandbox +sees a revision-scoped `GITHUB_TOKEN` placeholder, the host CONNECT proxy +substitutes it for `api.github.com`, and the same placeholder is rejected for a +different allowed endpoint. -Use [`examples/package-demo.ps1`](examples/package-demo.ps1) to assemble -the gateway EXE, CLI EXE, runtime DLLs (`libz3.dll`), `demo.yaml`, the -gateway config, and the runbook into one folder, then copy that folder to -the demo Windows host and follow `mxc-demo-runbook.md` inside it. The -script prints a SHA256 manifest so the operator can sanity-check what -landed before moving it. +This example uses `process_container`. The `IsoSessionApp.dll` and +`--features isolation_session` prerequisites above apply only to +`isolation_session` runs and are not required for this scenario. ## Real-MXC test lane @@ -163,6 +190,7 @@ exits 0 rather than failing. | Task | What it runs | When to use | |---|---|---| | `windows:test:mxc-real:x64` | `tests/wxc_exec_real.rs` — Tier-2 invoker tests with `--ignored --test-threads=1`, including an HTTPS request through the host proxy | Pre-merge on any Windows host that has `wxc-exec`; dry-run tests always pass; enforcement tests probe-gate themselves | +| `windows:test:mxc-real:arm64` | Native ARM64 `tests/wxc_exec_real.rs` with the same contract | Pre-merge on an ARM64 Windows host with `wxc-exec` | | `windows:e2e:mxc` | `examples/run-mxc-e2e.ps1` — Tier-3 scenario runner, real binary, probe-gated | Demo box / nightly; needs the gateway + CLI binaries in the script directory | | `windows:e2e:mxc:mock` | Same runner with `-Mock` — wiring-only, no real `wxc-exec` needed | Any Windows host (CI, dev machine); validates wiring and the network-reject scenario | @@ -183,12 +211,12 @@ powershell -NoProfile -ExecutionPolicy Bypass ` **Skip semantics:** tests in `wxc_exec_real.rs` are marked `#[ignore = "requires real wxc-exec"]` — the standard `windows:test:x64` suite never runs them. `OPENSHELL_WXC_EXEC_PATH` overrides the default -`C:\mxc\wxc-exec.exe` lookup. See `docs4gtb/mxc-box-capabilities.md` for the -empirical capability snapshot of the development box (build 26200, processcontainer -velocity keys not enabled, isolation_session absent). +`C:\mxc\wxc-exec.exe` lookup. Run the probe on the actual test host; a different +machine's capability report is not evidence that its backend is available here. ## Deferred work -- **Interactive exec/connect/forward** — gateway interactive-exec surgery (follow-on) +- **Interactive exec/connect** — gateway interactive-exec integration (follow-on); dynamic service forwarding is supported through the relay. +- **Persistent-session governed egress** remains fail-closed until `isolation_session` exposes an enforceable proxy path. - **Restart durability** (deprovision orphaned sessions on startup) → follow-on - **GPU passthrough** → not pursued in host-side-governance design diff --git a/crates/openshell-driver-mxc/examples/README-openclaw-forward.txt b/crates/openshell-driver-mxc/examples/README-openclaw-forward.txt new file mode 100644 index 0000000000..c3aa149896 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/README-openclaw-forward.txt @@ -0,0 +1,140 @@ +OpenShell MXC - OpenClaw + dynamic forward test (both backends) +======================================================================= + +WHAT THIS PROVES + The full path for reaching a service inside an MXC sandbox that has NO + in-sandbox supervisor process (ProcessContainer additionally has NO + inbound network capability at all): + gateway -> MXC driver -> ProcessContainer OR isolation_session sandbox + -> openshell-supervisor-relay launches OpenClaw's gateway inside it, + with no relay awareness in OpenClaw itself + -> `openshell forward service --target-port 18889` opens a fresh, + on-demand WebSocket relay for THIS call only (nothing pre-declared + in the config beyond the startup liveness port; the relay is torn + down when the forward ends) + -> a real OpenClaw client on the HOST, talking only through that + forwarded port, authenticates with a token and gets a real + "ok: true" health response. + + Pass -Backend process_container (default) or -Backend isolation_session. + Both exercise the exact same dynamic-forward/control-channel code path in + the driver -- only the gateway config differs (mxc-openclaw-gateway.toml + vs mxc-openclaw-isolation.toml). isolation_session is simpler to configure: + it merges the per-sandbox environment onto the inherited host environment rather than + replacing it, so none of ProcessContainer's pc_minimal_env / LOCALAPPDATA + workaround is needed -- see mxc-openclaw-isolation.toml's own comments for + what else differs (ProcessContainer-only fields it ignores entirely). + +PREREQUISITES (on this test box) + - An ELEVATED (Administrator) PowerShell session, for -Backend + process_container specifically. On this box's wxc-exec build, + process_container falls back to an "AppContainer + DACL" isolation + tier that needs two privileged operations: (1) WRITE_DAC on share_dir + to stamp the AppContainer's ACL -- fixable non-elevated if you own + share_dir yourself (first run wins ownership; icacls /setowner fixes a + folder an earlier elevated run left owned by Administrators), but + (2) with egress_proxy = true (mxc-openclaw-gateway.toml's default), + wxc-exec also calls NetworkIsolationSetAppContainerConfig to grant the + AppContainer a loopback exemption so it can reach the host's egress + proxy -- that Windows API requires Administrator regardless of file + ownership. Non-elevated fails both with ERROR_ACCESS_DENIED (0x5), the + second as "Network proxy error: Failed to set loopback exemption: + 0x00000005". -Backend isolation_session does not hit either path. + - wxc-exec.exe present (default expected: C:\mxc-kit\bin\wxc-exec.exe) + - process_container or isolation_session backend live (whichever -Backend + you pass) + - Your own OpenClaw install: a node.exe binary + the openclaw npm package + (the directory containing openclaw.mjs and its own node_modules). + Neither ships in this package -- point the script at your existing + install with -NodeExePath / -OpenClawInstallDir. Don't have one? Run + install-nodejs-openclaw.ps1 first (see below) -- it fetches both and + prints the exact paths to pass here. + - Windows has curl.exe / robocopy.exe built in (they do on Win10+). + - Outbound internet to nodejs.org and registry.npmjs.org, ONLY if you use + install-nodejs-openclaw.ps1 to fetch Node.js/OpenClaw. Not needed if you + already have both. + +DON'T HAVE NODE.JS / OPENCLAW YET? + powershell -NoProfile -ExecutionPolicy Bypass -File .\install-nodejs-openclaw.ps1 + Downloads a pinned, SHA256-verified Node.js build and installs the + "openclaw" package from the public npm registry, laid out exactly how this + test expects them. Prints the -NodeExePath / -OpenClawInstallDir values to + pass through. One-time step (or pass -Force to re-fetch); if you already + have a working install elsewhere, skip this and point directly at it. + +HOW TO RUN + 1. Open PowerShell in THIS folder. + 2. Run: + powershell -NoProfile -ExecutionPolicy Bypass -File .\run-openclaw-forward-test.ps1 ` + -WxcExecPath C:\mxc-kit\bin\wxc-exec.exe ` + -NodeExePath C:\path\to\node.exe ` + -OpenClawInstallDir C:\path\to\node_modules\openclaw + + Add -Backend isolation_session to exercise that backend instead of the + default process_container. + + The script COPIES your node.exe, the OpenClaw install, and this package's + own openclaw-capture.mjs / openshell-supervisor-relay.exe into a share_dir + (default C:\openshell-openclaw) before creating the sandbox -- the + AppContainer here can only read paths under share_dir, so everything the + sandboxed process touches has to live there. The OpenClaw copy uses + robocopy and only re-copies changed files on a rerun. + +WHAT YOU GET BACK + The script prints PASS/FAIL and creates: + results-openclaw-forward-.zip + Hand that zip back. It contains the transcript, gateway logs (including the + sandbox's own forwarded stdout/stderr), the `openshell forward service` + output, the raw OpenClaw health-check response, OpenClaw's own captured + log, and the exact config + policy used. + + The capture wrapper also makes one credential-free WebSocket handshake to + OpenClaw from inside the sandbox after the gateway reports ready. It records + only an outcome and response-byte count, never response content. This is a + diagnostic boundary check: a local response with a failed host-side health + check points at the sandbox-boundary/forward path; no local response points + at the sandboxed OpenClaw target. A `started-no-completion` outcome means + even the probe's bounded socket/timer callbacks stopped progressing after + OpenClaw reported ready, which is evidence of a blocked target event loop. + The diagnostic never changes the PASS/FAIL verdict, which still requires + the authenticated host-side OpenClaw client. + +FILES IN THIS PACKAGE + openshell-gateway.exe the gateway (self-contained; needs only VC++ runtime) + openshell.exe the CLI + openshell-supervisor-relay.exe generic spawn+relay-bridge binary the driver + launches inside the sandbox in place of + OpenClaw directly (OpenClaw itself has no + relay awareness) + openclaw-capture.mjs thin Node.js wrapper that appends the + sandboxed process's stdout/stderr to a log + file in share_dir and records only the + outcome/byte count of a credential-free + target-side self-probe (OpenShell's own + adapter code, not OpenClaw's) + mxc-openclaw-gateway.toml gateway/driver config (process_container, default) + mxc-openclaw-isolation.toml gateway/driver config (-Backend isolation_session) + mxc-openclaw-localnet.toml experimental alternate process_container config + (-UseLocalNetwork; currently non-functional, + see run-openclaw-forward-test.ps1's own comment) + openclaw-gateway.yaml sandbox policy (read-write grant to share_dir + only -- see the comment at its top for why) + run-openclaw-forward-test.ps1 the orchestrator you run + install-nodejs-openclaw.ps1 optional prerequisite: fetches Node.js + + OpenClaw if you don't already have them + README-openclaw-forward.txt this file + +NOTES + - The control plane between CLI and gateway runs with --disable-tls on + loopback (that's a separate test point, T2). This test's relay traffic + (host <-> sandbox) is a separate, unrelated WebSocket tunnel. + - A "supervisor session not connected" / ssh 255 message during sandbox + create is EXPECTED on MXC and harmless - the agent already ran in-driver. + - `pc_minimal_env = true` in mxc-openclaw-gateway.toml (process_container + only) means the sandboxed process gets ONLY the env vars passed by + run-openclaw-forward-test.ps1 to `sandbox create`. That includes the + non-obvious minimum Windows values needed for CreateProcessW, independent + of anything Node.js-specific. isolation_session does not need this mode. + - The relay is entirely on-demand: nothing is listening on any fixed host + port before you run `openshell forward service`, and nothing is left + listening after the forward process exits. diff --git a/crates/openshell-driver-mxc/examples/e2e-policies/openclaw-gateway.yaml b/crates/openshell-driver-mxc/examples/e2e-policies/openclaw-gateway.yaml new file mode 100644 index 0000000000..a41ddb21d6 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/e2e-policies/openclaw-gateway.yaml @@ -0,0 +1,49 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# openclaw-gateway.yaml - Sandbox policy for the openclaw gateway running in a ProcessContainer. +# +# node.js (openclaw) needs: +# - Read-write access to share_dir (C:/openshell-openclaw) for node.exe, +# the openclaw install, home/temp dirs, and log files -- granted +# explicitly below via read_write, since the policy is the only source +# of filesystem grants (the driver no longer adds gateway-configured +# host paths on its own). This AppContainer configuration has no other +# read-only grants, so everything the sandboxed process touches +# (including its own Node.js runtime and the OpenClaw package) must +# live under share_dir; see run-openclaw-forward-test.ps1's staging +# step. run-openclaw-forward-test.ps1 patches this path (alongside the +# per-sandbox cwd) when -ShareDir overrides the default below. +# - TCP socket binding on port 18889 (loopback) — governed by pc_capabilities. +# - Outbound TCP through the egress proxy — governed by egress_proxy in the TOML. +# - Win32k syscall access (ui.allow_graphical_ui) even though it never +# opens a real window: Node.js touches user32/gdi32 during its own +# startup and dies with STATUS_DLL_INIT_FAILED under the Win32k +# lockdown that applies by default when a policy has no `ui:` section, +# confirmed empirically against mxc-release-binaries-v0.8.0. +# +# The network policy is enforced by the OpenShell host CONNECT proxy. MXC's +# default-deny egress independently blocks direct Internet bypass attempts. +version: 1 + +filesystem_policy: + include_workdir: false + read_only: [] + read_write: + - "C:/openshell-openclaw" + +ui: + allow_graphical_ui: true + clipboard: none + allow_input_injection: false + +network_policies: + qualification_allowed: + name: qualification-allowed + endpoints: + - host: example.com + port: 443 + protocol: tcp + binaries: + # The harness replaces the default share root when -ShareDir is set. + - path: "C:/openshell-openclaw/node.exe" diff --git a/crates/openshell-driver-mxc/examples/e2e-policies/ws-agent.yaml b/crates/openshell-driver-mxc/examples/e2e-policies/ws-agent.yaml new file mode 100644 index 0000000000..6160be6f5c --- /dev/null +++ b/crates/openshell-driver-mxc/examples/e2e-policies/ws-agent.yaml @@ -0,0 +1,42 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# ws-agent.yaml - Sandbox policy for the WebSocket agent (mxc-ws-agent.exe, +# wrapped by openshell-supervisor-relay.exe -- see mxc-ws-gateway.toml). +# +# The wrapped server process only needs: +# - Execute access to mxc-ws-agent.exe / openshell-supervisor-relay.exe and +# their runtime DLLs (provided by the AppContainer inheriting access to +# system paths and share_dir). +# - TCP socket binding on port 22000 (governed by pc_capabilities in the +# gateway TOML, not by filesystem policy here). +# - Outbound TCP through the egress proxy, for openshell-supervisor-relay +# to dial the driver's on-demand relay — governed by egress_proxy in the +# TOML. +# - No writes to the host filesystem. +# +# workload directory (passed by run-ws-agent-test.ps1, default C:\work\openshell-mxc-ws) +# is granted explicitly below via read_only, since the policy is the only +# source of filesystem grants (the driver no longer adds gateway-configured +# host paths on its own) -- this makes the binary directory accessible even +# with an otherwise-empty filesystem_policy, without granting more than the +# no-writes-needed requirement above actually calls for. run-ws-agent-test.ps1 +# patches this path and the per-sandbox driver config when -AgentDir overrides +# the default below. +# +# This example intentionally omits network_policies, not because the driver +# would reject it: with egress_proxy = true (set in mxc-ws-gateway.toml), the +# driver takes the lossless split path (policy_map::split_policy) and +# delegates network_policies verbatim to the OpenShell host CONNECT proxy for +# enforcement -- an "info" loss item, not an error, so it would be accepted. +# (Only the no-proxy coarse path, or an unsupported rule shape, can turn a +# network_policies entry into a rejected "error" loss item -- see +# policy_map/map.rs.) This scenario just doesn't need host-enforced network +# rules beyond the loopback/pc_capabilities grant above. +version: 1 + +filesystem_policy: + include_workdir: false + read_only: + - "C:/work/openshell-mxc-ws" + read_write: [] diff --git a/crates/openshell-driver-mxc/examples/install-nodejs-openclaw.ps1 b/crates/openshell-driver-mxc/examples/install-nodejs-openclaw.ps1 new file mode 100644 index 0000000000..8e05b9f3f4 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/install-nodejs-openclaw.ps1 @@ -0,0 +1,157 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# install-nodejs-openclaw.ps1 - fetch a pinned Node.js and the OpenClaw npm +# package, laid out exactly how run-openclaw-forward-test.ps1 expects them. +# +# This is a PREREQUISITE step, not part of the test itself: run it once (or +# with -Force to re-fetch), then pass its printed -NodeExePath / +# -OpenClawInstallDir values straight through to run-openclaw-forward-test.ps1. +# If you already have a working Node.js + OpenClaw install elsewhere, you +# don't need this script at all -- just point run-openclaw-forward-test.ps1 at +# it directly. +# +# What it does: +# 1. Downloads the official Node.js Windows x64 zip build (no installer, no +# admin rights needed) for a pinned version, verifies its SHA256 against +# Node.js's published SHASUMS256.txt, and extracts it. +# 2. Uses that Node's bundled npm to install the "openclaw" package from the +# public npm registry into the same install directory (`npm install +# openclaw --prefix `), which lays it out at +# \node_modules\openclaw -- exactly what -OpenClawInstallDir expects. +# +# Usage: +# powershell -NoProfile -ExecutionPolicy Bypass -File .\install-nodejs-openclaw.ps1 +# powershell -NoProfile -ExecutionPolicy Bypass -File .\run-openclaw-forward-test.ps1 ` +# -WxcExecPath C:\mxc-kit\bin\wxc-exec.exe ` +# -NodeExePath C:\openshell-openclaw-install\node\node.exe ` +# -OpenClawInstallDir C:\openshell-openclaw-install\node_modules\openclaw +# +# Needs outbound internet access to nodejs.org and registry.npmjs.org. If +# your box only reaches the internet through a corporate proxy, set the usual +# HTTP_PROXY/HTTPS_PROXY env vars before running this script -- both +# Invoke-WebRequest and npm respect them. + +[CmdletBinding()] +param( + # Must be a DIRECT CHILD of a drive root (e.g. C:\openshell-openclaw-install, + # not C:\work\openshell-openclaw-install) for the SAME reason + # run-openclaw-forward-test.ps1's -ShareDir must be: nothing here actually + # runs inside the AppContainer, but keeping this path shape consistent + # avoids surprises if you ever point -ShareDir at this same location. + [string] $InstallDir = "C:\openshell-openclaw-install", + # Pinned to the version this package's OpenClaw scenario was validated + # against (observed working: Node.js v22.22.3). Override if you need a + # different one, but that combination is untested by this package. + [string] $NodeVersion = "22.22.3", + # Pinned to the version this package's OpenClaw scenario was validated + # against (the package actually exercised by run-openclaw-forward-test.ps1 + # across this repo's live testing). Override (or pass "" for whatever + # "npm install openclaw" resolves to latest at run time) for ad hoc testing, + # but that's untested by this package. + [string] $OpenClawVersion = "2026.7.1", + # Re-download/re-install even if InstallDir already looks populated. + [switch] $Force +) + +$ErrorActionPreference = "Stop" +$ProgressPreference = "SilentlyContinue" # Invoke-WebRequest is dramatically faster with the progress bar off. + +function Step([string]$m) { Write-Host "`n=== $m ===" -ForegroundColor Cyan } +function Info([string]$m) { Write-Host " $m" } +function Ok([string]$m) { Write-Host "[OK] $m" -ForegroundColor Green } +function Bad([string]$m) { Write-Host "[FAIL] $m" -ForegroundColor Red } + +$installDirNorm = $InstallDir.TrimEnd('\','/').Replace('/', '\') +$nodeDir = Join-Path $installDirNorm "node" +$nodeExe = Join-Path $nodeDir "node.exe" +$npmCmd = Join-Path $nodeDir "npm.cmd" +$openClawDir = Join-Path $installDirNorm "node_modules\openclaw" +$downloadDir = Join-Path $installDirNorm "_download" + +try { + Step "Node.js v$NodeVersion for win-x64" + if ((Test-Path $nodeExe) -and -not $Force) { + $existing = & $nodeExe --version + Info "already installed at $nodeExe (version $existing) -- pass -Force to re-fetch" + } else { + New-Item -ItemType Directory -Force $downloadDir | Out-Null + $distBase = "https://nodejs.org/dist/v$NodeVersion" + $zipName = "node-v$NodeVersion-win-x64.zip" + $zipPath = Join-Path $downloadDir $zipName + + Info "downloading $distBase/$zipName" + Invoke-WebRequest -Uri "$distBase/$zipName" -OutFile $zipPath + + Info "verifying SHA256 against $distBase/SHASUMS256.txt" + $shasums = Invoke-WebRequest -Uri "$distBase/SHASUMS256.txt" -UseBasicParsing | Select-Object -ExpandProperty Content + $expectedLine = ($shasums -split "`n") | Where-Object { $_ -match [regex]::Escape($zipName) } | Select-Object -First 1 + if (-not $expectedLine) { throw "no SHASUMS256.txt entry found for $zipName -- refusing to install an unverified download" } + $expectedHash = ($expectedLine -split '\s+')[0].Trim().ToLowerInvariant() + $actualHash = (Get-FileHash $zipPath -Algorithm SHA256).Hash.ToLowerInvariant() + if ($expectedHash -ne $actualHash) { + throw "SHA256 mismatch for $zipName`n expected: $expectedHash`n actual: $actualHash`nDeleting the download; do not use it." + } + Ok "SHA256 verified: $actualHash" + + Info "extracting to $nodeDir" + if (Test-Path $nodeDir) { Remove-Item -Recurse -Force $nodeDir } + $extractStaging = Join-Path $downloadDir "extract" + if (Test-Path $extractStaging) { Remove-Item -Recurse -Force $extractStaging } + Expand-Archive -Path $zipPath -DestinationPath $extractStaging -Force + # The zip's own top-level entry is "node-v-win-x64\..."; flatten + # that one level so callers get a stable \node\node.exe path + # regardless of version. + $innerDir = Get-ChildItem $extractStaging -Directory | Select-Object -First 1 + if (-not $innerDir) { throw "unexpected zip layout: no top-level directory found after extraction" } + Move-Item $innerDir.FullName $nodeDir + Remove-Item -Recurse -Force $extractStaging, $zipPath -ErrorAction SilentlyContinue + + if (-not (Test-Path $nodeExe)) { throw "extraction completed but $nodeExe is missing -- unexpected zip layout" } + $installedVersion = & $nodeExe --version + Ok "installed node.exe ($installedVersion) at $nodeExe" + } + + Step "OpenClaw (npm)" + if (-not (Test-Path $npmCmd)) { throw "npm.cmd not found next to node.exe at $npmCmd -- Node.js install looks incomplete" } + # This Node.js install is a standalone zip extraction, not the installer -- + # nothing put it on PATH. npm spawns pre/postinstall scripts (OpenClaw and + # some of its native-addon deps have them) via cmd.exe, and those scripts + # invoke bare "node"; without $nodeDir on PATH that fails with "'node' is + # not recognized...", which in turn makes npm's own cleanup of the + # half-installed tree fail with a wall of unrelated-looking EPERM rmdir + # warnings. Prepend $nodeDir to PATH for this call only. + $env:Path = "$nodeDir;$env:Path" + if ((Test-Path (Join-Path $openClawDir "openclaw.mjs")) -and -not $Force) { + Info "already installed at $openClawDir -- pass -Force to re-install" + } else { + $pkgSpec = if ($OpenClawVersion) { "openclaw@$OpenClawVersion" } else { "openclaw" } + Info "npm install $pkgSpec --prefix $installDirNorm" + # --no-save: this prefix dir isn't a real npm project (no package.json we + # want npm managing); we just want node_modules\openclaw populated. + & $npmCmd install $pkgSpec --prefix $installDirNorm --no-save --no-audit --no-fund + if ($LASTEXITCODE -ne 0) { throw "npm install failed (exit $LASTEXITCODE)" } + if (-not (Test-Path (Join-Path $openClawDir "openclaw.mjs"))) { + throw "npm install succeeded but $openClawDir\openclaw.mjs is missing -- is 'openclaw' really the right package name/layout on the registry you're using?" + } + Ok "installed OpenClaw at $openClawDir" + } + + Remove-Item -Recurse -Force $downloadDir -ErrorAction SilentlyContinue + + Step "Done" + Write-Host "" + Write-Host "Pass these to run-openclaw-forward-test.ps1:" -ForegroundColor Yellow + Write-Host " -NodeExePath `"$nodeExe`"" + Write-Host " -OpenClawInstallDir `"$openClawDir`"" + Write-Host "" + Write-Host "Example:" -ForegroundColor Yellow + Write-Host " powershell -NoProfile -ExecutionPolicy Bypass -File .\run-openclaw-forward-test.ps1 ``" + Write-Host " -WxcExecPath C:\mxc-kit\bin\wxc-exec.exe ``" + Write-Host " -NodeExePath `"$nodeExe`" ``" + Write-Host " -OpenClawInstallDir `"$openClawDir`"" +} +catch { + Bad $_.Exception.Message + exit 1 +} diff --git a/crates/openshell-driver-mxc/examples/mxc-github-provider-profile.yml b/crates/openshell-driver-mxc/examples/mxc-github-provider-profile.yml new file mode 100644 index 0000000000..49f8ee64e1 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/mxc-github-provider-profile.yml @@ -0,0 +1,29 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Test-only Windows variant of the built-in GitHub profile. MXC's host proxy +# uses the first per-sandbox command element as its static process identity, so +# this profile names inbox Windows PowerShell rather than the Linux gh/git paths +# in the production GitHub profile. + +id: mxc-github-e2e +display_name: MXC GitHub credential e2e +description: Test-only GitHub profile for the MXC provider credential example +category: source_control +credentials: + - name: api_token + description: GitHub token + env_vars: [GITHUB_TOKEN] + required: true + auth_style: bearer + header_name: authorization +discovery: + credentials: [api_token] +endpoints: + - host: api.github.com + port: 443 + protocol: rest + access: read-only + enforcement: enforce +binaries: + - "C:/Windows/System32/WindowsPowerShell/v1.0/powershell.exe" diff --git a/crates/openshell-driver-mxc/examples/mxc-openclaw-gateway.toml b/crates/openshell-driver-mxc/examples/mxc-openclaw-gateway.toml new file mode 100644 index 0000000000..b85617a216 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/mxc-openclaw-gateway.toml @@ -0,0 +1,55 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# mxc-openclaw-gateway.toml +# +# Runs OpenClaw's gateway inside an MXC ProcessContainer, with its port +# reachable from the host via dynamic `openshell forward service` bridging +# (openshell-supervisor-relay.exe + the driver's on-demand relay -- see +# crates/openshell-driver-mxc/src/relay.rs). There is no static/always-on +# bridge: every `forward service` call opens its own short-lived relay. +# +# Driven by run-openclaw-forward-test.ps1, which patches wxc_exec_path and +# stages node.exe / openclaw-capture.mjs / openshell-supervisor-relay.exe / +# the caller's OpenClaw install into the policy-authorized working directory +# (the AppContainer here can only read paths granted by policy -- see the +# script's "Stage artifacts into share_dir" step for why). + +[openshell.drivers.mxc] +wxc_exec_path = "C:\\mxc-kit\\bin\\wxc-exec.exe" + +backend = "process_container" +pc_capabilities = ["privateNetworkClientServer"] +pc_least_privilege = false +# UI capability is now a SandboxPolicy concern, not gateway TOML -- see +# openclaw-gateway.yaml's `ui: { allow_graphical_ui: true, ... }`. Needed +# because Node.js (OpenClaw's runtime) touches user32/gdi32 during its own +# startup and dies with STATUS_DLL_INIT_FAILED under the Win32k lockdown +# that applies when a policy has no `ui:` section, confirmed empirically +# against mxc-release-binaries-v0.8.0, even though this target never +# actually opens a window. + +# Egress proxy for outbound TCP connectivity. +egress_proxy = true +egress_proxy_addr = "127.0.0.1:18080" + +# Do not seed from host env; the harness supplies a curated per-sandbox env. +pc_minimal_env = true + +# The workload command, cwd, and verified-minimal environment are supplied per +# sandbox by run-openclaw-forward-test.ps1 through `sandbox create`. + +# Launch the per-sandbox command via the generic openshell-supervisor-relay +# binary. The driver sends the command/environment over the +# control channel once the spawner announces readiness (the "launch" +# handshake), and spawns OpenClaw with no relay awareness. pc_relay_target_port +# is OpenClaw's own +# --port above: used as an early liveness check (does the target ever bind +# it?) and as the target port openshell-supervisor-relay bridges by default. +# There is no static bridge -- relay bridging is entirely on-demand via +# ForwardSink::open_dynamic_forward / the control channel's "forward" op, +# driven by `openshell forward service --target-port 18889`. +pc_relay_spawner_path = "C:/openshell-openclaw/openshell-supervisor-relay.exe" +pc_relay_target_port = 18889 + +debug = true diff --git a/crates/openshell-driver-mxc/examples/mxc-openclaw-isolation.toml b/crates/openshell-driver-mxc/examples/mxc-openclaw-isolation.toml new file mode 100644 index 0000000000..b81f5b5565 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/mxc-openclaw-isolation.toml @@ -0,0 +1,44 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# mxc-openclaw-isolation.toml +# +# Like mxc-openclaw-gateway.toml, but on the isolation_session backend +# (persistent provision -> start -> exec session) instead of ProcessContainer +# (one-shot AppContainer). Dynamic forward relies on the same +# pc_relay_spawner_path / control-channel mechanism either way -- the driver +# computes spawner wrapping before branching on backend, so nothing about +# openshell-supervisor-relay or the relay protocol differs between the two. +# +# Two things ARE genuinely different from the ProcessContainer config, and +# both make this one simpler: +# - No pc_minimal_env: isolation_session merges the per-sandbox environment onto the +# full inherited host environment (PATH/SystemRoot kept) rather than +# REPLACING it, so none of ProcessContainer's curated-minimal-env / +# LOCALAPPDATA workaround is needed here. +# - No pc_capabilities / pc_least_privilege / pc_allow_local_network / +# pc_network_allow: those fields only apply to the ProcessContainer +# branch in driver.rs and are silently ignored here. Egress is +# default-allow for isolation_session. +# +# Driven by run-openclaw-forward-test.ps1 -Backend isolation_session, which +# patches wxc_exec_path and stages node.exe / openclaw-capture.mjs / +# openshell-supervisor-relay.exe / the caller's OpenClaw install into +# the sandbox's policy-authorized working directory before creating it. + +[openshell.drivers.mxc] +wxc_exec_path = "C:\\mxc-kit\\bin\\wxc-exec.exe" + +backend = "isolation_session" +default_configuration_id = "composable" + +# The workload command, cwd, and environment are supplied per sandbox by +# run-openclaw-forward-test.ps1 through `sandbox create`. + +# No static bridge -- relay bridging is entirely on-demand via +# ForwardSink::open_dynamic_forward / the control channel's "forward" op, +# driven by `openshell forward service --target-port 18889`. +pc_relay_spawner_path = "C:/openshell-openclaw/openshell-supervisor-relay.exe" +pc_relay_target_port = 18889 + +debug = true diff --git a/crates/openshell-driver-mxc/examples/mxc-openclaw-localnet.toml b/crates/openshell-driver-mxc/examples/mxc-openclaw-localnet.toml new file mode 100644 index 0000000000..f0e4c578d9 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/mxc-openclaw-localnet.toml @@ -0,0 +1,33 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# mxc-openclaw-localnet.toml +# Like mxc-openclaw-gateway.toml but uses allowLocalNetwork instead of egress_proxy. +# allowLocalNetwork=true lets the AppContainer reach the host's loopback (the gateway +# relay) without routing through the egress proxy, which breaks node.js DLL init. + +[openshell.drivers.mxc] +wxc_exec_path = "C:\\FromSenthil\\mxc-fixes-env-vars\\wxc-exec.exe" + +backend = "process_container" +pc_capabilities = ["privateNetworkClientServer"] +pc_least_privilege = false +pc_allow_local_network = true +# UI capability is now a SandboxPolicy concern, not gateway TOML -- see +# openclaw-gateway.yaml's `ui: { allow_graphical_ui: true, ... }`. Needed +# because Node.js (OpenClaw's runtime) touches user32/gdi32 during its own +# startup and dies with STATUS_DLL_INIT_FAILED under the Win32k lockdown +# that applies when a policy has no `ui:` section, confirmed empirically +# against mxc-release-binaries-v0.8.0, even though this target never +# actually opens a window. + +# The workload command, cwd, and environment are supplied per sandbox by +# run-openclaw-forward-test.ps1 through `sandbox create`. + +# No static bridge -- relay bridging is entirely on-demand via +# ForwardSink::open_dynamic_forward / the control channel's "forward" op, +# driven by `openshell forward service --target-port 18889`. +pc_relay_spawner_path = "C:/openshell-openclaw/openshell-supervisor-relay.exe" +pc_relay_target_port = 18889 + +debug = true diff --git a/crates/openshell-driver-mxc/examples/mxc-provider-credential-policy.yaml b/crates/openshell-driver-mxc/examples/mxc-provider-credential-policy.yaml new file mode 100644 index 0000000000..cc6809fe70 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/mxc-provider-credential-policy.yaml @@ -0,0 +1,36 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Base policy for the MXC provider-credential example. The attached provider +# profile contributes the api.github.com rule. This base rule separately allows +# github.com so the probe can prove credential endpoint binding independently of +# network admission: traffic is allowed, but use of GITHUB_TOKEN is not. Keeping +# the negative probe within GitHub avoids risking a token leak to an unrelated +# service if the behavior under test regresses. + +version: 1 + +ui: + # Windows PowerShell loads USER32 during startup and therefore requires the + # graphical UI subsystem. Clipboard and input injection remain denied. + allow_graphical_ui: true + clipboard: none + allow_input_injection: false + +filesystem_policy: + include_workdir: false + read_only: [] + read_write: + - "C:/work/openshell-mxc-provider" + +network_policies: + credential_endpoint_mismatch_probe: + name: credential-endpoint-mismatch-probe + endpoints: + - host: github.com + port: 443 + protocol: rest + access: read-only + enforcement: enforce + binaries: + - path: "C:/Windows/System32/WindowsPowerShell/v1.0/powershell.exe" diff --git a/crates/openshell-driver-mxc/examples/mxc-provider-credential-probe.ps1 b/crates/openshell-driver-mxc/examples/mxc-provider-credential-probe.ps1 new file mode 100644 index 0000000000..56477c2342 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/mxc-provider-credential-probe.ps1 @@ -0,0 +1,175 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# In-sandbox probe for the MXC provider-credential example. The probe verifies +# that GITHUB_TOKEN is a revision-scoped placeholder before making any network +# request, then exercises authorized substitution and endpoint mismatch. + +[CmdletBinding()] +param( + [string] $OutputDir = "C:\work\openshell-mxc-provider" +) + +$ErrorActionPreference = "Stop" +$PSNativeCommandUseErrorActionPreference = $false +$utf8NoBom = New-Object System.Text.UTF8Encoding($false) +$resultPath = Join-Path $OutputDir "mxc-provider-credential-result.txt" + +function Complete-Probe([string[]] $Checks) { + $passed = @($Checks | Where-Object { $_.StartsWith("[FAIL]") }).Count -eq 0 + $lines = @($Checks) + "OVERALL: $(if ($passed) { 'PASS' } else { 'FAIL' })" + $summary = ($lines -join "`n") + "`n" + [System.IO.File]::WriteAllText($resultPath, $summary, $utf8NoBom) + Write-Output $summary.TrimEnd() + if ($passed) { exit 0 } else { exit 1 } +} + +function Get-SafeProbeText([string] $Text, [string] $Token) { + if ([string]::IsNullOrEmpty($Text)) { return $Text } + + $safe = $Text + if (-not [string]::IsNullOrEmpty($Token)) { + $safe = $safe.Replace($Token, "") + } + $safe = [regex]::Replace( + $safe, + '(?i)(authorization:\s*bearer\s+)\S+', + '$1' + ) + $safe = ($safe -replace '\r?\n', ' | ').Trim() + if ($safe.Length -gt 512) { + $safe = $safe.Substring(0, 512) + "..." + } + return $safe +} + +function Invoke-CurlProbe( + [string] $CurlPath, + [string] $Url, + [string] $BodyPath, + [string] $CaBundle, + [string] $Token +) { + Remove-Item $BodyPath -Force -ErrorAction SilentlyContinue + + # Windows inbox curl uses Schannel, which ignores CURL_CA_BUNDLE as an + # environment variable. Pass the host proxy's generated bundle explicitly. + # Windows PowerShell also turns native stderr into ErrorRecord objects; use + # Continue locally and merge stderr into memory so curl failures become + # structured probe results. Avoid redirecting stderr to the shared folder: + # a file created by an earlier sandbox can carry a different AppContainer + # SID and cause PowerShell to throw UnauthorizedAccessException before curl + # starts. + $nativeOutput = @() + $exitCode = -1 + $previous = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { + $nativeOutput = @(& $CurlPath ` + --silent ` + --show-error ` + --connect-timeout 15 ` + --max-time 60 ` + --cacert $CaBundle ` + --ssl-revoke-best-effort ` + --output $BodyPath ` + --write-out "%{http_code}" ` + --header "Authorization: Bearer $Token" ` + --header "Accept: application/vnd.github+json" ` + --header "User-Agent: openshell-mxc-provider-credential-example" ` + $Url 2>&1) + $exitCode = $LASTEXITCODE + } finally { + $ErrorActionPreference = $previous + } + $stdout = @($nativeOutput | Where-Object { + $_ -isnot [System.Management.Automation.ErrorRecord] + } | ForEach-Object { $_.ToString() }) + $nativeErrors = @($nativeOutput | Where-Object { + $_ -is [System.Management.Automation.ErrorRecord] + } | ForEach-Object { $_.Exception.Message }) + $httpCode = ($stdout -join "").Trim() + $stderr = Get-SafeProbeText ` + -Text ($nativeErrors -join [Environment]::NewLine) ` + -Token $Token + $body = if (Test-Path $BodyPath) { + [System.IO.File]::ReadAllText($BodyPath, [System.Text.Encoding]::UTF8) + } else { + "" + } + $errorText = if ($exitCode -eq 0) { + $null + } elseif ([string]::IsNullOrWhiteSpace($stderr)) { + "curl exited $exitCode" + } else { + $stderr + } + + return [pscustomobject]@{ + HttpCode = $httpCode + Body = $body + Error = $errorText + } +} + +$checks = @() +$stage = "environment validation" +try { + $token = $env:GITHUB_TOKEN + if ([string]::IsNullOrWhiteSpace($token)) { + $checks += "[FAIL] GITHUB_TOKEN is unavailable" + Complete-Probe $checks + } + if ($token -notmatch '^openshell:resolve:env:v[0-9]+_GITHUB_TOKEN$') { + $checks += "[FAIL] MXC did not receive a revision-scoped GITHUB_TOKEN placeholder" + $checks += "[INFO] no network request was attempted" + Complete-Probe $checks + } + + $checks += "[PASS] MXC received only a revision-scoped GITHUB_TOKEN placeholder" + $curlPath = Join-Path $env:SystemRoot "System32\curl.exe" + if (-not (Test-Path $curlPath)) { + $checks += "[FAIL] inbox curl.exe is unavailable" + Complete-Probe $checks + } + $caBundle = $env:CURL_CA_BUNDLE + if ([string]::IsNullOrWhiteSpace($caBundle) -or -not (Test-Path $caBundle)) { + $checks += "[FAIL] host proxy CA bundle is unavailable" + Complete-Probe $checks + } + $checks += "[PASS] host proxy CA bundle is available to inbox curl" + + $stage = "api.github.com request" + $github = Invoke-CurlProbe ` + -CurlPath $curlPath ` + -Url "https://api.github.com/user" ` + -BodyPath (Join-Path $OutputDir "github-user-response.json") ` + -CaBundle $caBundle ` + -Token $token + if ($null -eq $github.Error -and $github.HttpCode -eq "200" -and $github.Body.Contains('"login"')) { + $checks += "[PASS] GitHub accepted the credential rewritten by the host CONNECT proxy (HTTP 200)" + } else { + $errorText = if ($null -eq $github.Error) { "none" } else { $github.Error } + $checks += "[FAIL] authenticated GitHub request failed (http=$($github.HttpCode), error=$errorText)" + } + + $stage = "github.com endpoint-mismatch request" + $mismatch = Invoke-CurlProbe ` + -CurlPath $curlPath ` + -Url "https://github.com/" ` + -BodyPath (Join-Path $OutputDir "credential-mismatch-response.json") ` + -CaBundle $caBundle ` + -Token $token + if ($null -eq $mismatch.Error -and $mismatch.HttpCode -eq "403" -and $mismatch.Body.Contains("credential_endpoint_mismatch")) { + $checks += "[PASS] proxy rejected placeholder use outside the GitHub binding (HTTP 403 credential_endpoint_mismatch)" + } else { + $errorText = if ($null -eq $mismatch.Error) { "none" } else { $mismatch.Error } + $checks += "[FAIL] endpoint-mismatch request was not rejected as expected (http=$($mismatch.HttpCode), error=$errorText)" + } +} catch { + # Report only the stage and exception type. Exception messages can echo + # native command arguments, and this probe must never persist credentials. + $checks += "[FAIL] probe encountered an unexpected error during $stage ($($_.Exception.GetType().Name))" +} + +Complete-Probe $checks diff --git a/crates/openshell-driver-mxc/examples/mxc-provider-credential.toml b/crates/openshell-driver-mxc/examples/mxc-provider-credential.toml new file mode 100644 index 0000000000..65cd4a21c1 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/mxc-provider-credential.toml @@ -0,0 +1,18 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Gateway configuration for run-provider-credential-test.ps1. The runner writes +# a disposable copy with the requested wxc-exec path. + +[openshell.drivers.mxc] +wxc_exec_path = "C:\\mxc-kit\\bin\\wxc-exec.exe" +backend = "process_container" +default_configuration_id = "composable" + +# The split mapper gives MXC a loopback redirect and the driver starts the +# per-sandbox host CONNECT proxy that enforces L4/L7 policy and rewrites +# provider credential placeholders. +egress_proxy = true +egress_proxy_addr = "127.0.0.1:18080" + +debug = false diff --git a/crates/openshell-driver-mxc/examples/mxc-ws-agent.rs b/crates/openshell-driver-mxc/examples/mxc-ws-agent.rs new file mode 100644 index 0000000000..dbd63f1426 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/mxc-ws-agent.rs @@ -0,0 +1,622 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! WebSocket demo agent for `OpenShell` MXC `ProcessContainer`. +//! +//! **Active role: `server`** — a plain WebSocket echo server on port 22000, +//! used by `run-ws-agent-test.ps1` as the target application. It is launched +//! directly as `agent_command` and wrapped by `openshell-supervisor-relay.exe` +//! (see `mxc-ws-gateway.toml`'s `pc_relay_spawner_path`/`pc_relay_target_port`) +//! for connectivity, exactly like the `OpenClaw` scenario wraps `node.exe` -- +//! `openshell forward service --target-port 22000` opens an on-demand relay +//! for a host client to reach it. `server` has no relay awareness at all. +//! +//! **Legacy roles: `spawner` and `proxy-for`** — implement an older, +//! *removed* static-relay protocol (`pc_relay_port` config field + a +//! `reverse-relay-addr.txt` file the driver would write into `share_dir` +//! before sandbox creation). The driver no longer supports this: `mxc.rs`/ +//! `driver.rs` have no code path that binds `pc_relay_port` or writes that +//! file, so these modes cannot work against the current driver -- kept in +//! this file only as a historical reference for the pre-dynamic-forward +//! design, not exercised by any current test. + +use std::net::SocketAddr; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::Duration; + +use futures::{SinkExt, StreamExt}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::sync::oneshot; +use tokio_tungstenite::tungstenite::Message; + +const WS_PORT: u16 = 22000; + +// ── Entry point ─────────────────────────────────────────────────────────────── + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let mode = std::env::args().nth(1).unwrap_or_default(); + match mode.as_str() { + "spawner" => spawner().await, + "server" => server().await, + "proxy-for" => { + // proxy-for : start the command in agent-cmd.txt as a child + // process, wait for it to bind , connect outward to the + // gateway relay, and bridge bidirectionally. Used to expose an + // arbitrary WebSocket server (e.g. openclaw gateway) to host + // clients via the OpenShell relay without modifying that server. + let port = std::env::args() + .nth(2) + .and_then(|s| s.parse::().ok()) + .expect("Usage: mxc-ws-agent proxy-for "); + proxy_for(port).await + } + other => { + eprintln!("mxc-ws-agent: unknown mode {other:?}. Use 'spawner' or 'server'."); + std::process::exit(2); + } + } +} + +// ── AppContainer SID (Windows only) ────────────────────────────────────────── + +/// Returns the `AppContainer` SID string of the current process, or `None` if +/// not running in an `AppContainer`. Written to `appcontainer-sid.txt` in the +/// share dir as a diagnostic aid. +#[cfg(windows)] +#[allow(unsafe_code)] // Windows token-query FFI is confined to this diagnostic helper. +fn appcontainer_sid() -> Option { + use std::ptr; + + #[link(name = "advapi32")] + unsafe extern "system" { + fn OpenProcessToken(process: isize, access: u32, token: *mut isize) -> i32; + fn GetTokenInformation( + token: isize, + class: i32, + info: *mut u8, + len: u32, + ret_len: *mut u32, + ) -> i32; + fn ConvertSidToStringSidW(sid: *const u8, str_sid: *mut *mut u16) -> i32; + } + #[link(name = "kernel32")] + unsafe extern "system" { + fn GetCurrentProcess() -> isize; + fn LocalFree(mem: *mut u8) -> *mut u8; + fn CloseHandle(handle: isize) -> i32; + } + + // SAFETY: output buffers remain alive through each call; successful token + // information contains a SID pointer into that buffer. The SID conversion + // returns a NUL-terminated allocation freed with LocalFree, and the owned + // token handle is closed on every return path. + unsafe { + let proc = GetCurrentProcess(); + let mut token: isize = 0; + if OpenProcessToken(proc, 0x0008, &raw mut token) == 0 { + return None; + } + let mut buf = [0u8; 256]; + let mut ret_len: u32 = 0; + let ok = GetTokenInformation(token, 31, buf.as_mut_ptr(), 256, &raw mut ret_len); + if ok == 0 { + CloseHandle(token); + return None; + } + let sid_ptr = ptr::read_unaligned(buf.as_ptr().cast::<*const u8>()); + if sid_ptr.is_null() { + CloseHandle(token); + return None; + } + let mut wide_ptr: *mut u16 = ptr::null_mut(); + if ConvertSidToStringSidW(sid_ptr, &raw mut wide_ptr) == 0 || wide_ptr.is_null() { + CloseHandle(token); + return None; + } + let mut len = 0; + while *wide_ptr.add(len) != 0 { + len += 1; + } + let slice = std::slice::from_raw_parts(wide_ptr, len); + let result = String::from_utf16_lossy(slice); + LocalFree(wide_ptr.cast()); + CloseHandle(token); + if result.is_empty() { + None + } else { + Some(result) + } + } +} + +#[cfg(not(windows))] +fn appcontainer_sid() -> Option { + None +} + +// ── Signal / relay address helpers ──────────────────────────────────────────── + +fn exe_dir() -> anyhow::Result { + Ok(std::env::current_exe()? + .parent() + .ok_or_else(|| anyhow::anyhow!("exe has no parent dir"))? + .to_path_buf()) +} + +fn signal_file_path() -> anyhow::Result { + Ok(exe_dir()?.join("openshell-shutdown.signal")) +} + +/// Read a file written by the host (ASCII, possibly with UTF-8 BOM from +/// `PowerShell` Set-Content -Encoding UTF8) and return the trimmed string. +fn read_host_file(path: &std::path::Path) -> Option { + std::fs::read_to_string(path) + .ok() + .map(|s| s.trim_start_matches('\u{FEFF}').trim().to_string()) + .filter(|s| !s.is_empty()) +} + +// ── Spawner ─────────────────────────────────────────────────────────────────── + +/// Process #1 — the sandbox `agent_command`. +/// +/// Responsibilities: +/// 1. Spawns the server subprocess and holds its stdin pipe. +/// 2. Waits for the server to bind its port (ws-server-started.txt). +/// 3. Reads reverse-relay-addr.txt and starts the relay proxy bridge. +/// 4. Polls for the shutdown signal file; on detection kills the server and exits. +/// +/// The server is a plain WebSocket application with no relay knowledge. +async fn spawner() -> anyhow::Result<()> { + let exe = std::env::current_exe()?; + let dir = exe_dir()?; + let signal = signal_file_path()?; + + // Remove stale files from a previous run. ws-server-started.txt in + // particular must go too: it encodes the server's port, and a stale copy + // would let the readiness wait below observe an old run's port instead + // of actually waiting for this run's server to (re)bind. + let _ = std::fs::remove_file(&signal); + if let Ok(dir) = exe_dir() { + let _ = std::fs::remove_file(dir.join("relay-ready.txt")); + let _ = std::fs::remove_file(dir.join("ws-server-started.txt")); + } + + // Write AppContainer SID for diagnostic use. + if let Some(sid) = appcontainer_sid() { + let _ = std::fs::write(dir.join("appcontainer-sid.txt"), &sid); + eprintln!("[spawner] AppContainer SID: {sid}"); + } else { + eprintln!("[spawner] not running in an AppContainer (no SID)"); + } + + // Spawn the server. + let mut cmd = tokio::process::Command::new(&exe); + cmd.arg("server") + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()); + + let mut child = cmd.spawn()?; + let _pipe = child.stdin.take(); // keep write-end alive + eprintln!("[spawner] server started (pid {:?})", child.id()); + + // Wait up to 30 s for the server to write its startup marker, then start + // the relay proxy. We launch the proxy in a background task so the main + // lifecycle loop can still react to shutdown signals and server exit. + let relay_addr_file = dir.join("reverse-relay-addr.txt"); + let server_marker = dir.join("ws-server-started.txt"); + + let server_ready_deadline = tokio::time::Instant::now() + Duration::from_secs(30); + loop { + if server_marker.exists() { + break; + } + if tokio::time::Instant::now() >= server_ready_deadline { + eprintln!("[spawner] server did not start within 30 s; relay proxy skipped"); + break; + } + tokio::time::sleep(Duration::from_millis(200)).await; + } + + // Read the local server port from ws-server-started.txt ("port="). + let local_ws_port = read_host_file(&server_marker) + .and_then(|s| s.strip_prefix("port=").and_then(|n| n.parse::().ok())) + .unwrap_or(WS_PORT); + + // Start the relay proxy bridge if the gateway relay address is available. + let mut relay_task = read_host_file(&relay_addr_file).map_or_else( + || { + eprintln!("[spawner] no reverse-relay-addr.txt; relay proxy disabled"); + None + }, + |relay_addr| { + let local_url = format!("ws://127.0.0.1:{local_ws_port}"); + let relay_url = format!("ws://{relay_addr}"); + eprintln!("[spawner] starting relay proxy: {relay_url} <-> {local_url}"); + + let (bridge_stop_tx, bridge_stop_rx) = oneshot::channel::<()>(); + tokio::spawn(run_relay_proxy(relay_url, local_url, bridge_stop_rx)); + Some(bridge_stop_tx) + }, + ); + + // Main lifecycle loop: server exit or shutdown signal. + loop { + tokio::select! { + status = child.wait() => { + let code = status.map_or(1, |s| s.code().unwrap_or(1)); + eprintln!("[spawner] server exited with code {code}"); + let _ = std::fs::remove_file(&signal); + std::process::exit(code); + } + () = tokio::time::sleep(Duration::from_millis(500)) => { + if signal.exists() { + eprintln!("[spawner] shutdown signal -- killing server"); + // Stop the relay proxy first so the relay connection closes + // cleanly before we tear down the server. + if let Some(tx) = relay_task.take() { let _ = tx.send(()); } + let _ = child.kill().await; + let _ = child.wait().await; + let _ = std::fs::remove_file(&signal); + eprintln!("[spawner] done"); + std::process::exit(0); + } + } + } + } +} + +// ── Relay proxy bridge ──────────────────────────────────────────────────────── + +/// Connect outward to the gateway relay and inward to the local server, then +/// bridge WebSocket messages bidirectionally until either side closes or the +/// shutdown signal fires. +/// +/// The local server is a plain WebSocket application. The spawner acts as a +/// transparent proxy between the gateway relay and the local server, so the +/// server needs no knowledge of the relay. +async fn run_relay_proxy(relay_url: String, local_url: String, mut stop_rx: oneshot::Receiver<()>) { + const LOCAL_CONNECT_ATTEMPTS: u32 = 15; + const LOCAL_CONNECT_TIMEOUT: Duration = Duration::from_millis(500); + const LOCAL_CONNECT_BACKOFF: Duration = Duration::from_millis(300); + // Connect to the gateway relay (outbound via egress_proxy). + let relay_ws = match tokio_tungstenite::connect_async(&relay_url).await { + Ok((ws, _)) => { + eprintln!("[spawner] relay connected: {relay_url}"); + ws + } + Err(e) => { + let msg = format!("relay connect failed: {e}"); + eprintln!("[spawner] {msg}"); + if let Ok(dir) = exe_dir() { + let _ = std::fs::write(dir.join("relay-debug.txt"), &msg); + } + return; + } + }; + + // Connect to the local server (AppContainer-internal loopback), with + // retries. The in-sandbox server and the spawner's own AppContainer + // network-permission state can still be settling when this fires, so the + // first attempt can race the server's listen() call or a brief + // AppContainer network-policy warmup window. Without retry, a lost race + // manifests as a ~20s OS-level connect timeout (os error 10060) rather + // than an instant refusal, because the SYN is silently dropped, not + // rejected -- so each retry attempt uses a short timeout instead of + // waiting out that OS timeout on every try. + let mut local_ws = None; + let mut last_err = String::new(); + for attempt in 1..=LOCAL_CONNECT_ATTEMPTS { + match tokio::time::timeout( + LOCAL_CONNECT_TIMEOUT, + tokio_tungstenite::connect_async(&local_url), + ) + .await + { + Ok(Ok((ws, _))) => { + eprintln!( + "[spawner] local server connected: {local_url} (attempt {attempt}/{LOCAL_CONNECT_ATTEMPTS})" + ); + local_ws = Some(ws); + break; + } + Ok(Err(e)) => last_err = e.to_string(), + Err(_) => last_err = format!("timed out after {LOCAL_CONNECT_TIMEOUT:?}"), + } + eprintln!( + "[spawner] local server connect attempt {attempt}/{LOCAL_CONNECT_ATTEMPTS} failed ({last_err}); retrying" + ); + if attempt < LOCAL_CONNECT_ATTEMPTS { + tokio::time::sleep(LOCAL_CONNECT_BACKOFF).await; + } + } + let Some(local_ws) = local_ws else { + let msg = format!( + "local server connect failed after {LOCAL_CONNECT_ATTEMPTS} attempts ({local_url}): {last_err}" + ); + eprintln!("[spawner] {msg}"); + if let Ok(dir) = exe_dir() { + let _ = std::fs::write(dir.join("relay-debug.txt"), &msg); + } + return; + }; + + let (mut relay_write, mut relay_read) = relay_ws.split(); + let (mut local_write, mut local_read) = local_ws.split(); + + eprintln!("[spawner] relay proxy bridge active"); + + // Write a marker so the host can wait until the bridge is fully connected + // before sending the first message. + if let Ok(dir) = exe_dir() { + let _ = std::fs::write(dir.join("relay-ready.txt"), b"ok"); + } + + loop { + tokio::select! { + // Relay -> local server + msg = relay_read.next() => match msg { + Some(Ok(Message::Text(t))) => { + if local_write.send(Message::Text(t)).await.is_err() { break; } + } + Some(Ok(Message::Binary(b))) => { + if local_write.send(Message::Binary(b)).await.is_err() { break; } + } + Some(Ok(Message::Close(_))) | None => { + eprintln!("[spawner] relay closed"); + break; + } + Some(Ok(_)) => {} // ping/pong + Some(Err(e)) => { + eprintln!("[spawner] relay read error: {e}"); + break; + } + }, + // Local server -> relay + msg = local_read.next() => match msg { + Some(Ok(Message::Text(t))) => { + if relay_write.send(Message::Text(t)).await.is_err() { break; } + } + Some(Ok(Message::Binary(b))) => { + if relay_write.send(Message::Binary(b)).await.is_err() { break; } + } + Some(Ok(Message::Close(_))) | None => { + eprintln!("[spawner] local server closed"); + break; + } + Some(Ok(_)) => {} + Some(Err(e)) => { + eprintln!("[spawner] local server read error: {e}"); + break; + } + }, + _ = &mut stop_rx => { + eprintln!("[spawner] relay proxy stopped by shutdown"); + break; + } + } + } + + eprintln!("[spawner] relay proxy bridge exited"); +} + +// ── Server ──────────────────────────────────────────────────────────────────── + +/// Process #2 — a plain WebSocket echo server. +/// +/// This is a stand-in for any real application. It has no knowledge of any +/// relay -- launched directly as the `agent_command` `openshell-supervisor- +/// relay.exe` wraps (see mxc-ws-gateway.toml's `pc_relay_spawner_path`/ +/// `pc_relay_target_port`), the same way `OpenClaw`'s gateway is. Runs until +/// killed; there is no cooperative shutdown protocol to implement (the +/// generic spawner just kills its target on shutdown, same as any other +/// wrapped process), so this loops on `listener.accept()` alone. +async fn server() -> anyhow::Result<()> { + let listener = TcpListener::bind(("0.0.0.0", WS_PORT)).await?; + eprintln!("[server] WebSocket listening on 0.0.0.0:{WS_PORT}"); + + let active = Arc::new(AtomicUsize::new(0)); + + loop { + let (stream, addr) = listener.accept().await?; + let active2 = active.clone(); + active2.fetch_add(1, Ordering::Relaxed); + tokio::spawn(async move { + handle_connection(stream, addr).await; + active2.fetch_sub(1, Ordering::Relaxed); + }); + } +} + +// ── WebSocket connection handler ────────────────────────────────────────────── + +async fn handle_connection(stream: TcpStream, addr: SocketAddr) { + eprintln!("[server] new connection from {addr}"); + + let ws = match tokio_tungstenite::accept_async(stream).await { + Ok(ws) => ws, + Err(e) => { + eprintln!("[server] handshake failed from {addr}: {e}"); + return; + } + }; + + let (mut write, mut read) = ws.split(); + + while let Some(msg) = read.next().await { + match msg { + Ok(Message::Text(text)) => { + eprintln!("[server] {addr} recv: {text}"); + if write.send(Message::Text(text)).await.is_err() { + break; + } + } + Ok(Message::Binary(bin)) => { + if write.send(Message::Binary(bin)).await.is_err() { + break; + } + } + Ok(Message::Close(_)) => break, + Ok(_) => {} + Err(e) => { + eprintln!("[server] {addr} error: {e}"); + break; + } + } + } + + eprintln!("[server] connection from {addr} ended"); +} + +// ── proxy-for mode ──────────────────────────────────────────────────────────── + +/// Start the command listed in `agent-cmd.txt` in the share dir as a child +/// process, wait for it to accept TCP on `port`, then connect outward to the +/// gateway relay and bridge all WebSocket traffic to/from the local server. +/// +/// This lets any WebSocket server (e.g. openclaw gateway) be exposed to host +/// clients via the `OpenShell` relay without any changes to that server. +async fn proxy_for(port: u16) -> anyhow::Result<()> { + // Early sentinel: write to exe_dir so it works in any container directory. + if let Ok(exe) = std::env::current_exe() + && let Some(d) = exe.parent() + { + let _ = std::fs::write( + d.join("proxy-for-started.txt"), + format!("port={port} exe={}", exe.display()), + ); + } + + let dir = match exe_dir() { + Ok(d) => d, + Err(e) => { + let _ = std::fs::write( + "C:\\work\\openshell-mxc-openclaw\\proxy-for-error.txt", + format!("exe_dir failed: {e}"), + ); + return Err(e); + } + }; + let signal = signal_file_path()?; + let _ = std::fs::remove_file(&signal); + if let Ok(d) = exe_dir() { + let _ = std::fs::remove_file(d.join("relay-ready.txt")); + } + + // Read the command to launch from agent-cmd.txt in the share dir. + // Each line is one argument; the first line is the executable. + let cmd_file = dir.join("agent-cmd.txt"); + let mut child = if cmd_file.exists() { + let lines: Vec = std::fs::read_to_string(&cmd_file)? + .lines() + .map(|l| l.trim_start_matches('\u{FEFF}').trim().to_string()) + .filter(|l| !l.is_empty()) + .collect(); + if lines.is_empty() { + anyhow::bail!("agent-cmd.txt is empty"); + } + let stdout_file = std::fs::File::create(dir.join("agent-stdout.txt")).ok(); + let stderr_file = std::fs::File::create(dir.join("agent-stderr.txt")).ok(); + // If agent-env.txt exists in the share dir, set the child process env + // explicitly (clear parent env, then set only those vars). This allows + // running runtimes like node.js that fail with STATUS_DLL_INIT_FAILED + // when presented with the full host env, while mxc-ws-agent itself + // (the parent) still runs with the full env it needs. + // agent-env.txt format: one KEY=VALUE per line. + let env_file = dir.join("agent-env.txt"); + let mut cmd = tokio::process::Command::new(&lines[0]); + cmd.args(&lines[1..]); + if env_file.exists() + && let Ok(content) = std::fs::read_to_string(&env_file) + { + let child_env: Vec<(String, String)> = content + .lines() + .map(|l| l.trim_start_matches('\u{FEFF}').trim().to_string()) + .filter(|l| !l.is_empty() && l.contains('=')) + .filter_map(|l| { + let pos = l.find('=')?; + Some((l[..pos].to_string(), l[pos + 1..].to_string())) + }) + .collect(); + eprintln!( + "[proxy-for] using {} child env vars from agent-env.txt", + child_env.len() + ); + cmd.env_clear().envs(child_env); + } + cmd.stdout( + stdout_file.map_or_else(std::process::Stdio::inherit, std::process::Stdio::from), + ) + .stderr(stderr_file.map_or_else(std::process::Stdio::inherit, std::process::Stdio::from)); + eprintln!("[proxy-for] starting: {}", lines.join(" ")); + Some(cmd.spawn()?) + } else { + eprintln!("[proxy-for] no agent-cmd.txt; assuming server already running on port {port}"); + None + }; + + // Wait up to 60 s for the server to accept TCP on `port`. + eprintln!("[proxy-for] waiting for server on 127.0.0.1:{port} ..."); + let deadline = tokio::time::Instant::now() + Duration::from_mins(1); + loop { + if TcpStream::connect(format!("127.0.0.1:{port}")) + .await + .is_ok() + { + eprintln!("[proxy-for] server is up on port {port}"); + break; + } + if tokio::time::Instant::now() >= deadline { + anyhow::bail!("[proxy-for] timeout waiting for server on port {port}"); + } + tokio::time::sleep(Duration::from_millis(500)).await; + // Also check for early child exit + if let Some(ref mut c) = child + && let Ok(Some(status)) = c.try_wait() + { + anyhow::bail!("[proxy-for] child exited early: {status}"); + } + } + + // Connect relay and run bridge. + let relay_addr_file = dir.join("reverse-relay-addr.txt"); + if let Some(relay_addr) = read_host_file(&relay_addr_file) { + let relay_url = format!("ws://{relay_addr}"); + let local_url = format!("ws://127.0.0.1:{port}"); + eprintln!("[proxy-for] relay bridge: {relay_url} <-> {local_url}"); + let (bridge_stop_tx, bridge_stop_rx) = oneshot::channel::<()>(); + tokio::spawn(run_relay_proxy(relay_url, local_url, bridge_stop_rx)); + + // Main lifecycle: child exit or shutdown signal. + loop { + tokio::select! { + status = async { + if let Some(ref mut c) = child { c.wait().await.ok() } else { std::future::pending().await } + } => { + eprintln!("[proxy-for] child exited: {status:?}"); + let _ = std::fs::remove_file(&signal); + break; + } + () = tokio::time::sleep(Duration::from_millis(500)) => { + if signal.exists() { + eprintln!("[proxy-for] shutdown signal -- stopping"); + let _ = bridge_stop_tx.send(()); + if let Some(ref mut c) = child { let _ = c.kill().await; let _ = c.wait().await; } + let _ = std::fs::remove_file(&signal); + break; + } + } + } + } + } else { + eprintln!("[proxy-for] no reverse-relay-addr.txt; relay bridge disabled"); + // Still manage child lifecycle. + if let Some(mut c) = child { + let _ = c.wait().await; + } + } + Ok(()) +} diff --git a/crates/openshell-driver-mxc/examples/mxc-ws-gateway.toml b/crates/openshell-driver-mxc/examples/mxc-ws-gateway.toml new file mode 100644 index 0000000000..afd04a44cd --- /dev/null +++ b/crates/openshell-driver-mxc/examples/mxc-ws-gateway.toml @@ -0,0 +1,66 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# mxc-ws-gateway.toml - Gateway config for the WebSocket agent test. +# +# Used by run-ws-agent-test.ps1. Fields that contain host-specific paths +# (wxc_exec_path and pc_relay_spawner_path) +# are patched at test runtime by the script; the values below are +# safe-to-commit placeholders. +# +# Layout: +# sandbox command = mxc-ws-agent.exe server +# Binds WebSocket on 0.0.0.0:22000, echoes messages back. +# No relay awareness -- launched the same way OpenClaw's +# gateway is in mxc-openclaw-gateway.toml. +# +# pc_relay_spawner_path / pc_relay_target_port +# Wrap the sandbox command in openshell-supervisor-relay.exe +# instead of launching it directly (see driver.rs's +# launch handshake). This is what gives the driver a +# control channel into the sandbox, which dynamic +# forwarding depends on. +# +# Connectivity: entirely on-demand via `openshell forward service +# --target-port 22000` (ForwardSink::open_dynamic_forward / the control +# channel's "forward" op) -- there is no static/always-on bridge and no +# port pre-declared in this config beyond pc_relay_target_port's own +# startup liveness check. +# +# Shutdown sequence on `sandbox delete`: +# driver sends "shutdown" over the control channel (see driver.rs) +# openshell-supervisor-relay kills the server directly and exits +# driver also kills wxc-exec as a backstop regardless + +[openshell.drivers.mxc] +# Path to wxc-exec.exe. Patched at runtime by run-ws-agent-test.ps1. +# Leave commented for mock-mode smoke tests (pass -Mock to the script). +wxc_exec_path = "C:\\mxc\\wxc-exec.exe" + +# One-shot AppContainer backend: genuinely default-deny at the OS level. +backend = "process_container" + +# AppContainer capability required for the server to bind a TCP socket on +# 0.0.0.0:22000. "privateNetworkClientServer" allows the sandbox to act as +# both a client and a server on private (home/work/loopback) networks. +pc_capabilities = ["privateNetworkClientServer"] + +# process_container only: keep standard privilege level (not LPA). +pc_least_privilege = false + +# Egress proxy for outbound TCP connectivity -- required for +# openshell-supervisor-relay to dial out to the driver's on-demand relay +# (see mxc-openclaw-gateway.toml, which uses the same pattern). +egress_proxy = true +egress_proxy_addr = "127.0.0.1:18080" + +# The workload command and cwd are supplied per sandbox by +# run-ws-agent-test.ps1 through `sandbox create --driver-config-json`. + +# Launch the per-sandbox command via the generic openshell-supervisor-relay binary. +# Patched at runtime by run-ws-agent-test.ps1. +pc_relay_spawner_path = "C:/work/openshell-mxc-ws/openshell-supervisor-relay.exe" +pc_relay_target_port = 22000 + +# Enable for verbose wxc-exec output during debugging. +debug = true diff --git a/crates/openshell-driver-mxc/examples/openclaw-capture.mjs b/crates/openshell-driver-mxc/examples/openclaw-capture.mjs new file mode 100644 index 0000000000..eb32f624c0 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/openclaw-capture.mjs @@ -0,0 +1,202 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// openclaw-capture.mjs - generic launcher/log-capture shim for running an +// arbitrary Node.js entry point as the per-sandbox command inside an MXC +// ProcessContainer sandbox. +// +// This is OpenShell's own adapter code, not part of OpenClaw -- it contains +// no OpenClaw-specific logic. It exists because the sandboxed process's own +// stdout/stderr are piped (not inherited) by openshell-supervisor-relay (see +// pc_relay_spawner_path), which forwards them to the gateway log tagged +// "[target stdout]"/"[target stderr]" -- but a durable on-disk log inside +// share_dir is also useful for post-hoc debugging without re-running. +// +// Required env vars (set via `openshell sandbox create --env`): +// NEMOCLAW_MXC_CAPTURE_ENTRY absolute path to the real entry .mjs to run +// (e.g. /openclaw.mjs) +// NEMOCLAW_MXC_CAPTURE_LOG absolute path to append captured output to +// +// Usage: node openclaw-capture.mjs +// Equivalent to: node , except stdout +// and stderr are also appended to NEMOCLAW_MXC_CAPTURE_LOG as they're written. + +import fs, { appendFileSync } from "node:fs"; +import { syncBuiltinESMExports } from "node:module"; +import { createConnection } from "node:net"; +import { pathToFileURL } from "node:url"; +import { promisify } from "node:util"; + +// Node's promises realpath implementation uses a native Windows binding that +// requests privileges unavailable to AppContainer tokens. The callback +// implementation has the same realpath contract without those privileges. +// Patch before importing OpenClaw so node:fs/promises consumers see it too. +if (process.platform === "win32") { + fs.promises.realpath = promisify(fs.realpath); + syncBuiltinESMExports(); +} + +const required = (name) => { + const value = process.env[name]; + if (!value) throw new Error(name + " is required"); + return value; +}; + +const entry = required("NEMOCLAW_MXC_CAPTURE_ENTRY"); +const logPath = required("NEMOCLAW_MXC_CAPTURE_LOG"); +const selfProbePort = process.env.NEMOCLAW_MXC_CAPTURE_SELF_PROBE_PORT; + +const append = (label, value) => { + try { + appendFileSync(logPath, "[" + label + "] " + String(value), "utf8"); + } catch { + // best-effort; never let logging failure take down the wrapped process + } +}; + +const connectProbe = (host, port, timeoutMs = 5000) => + new Promise((resolve) => { + let settled = false; + const socket = createConnection({ host, port: Number(port) }); + const finish = (connected, detail) => { + if (settled) return; + settled = true; + clearTimeout(deadline); + socket.destroy(); + resolve({ connected, detail }); + }; + const deadline = setTimeout(() => finish(false, "timeout"), timeoutMs); + socket.once("connect", () => finish(true, "connected")); + socket.once("error", (error) => + finish(false, String(error?.code || error?.message || error)), + ); + }); + +const fetchProbe = async (url, timeoutMs = 15000) => { + try { + const response = await fetch(url, { + redirect: "manual", + signal: AbortSignal.timeout(timeoutMs), + }); + await response.body?.cancel(); + return { connected: true, detail: "status=" + response.status }; + } catch (error) { + return { + connected: false, + detail: String(error?.cause?.code || error?.code || error?.message || error), + }; + } +}; + +const runEgressProof = async () => { + if (process.env.NEMOCLAW_MXC_EGRESS_PROOF !== "1") return; + const result = { + proxyConfigured: Boolean(process.env.HTTPS_PROXY || process.env.https_proxy), + allowedViaProxy: await fetchProbe(required("NEMOCLAW_MXC_EGRESS_ALLOWED_URL")), + deniedViaProxy: await fetchProbe(required("NEMOCLAW_MXC_EGRESS_DENIED_URL")), + directInternetBypass: await connectProbe( + required("NEMOCLAW_MXC_EGRESS_DIRECT_HOST"), + 443, + ), + unrelatedHostLoopback: await connectProbe( + "127.0.0.1", + required("NEMOCLAW_MXC_EGRESS_LOOPBACK_PORT"), + ), + }; + append("egress-proof", JSON.stringify(result) + "\n"); + console.log("[egress-proof] " + JSON.stringify(result)); +}; + +let selfProbeStarted = false; +let readinessWindow = ""; + +const startSelfProbe = () => { + if (selfProbeStarted || !selfProbePort) return; + selfProbeStarted = true; + append("self-probe-attempt", "started\n"); + + const port = Number(selfProbePort); + if (!Number.isSafeInteger(port) || port < 1 || port > 65535) { + append("self-probe", "invalid_port\n"); + return; + } + + const maxAttempts = 6; + let attempt = 0; + const probe = () => { + attempt += 1; + let responseBytes = 0; + let settled = false; + const socket = createConnection({ host: "127.0.0.1", port }); + const finish = (outcome, errorCode = "none") => { + if (settled) return; + settled = true; + clearTimeout(deadline); + socket.destroy(); + if (responseBytes > 0 || attempt >= maxAttempts) { + append( + "self-probe", + `outcome=${responseBytes > 0 ? "response" : outcome} response_bytes=${responseBytes} attempts=${attempt} error_code=${errorCode}\n`, + ); + } else { + setTimeout(probe, 1000); + } + }; + const deadline = setTimeout(() => finish("timeout"), 2000); + + socket.once("connect", () => { + // This is the same protocol boundary exercised by the host-side health + // client, but it intentionally sends no token or other credential. Any + // HTTP or WebSocket response proves the target can service a connection + // from inside the ProcessContainer; payload content is never recorded. + socket.write( + `GET / HTTP/1.1\r\nHost: 127.0.0.1:${port}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: b3BlbnNoZWxsLW14YyE=\r\nSec-WebSocket-Version: 13\r\n\r\n`, + ); + }); + socket.on("data", (chunk) => { + responseBytes += chunk.length; + finish("response"); + }); + socket.once("error", (error) => + finish("error", String(error?.code || "unknown").replace(/[^A-Z0-9_-]/gi, "_")), + ); + socket.once("close", () => { + if (!settled) finish(responseBytes > 0 ? "response" : "closed"); + }); + }; + probe(); +}; + +const wrap = (stream, label) => { + const original = stream.write.bind(stream); + stream.write = (chunk, encoding, callback) => { + const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk); + append(label, text); + // Logging libraries may split one rendered line across multiple writes + // (and may insert ANSI sequences), so detect readiness across chunk + // boundaries rather than requiring one exact write call. + readinessWindow = (readinessWindow + text).slice(-512); + if (/\[gateway\][\s\S]{0,256}ready/.test(readinessWindow)) startSelfProbe(); + return original(chunk, encoding, callback); + }; +}; + +wrap(process.stdout, "stdout"); +wrap(process.stderr, "stderr"); +append("self-probe", selfProbePort ? "configured\n" : "disabled\n"); +if (selfProbePort) { + // Readiness normally triggers the probe immediately. Keep a delayed + // fallback because some logging stacks bypass or split stdout writes in a + // way the wrapper cannot observe reliably. + setTimeout(startSelfProbe, 10000); +} +process.on("uncaughtExceptionMonitor", (error) => + append("uncaught", String(error?.stack || error) + "\n"), +); +process.on("unhandledRejection", (error) => + append("rejection", String(error?.stack || error) + "\n"), +); + +process.argv = [process.execPath, entry, ...process.argv.slice(2)]; +await runEgressProof(); +await import(pathToFileURL(entry).href); diff --git a/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 b/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 index 46536ff4a3..0cefca119b 100644 --- a/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 +++ b/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 @@ -146,6 +146,7 @@ $policyDir = Join-Path $here "e2e-policies" $cmdExe = "C:\Windows\System32\cmd.exe" $demoDirFwd = $DemoDir.Replace('\', '/') +$defaultDemoDir = "C:\work\openshell-mxc-e2e" $roSrc = "$DemoDir-ro-src" # matches e2e-policies/fs-readonly.yaml read_only path $denyProbe = "$DemoDir-deny-probe" # ungranted, NOT the share: used to prove default-deny @@ -515,6 +516,19 @@ try { continue } + # Render a disposable policy for every scenario. The source YAML + # intentionally carries the documented default paths, while + # -DemoDir is a supported override. Both the read-write path and + # the read-only sibling share the same default prefix, so one + # exact prefix substitution keeps their relative naming intact. + $policyUsed = Join-Path $resultDir "policy.$($sc.Name).yaml" + $policyText = Get-Content $sc.PolicyFile -Raw + $policyText = $policyText.Replace( + $defaultDemoDir.Replace('\', '/'), + $DemoDir.Replace('\', '/') + ) + Set-Content -Path $policyUsed -Value $policyText -Encoding UTF8 + # Per-scenario gateway logs land in the bundle under their own names. $gwLog = Join-Path $resultDir "gateway.$($sc.Name).log" $gwErrLog = Join-Path $resultDir "gateway.$($sc.Name).err.log" @@ -547,7 +561,8 @@ try { Render-Toml # Preserve the exact rendered config + policy fixture used for this scenario. Copy-Item $toml (Join-Path $resultDir "mxc-gateway.$($sc.Name).toml") -Force -ErrorAction SilentlyContinue - Copy-Item $sc.PolicyFile (Join-Path $resultDir "policy.$($sc.Name).yaml") -Force -ErrorAction SilentlyContinue + # policyUsed already lives in the result bundle and is the exact + # rendered policy passed to OpenShell. $gw = Start-Gw Info "gateway pid $($gw.Id)" @@ -563,7 +578,7 @@ try { try { $createResult = Invoke-NativeCaptured $cli @( "sandbox", "create", "--name", $sandboxName, - "--policy", [string]$sc.PolicyFile, + "--policy", [string]$policyUsed, "--driver-config-json", $driverConfig, "--no-tty" ) diff --git a/crates/openshell-driver-mxc/examples/run-openclaw-forward-test.ps1 b/crates/openshell-driver-mxc/examples/run-openclaw-forward-test.ps1 new file mode 100644 index 0000000000..6935b68754 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/run-openclaw-forward-test.ps1 @@ -0,0 +1,722 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# run-openclaw-forward-test.ps1 - OpenClaw-over-MXC test (both backends). +# +# Proves the full path this package exists to demonstrate: +# gateway -> MXC driver -> ProcessContainer OR isolation_session sandbox +# (neither has an in-sandbox supervisor process; ProcessContainer also +# has no inbound network capability at all) +# -> openshell-supervisor-relay launches OpenClaw's gateway inside it +# -> `openshell forward service --target-port 18889` opens a per-request, +# on-demand WebSocket relay (bound fresh for this call, torn down when +# it ends -- there is no always-on bridge) +# -> a real OpenClaw client (`openclaw gateway health`) on the HOST, +# talking through that forwarded port, authenticates and gets a real +# response. +# +# -Backend selects which MXC backend to exercise (default: process_container). +# Both go through the exact same dynamic-forward/control-channel code path in +# the driver -- spawner wrapping is computed before the backend branch, so +# nothing about openshell-supervisor-relay or the relay protocol differs. +# What DOES differ is the config: isolation_session merges the sandbox env onto the +# full host environment (no pc_minimal_env / LOCALAPPDATA workaround needed) +# and ignores ProcessContainer-only fields like pc_capabilities entirely -- +# see mxc-openclaw-isolation.toml's own comments. +# +# This test brings its OWN OpenClaw install (node.exe + the openclaw npm +# package) rather than shipping one: point -NodeExePath and +# -OpenClawInstallDir at your existing install. The AppContainer here can +# only read paths under share_dir, so this script STAGES (copies) your +# node.exe, the openclaw package, and this package's own +# openclaw-capture.mjs / openshell-supervisor-relay.exe into share_dir before +# creating the sandbox -- see the "Stage artifacts" step below. The OpenClaw +# package can be large (native-addon plugins etc.); the copy uses robocopy +# and only re-copies changed files on a rerun. +# +# Run from inside the package folder (gateway + cli + openshell-supervisor- +# relay.exe + mxc-openclaw-gateway.toml + openclaw-gateway.yaml + +# openclaw-capture.mjs + this script all sit together): +# +# powershell -NoProfile -ExecutionPolicy Bypass -File .\run-openclaw-forward-test.ps1 ` +# -WxcExecPath C:\mxc-kit\bin\wxc-exec.exe ` +# -NodeExePath C:\path\to\node.exe ` +# -OpenClawInstallDir C:\path\to\node_modules\openclaw + +[CmdletBinding()] +param( + [string] $WxcExecPath = "C:\mxc-kit\bin\wxc-exec.exe", + # Your existing Node.js binary. Copied (not run in place) into share_dir -- + # the AppContainer cannot read paths outside it. + [Parameter(Mandatory = $true)] + [string] $NodeExePath, + # Root directory of your OpenClaw npm package install -- the directory that + # directly contains openclaw.mjs and its own node_modules. Copied + # (recursively, via robocopy) into share_dir\runtime\node_modules\openclaw. + [Parameter(Mandatory = $true)] + [string] $OpenClawInstallDir, + # Must be a DIRECT CHILD of a drive root (e.g. C:\openshell-openclaw, not + # C:\work\openshell-openclaw). Node's CommonJS module resolver calls + # fs.realpathSync while resolving the entry script, which lstat()s every + # parent directory up the chain -- including ones OUTSIDE share_dir. The + # AppContainer only grants share_dir itself, so an intermediate parent like + # C:\work fails with EPERM (confirmed empirically: this exact test failed + # with "EPERM: operation not permitted, lstat 'C:\work'" until the share + # dir was moved to the drive root). The drive root itself (C:\) apparently + # doesn't need an explicit grant to lstat successfully, so a one-level path + # sidesteps the problem entirely. + [string] $ShareDir = "C:\openshell-openclaw", + [int] $TargetPort = 18889, + [int] $ForwardLocalPort = 28889, + [int] $Port = 17670, + [string] $GatewayName = "openshell-mxc-openclaw", + [string] $GatewayToken = "openshell-mxc-test-token", + # Sandbox name. Default is UNIQUE per run (openclaw-$PID): ProcessContainer + # sandboxes are one-shot, but a leftover from a killed prior run can still + # collide with `sandbox create` by name. Not backend-suffixed: sandbox + # names are capped at 19 chars (observed: "name exceeds maximum length (20 + # > 19)"), and "openclaw-$PID" alone is already close to that budget. + [string] $SandboxName = "", + [switch] $KeepRunning, + # Which MXC backend to exercise. process_container: one-shot AppContainer, + # no inbound network capability, needs pc_minimal_env's curated sandbox env + # (mxc-openclaw-gateway.toml). isolation_session: persistent + # provision/start/exec session, merges the sandbox env onto the full host env, + # ignores ProcessContainer-only fields (mxc-openclaw-isolation.toml). + [ValidateSet("process_container", "isolation_session")] + [string] $Backend = "process_container", + # Use mxc-openclaw-localnet.toml (pc_allow_local_network=true) instead of + # mxc-openclaw-gateway.toml (egress_proxy=true), to test whether traffic + # through the egress_proxy shim was responsible for a data-plane failure + # seen on one corp-managed machine (clean TCP connect + WS handshake, then + # silently dropped bytes). VERIFIED BROKEN as an escape hatch on the + # currently-used wxc-exec build, though: pc_allow_local_network does not + # actually let the sandbox reach the gateway's relay at all here -- + # `relay connect failed: ... actively refused it (os error 10061)` on + # every attempt, a hard connectivity failure, not the subtler data-drop + # this switch was meant to test around. Left in for whoever investigates + # next (a different wxc-exec build may behave differently), but don't + # expect it to work today. + [switch] $UseLocalNetwork +) + +$ErrorActionPreference = "Stop" +$PSNativeCommandUseErrorActionPreference = $false + +# The OpenShell CLI emits UTF-8 (status glyphs like Ok/× and checkmarks). PowerShell +# decodes captured native-command output using [Console]::OutputEncoding; if that is a +# legacy OEM code page the glyphs render as mojibake. Force UTF-8. +try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch {} +$OutputEncoding = [System.Text.Encoding]::UTF8 + +$here = if ($PSScriptRoot) { $PSScriptRoot } else { (Get-Location).Path } + +if ([string]::IsNullOrWhiteSpace($SandboxName)) { + $SandboxName = "openclaw-$PID" +} + +$stamp = Get-Date -Format "yyyyMMdd-HHmmss" +$resultDir = Join-Path $here "results-openclaw-forward-$stamp" +New-Item -ItemType Directory -Force $resultDir | Out-Null +Start-Transcript -Path (Join-Path $resultDir "transcript.txt") -Force | Out-Null + +function Step([string]$m) { Write-Host "`n=== $m ===" -ForegroundColor Cyan } +function Info([string]$m) { Write-Host " $m" } + +# ProcessContainer teardown on this box has been observed to leave the +# sandboxed node.exe (and occasionally openshell-supervisor-relay.exe) +# running for a few seconds after `sandbox delete` returns success -- long +# enough to still hold a lock on share_dir\node.exe when the NEXT run tries +# to re-stage it. Retry with backoff rather than failing outright, since +# "run this script again right after the last run" is a completely normal +# thing to do. +function Copy-ItemRetry([string]$src, [string]$dst, [int]$attempts = 10, [int]$delayMs = 1000) { + for ($i = 1; $i -le $attempts; $i++) { + try { Copy-Item $src $dst -Force; return } catch { + if ($i -eq $attempts) { throw } + Info "copy '$dst' locked (attempt $i/$attempts): $($_.Exception.Message) -- retrying in $($delayMs)ms" + Start-Sleep -Milliseconds $delayMs + } + } +} +function Ok([string]$m) { Write-Host "[OK] $m" -ForegroundColor Green } +function Bad([string]$m) { Write-Host "[FAIL] $m" -ForegroundColor Red } + +function Grant-AppContainerWritableDirectory([string]$Path) { + # AppContainer access is a dual check: the generated package SID grant from + # MXC is necessary, but OpenClaw's SQLite staging also needs the two built-in + # application-package group SIDs. Scope inherited Modify access to the + # disposable writable data directories; never grant it to staged binaries. + & "$env:SystemRoot\System32\icacls.exe" $Path /grant ` + '*S-1-15-2-1:(OI)(CI)(M)' ` + '*S-1-15-2-2:(OI)(CI)(M)' /T /C /Q | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "failed to prepare AppContainer DACL for '$Path'" + } +} + +# Present the EXPECTED-on-MXC `sandbox create` outcomes as information rather +# than raw CLI error text -- see run-ollama-test.ps1 for the same pattern and +# rationale (ProcessContainer has no in-sandbox shell to attach to; a +# leftover sandbox from a prior run is cleared and recreated). + +# Returns $true when $out's content matches one of the known-benign +# MXC `sandbox create` patterns (post-create attach skipped / stale sandbox +# recreated), $false when it contains anything else -- the caller uses this +# plus the exit code to decide whether to stop instead of silently sailing +# into a 90s readiness wait that can only time out uninformatively. +function Show-SandboxCreate([object]$out, [string]$name) { + $lines = @($out | ForEach-Object { [string]$_ } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + if ($lines.Count -eq 0) { return $true } + $attachPat = '(?i)cannot exec in sandbox|no in-sandbox supervisor|has no interactive shell|supervisor session not connected|ssh exited with status' + $existsPat = '(?i)already exists|delete it first' + $joined = $lines -join "`n" + $hasOther = @($lines | Where-Object { $_ -notmatch $attachPat -and $_ -notmatch $existsPat }).Count -gt 0 + if ($joined -match $attachPat -and -not $hasOther) { + Info "sandbox '$name' created; agent ran in-driver. ProcessContainer has no in-sandbox shell, so the post-create attach was skipped (expected, not an error)." + return $true + } elseif ($joined -match $existsPat -and -not $hasOther) { + Info "sandbox '$name': a leftover from a prior run was cleared and recreated (expected, not an error)." + return $true + } else { + $lines | ForEach-Object { Info $_ } + return -not $hasOther + } +} + +$gateway = Join-Path $here "openshell-gateway.exe" +$cli = Join-Path $here "openshell.exe" +$relayExe = Join-Path $here "openshell-supervisor-relay.exe" +$policy = Join-Path $here "e2e-policies\openclaw-gateway.yaml" +if ($UseLocalNetwork -and $Backend -eq "isolation_session") { + throw "-UseLocalNetwork only applies to -Backend process_container (it swaps in pc_allow_local_network, a ProcessContainer-only field; isolation_session already has default-allow egress and needs no such override)." +} +$tomlName = switch ($Backend) { + "isolation_session" { "mxc-openclaw-isolation.toml" } + default { if ($UseLocalNetwork) { "mxc-openclaw-localnet.toml" } else { "mxc-openclaw-gateway.toml" } } +} +$tomlBaseName = [System.IO.Path]::GetFileNameWithoutExtension($tomlName) +$toml = Join-Path $here $tomlName +$captureScript = Join-Path $here "openclaw-capture.mjs" + +$shareDirNorm = $ShareDir.TrimEnd('\','/').Replace('/', '\') +# Must be a direct child of a drive root (see the -ShareDir param doc for +# why: Node's module resolver lstat()s ungranted parent dirs otherwise). +# Enforced here too so a bad path fails fast instead of silently breaking +# node's resolver deep into the run, or -- worse -- widening the stale- +# process prefix match below to something unexpectedly shallow. +if ($shareDirNorm -notmatch '^[A-Za-z]:\\[^\\]+$') { + throw "ShareDir must be a direct child of a drive root (e.g. C:\openshell-openclaw), got '$shareDirNorm'" +} +$openClawStageDir = Join-Path $shareDirNorm "runtime\node_modules\openclaw" + +$gw = $null +$gwLog = Join-Path $resultDir "gateway.log" +$gwErrLog = Join-Path $resultDir "gateway.err.log" +$fwdProc = $null +$fwdLog = Join-Path $resultDir "forward.log" +$fwdErrLog = Join-Path $resultDir "forward.err.log" +$passed = $false +$healthJson = $null +$selfProbeOutcome = "not-recorded" +$selfProbeResponseBytes = 0 + +try { + # 1. Validate package artifacts + caller-supplied paths. + Step "Validate artifacts" + Info "backend: $Backend -- network mode: $(if ($UseLocalNetwork) { 'pc_allow_local_network (bypasses egress_proxy for the relay hop)' } else { 'default' }) -- config: $tomlName" + foreach ($f in @($gateway, $cli, $relayExe, $policy, $toml, $captureScript)) { + if (-not (Test-Path $f)) { throw "missing artifact: $f (run from inside the package folder)" } + Info "found $(Split-Path $f -Leaf)" + } + if (-not (Test-Path $WxcExecPath)) { throw "wxc-exec not found at '$WxcExecPath'. Pass -WxcExecPath." } + if (-not (Test-Path $NodeExePath)) { throw "node.exe not found at '$NodeExePath'. Pass -NodeExePath." } + if (-not (Test-Path $OpenClawInstallDir)) { throw "OpenClaw install dir not found at '$OpenClawInstallDir'. Pass -OpenClawInstallDir." } + $openClawEntry = Join-Path $OpenClawInstallDir "openclaw.mjs" + if (-not (Test-Path $openClawEntry)) { throw "expected an OpenClaw entry point at '$openClawEntry' -- is -OpenClawInstallDir the package root (the dir containing openclaw.mjs)?" } + Info "machine : $env:COMPUTERNAME user: $env:USERNAME PS: $($PSVersionTable.PSVersion)" + + # 2. Patch a DISPOSABLE copy of the TOML in the results dir (never mutate the + # tracked source config in place). + Step "Patch gateway config (disposable copy)" + $tomlText = Get-Content $toml -Raw + $escaped = $WxcExecPath.Replace('\', '\\') + $tomlText = [regex]::Replace($tomlText, '(?m)^\s*#?\s*wxc_exec_path\s*=.*$', "wxc_exec_path = `"$escaped`"") + # The shipped TOMLs hardcode the default share dir only in the relay spawner + # path. Workload command/cwd/env are supplied per sandbox below. + $defaultShareDirToml = "C:/openshell-openclaw" + $shareDirToml = $shareDirNorm.Replace('\', '/') + if ($shareDirToml -ne $defaultShareDirToml) { + $tomlText = $tomlText.Replace($defaultShareDirToml, $shareDirToml) + } + $tomlUsed = Join-Path $resultDir "${tomlBaseName}.used.toml" + Set-Content $tomlUsed -Value $tomlText -Encoding UTF8 + # The policy's read_write grant is the only source of filesystem access + # now (the driver no longer adds share_dir automatically) -- it hardcodes + # the same default share dir literal as the TOML, so it needs the same + # -ShareDir substitution, or an overridden share_dir loses its grant + # entirely and every sandboxed file access fails closed. + $policyText = Get-Content $policy -Raw + if ($shareDirToml -ne $defaultShareDirToml) { + $policyText = $policyText.Replace($defaultShareDirToml, $shareDirToml) + } + if ($Backend -eq "isolation_session") { + # isolation_session advertises no UI-policy support, so the gateway + # rejects an explicit `ui:` section before provisioning even starts + # (see README.md's Capability Matrix). Strip it from this backend's + # disposable copy -- process_container is the only backend that needs + # it (Node.js touches user32/gdi32 at startup even though it never + # opens a window). + $policyText = [regex]::Replace($policyText, '(?ms)^ui:\r?\n(?:^[ \t].*\r?\n?)*', '') + } + $policyUsed = Join-Path $resultDir "openclaw-gateway.used.yaml" + Set-Content $policyUsed -Value $policyText -Encoding UTF8 + + # 3. Port free (auto-clear our own stale gateway). + Step "Check gateway port $Port is free" + $busy = Get-NetTCPConnection -State Listen -LocalPort $Port -ErrorAction SilentlyContinue + if ($busy) { + $owner = Get-Process -Id $busy.OwningProcess -ErrorAction SilentlyContinue + if ($owner -and $owner.Name -eq "openshell-gateway") { + Info "stopping stale gateway pid $($owner.Id)"; Stop-Process -Id $owner.Id -Force -ErrorAction SilentlyContinue; Start-Sleep 2 + } else { throw "port $Port in use by '$($owner.Name)' (pid $($busy.OwningProcess))" } + } + Ok "port $Port free" + + # 4. Stage artifacts into share_dir. The AppContainer here has a read-write + # grant on share_dir ONLY (see openclaw-gateway.yaml) -- no read-only + # grants on arbitrary host paths -- so node.exe, this package's + # openclaw-capture.mjs and openshell-supervisor-relay.exe, and your + # OpenClaw install must all physically live under share_dir. + Step "Stage artifacts into share_dir ($shareDirNorm)" + # A prior run's sandboxed processes can outlive `sandbox delete` by more + # than a few seconds -- sometimes indefinitely, if that run's own teardown + # hit a transport error talking to an already-stopped gateway. Rather than + # retry a locked copy indefinitely, find and kill anything still running + # out of share_dir before touching it. Copy-ItemRetry (below) remains as a + # short-window fallback for the ordinary "just exited, handle not released + # yet" case. + # Trailing separator anchors the match to "inside $shareDirNorm", not just + # "starts with the same characters" -- without it, a sibling directory like + # C:\openshell-openclaw-old would also match C:\openshell-openclaw. + $shareDirPrefix = $shareDirNorm.TrimEnd('\') + '\' + $stale = Get-Process -ErrorAction SilentlyContinue | Where-Object { $_.Path -and $_.Path.StartsWith($shareDirPrefix, [System.StringComparison]::OrdinalIgnoreCase) } + foreach ($p in $stale) { + Info "killing stale process from a prior run: $($p.ProcessName) (pid $($p.Id), $($p.Path))" + Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue + } + if ($stale) { Start-Sleep -Seconds 1 } + + New-Item -ItemType Directory -Force $shareDirNorm | Out-Null + New-Item -ItemType Directory -Force (Join-Path $shareDirNorm "home") | Out-Null + New-Item -ItemType Directory -Force (Join-Path $shareDirNorm "temp") | Out-Null + New-Item -ItemType Directory -Force (Join-Path $shareDirNorm "local") | Out-Null + Grant-AppContainerWritableDirectory (Join-Path $shareDirNorm "home") + Grant-AppContainerWritableDirectory (Join-Path $shareDirNorm "temp") + Remove-Item (Join-Path $shareDirNorm "openclaw-capture.log") -Force -ErrorAction SilentlyContinue + Remove-Item (Join-Path $shareDirNorm "openshell-shutdown.signal") -Force -ErrorAction SilentlyContinue + + Copy-ItemRetry $NodeExePath (Join-Path $shareDirNorm "node.exe") + Info "staged node.exe" + Copy-ItemRetry $captureScript (Join-Path $shareDirNorm "openclaw-capture.mjs") + Info "staged openclaw-capture.mjs" + Copy-ItemRetry $relayExe (Join-Path $shareDirNorm "openshell-supervisor-relay.exe") + Info "staged openshell-supervisor-relay.exe" + + New-Item -ItemType Directory -Force $openClawStageDir | Out-Null + # /MIR deletes files in the destination not present in the source, which + # is what we want on a rerun after an OpenClaw upgrade/rollback -- without + # it, /E alone can leave a mixed tree from multiple versions, making + # failures hard to reproduce. Safe here because $openClawStageDir is + # computed from $shareDirNorm, already validated above (a direct child of + # a drive root, not user-arbitrary), not a path this script accepts raw. + $roboArgs = @($OpenClawInstallDir, $openClawStageDir, "/MIR", "/NFL", "/NDL", "/NJH", "/NJS", "/NP", "/R:2", "/W:1") + $roboOut = & robocopy.exe @roboArgs 2>&1 + # robocopy exit codes 0-7 are all "success" (bit flags for copied/skipped/ + # mismatched files); only >= 8 indicates a real failure. + if ($LASTEXITCODE -ge 8) { throw "robocopy failed staging OpenClaw install (exit $LASTEXITCODE): $($roboOut -join ' ')" } + Info "staged OpenClaw install ($OpenClawInstallDir -> $openClawStageDir, robocopy exit $LASTEXITCODE)" + Ok "share_dir staged" + + # 5. Gateway env: config path via env var (clap: OPENSHELL_GATEWAY_CONFIG), + # NOT a --config token -- Start-Process -ArgumentList does not quote + # array elements, so a config path containing a space gets split and the + # gateway's arg parser rejects it. OPENCLAW_GATEWAY_TOKEN is passed with + # sandbox create --env-from below, so setting it here gives the sandboxed + # OpenClaw a stable, known token without placing it in argv. + $env:OPENSHELL_DRIVERS = "mxc" + $env:OPENSHELL_GATEWAY_CONFIG = $tomlUsed + $env:OPENCLAW_GATEWAY_TOKEN = $GatewayToken + Remove-Item Env:OPENSHELL_MXC_MOCK_WXC -ErrorAction SilentlyContinue + + # 6. Start gateway. + Step "Start gateway" + $gw = Start-Process -FilePath $gateway ` + -ArgumentList @("--disable-tls", "--log-level", "info", "--port", "$Port") ` + -WorkingDirectory $here -PassThru -NoNewWindow ` + -RedirectStandardOutput $gwLog -RedirectStandardError $gwErrLog + Info "gateway pid $($gw.Id)" + $deadline = (Get-Date).AddSeconds(30); $ready = $false + while ((Get-Date) -lt $deadline) { + if ($gw.HasExited) { Get-Content $gwLog, $gwErrLog -Encoding UTF8 -ErrorAction SilentlyContinue | ForEach-Object { Info $_ }; throw "gateway exited early (code $($gw.ExitCode))" } + if (Get-NetTCPConnection -State Listen -LocalPort $Port -ErrorAction SilentlyContinue) { $ready = $true; break } + Start-Sleep -Milliseconds 500 + } + if (-not $ready) { throw "gateway did not start listening on $Port within 30s" } + Ok "gateway listening on 127.0.0.1:$Port" + + # 7. Register CLI -> gateway. See run-ollama-test.ps1 for why EAP is + # dropped to 'Continue' around these calls (the CLI writes success + # banners to stderr too, which $ErrorActionPreference='Stop' would + # otherwise turn into terminating errors on Windows PowerShell 5.1). + Step "Register CLI -> gateway" + Remove-Item Env:OPENSHELL_GATEWAY -ErrorAction SilentlyContinue + $prevEAP = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { + $expectedEndpoint = "http://127.0.0.1:$Port" + & $cli gateway add $expectedEndpoint --local --name $GatewayName 2>&1 | ForEach-Object { Info "$_" } + if ($LASTEXITCODE -ne 0) { + # Most likely "$GatewayName already registered" from a prior run. Don't + # just continue and select it blindly -- if it points at a stale URL + # (e.g. a different port from an earlier run), the rest of this script + # would create sandboxes and forward against the wrong gateway process. + # Verify the existing registration's endpoint actually matches this + # run's port; re-point the alias if it doesn't. + Info "gateway add exit $LASTEXITCODE -- '$GatewayName' likely already registered; verifying its endpoint matches this run" + $existingEndpoint = $null + try { + $listJson = & $cli gateway list -o json 2>&1 + # Split from the filter below (rather than one chained pipeline) -- + # piping ConvertFrom-Json's array output directly into Where-Object + # in the same pipeline expression does not filter correctly here. + $gateways = $listJson | ConvertFrom-Json + $existingEndpoint = ($gateways | Where-Object { $_.name -eq $GatewayName } | Select-Object -First 1).endpoint + } catch { + Info "could not parse 'gateway list -o json' output ($($_.Exception.Message)); treating as a mismatch" + } + if ($existingEndpoint -ne $expectedEndpoint) { + Info "'$GatewayName' is missing or points at '$existingEndpoint' (expected '$expectedEndpoint') -- removing and re-adding" + & $cli gateway remove $GatewayName 2>&1 | ForEach-Object { Info "$_" } + & $cli gateway add $expectedEndpoint --local --name $GatewayName 2>&1 | ForEach-Object { Info "$_" } + if ($LASTEXITCODE -ne 0) { throw "gateway add failed after removing stale alias '$GatewayName' (exit $LASTEXITCODE)" } + } else { + Info "'$GatewayName' already points at '$expectedEndpoint' -- reusing" + } + } + & $cli gateway select $GatewayName 2>&1 | ForEach-Object { Info "$_" } + $selExit = $LASTEXITCODE + } finally { + $ErrorActionPreference = $prevEAP + } + if ($selExit -ne 0) { throw "gateway select failed (exit $selExit); cannot guarantee the correct gateway context." } + + # 8. Create sandbox. Best-effort clear of any leftover sandbox of this name + # first (see run-ollama-test.ps1 for the same defensive pattern). + Step "Create sandbox '$SandboxName' (runs OpenClaw's gateway inside a ProcessContainer)" + $delOut = ""; $delCode = 0 + try { $delOut = (& $cli sandbox delete $SandboxName 2>&1 | Out-String).Trim(); $delCode = $LASTEXITCODE } + catch { $delOut = "$($_.Exception.Message)"; $delCode = 1 } + if ($delCode -ne 0) { + if ($delOut -match '(?i)not found') { Info "no leftover sandbox '$SandboxName' to remove (expected on a clean run)" } + elseif ($delOut) { Info "sandbox pre-delete '$SandboxName': $delOut (continuing)" } + else { Info "sandbox pre-delete '$SandboxName': delete exited $delCode (continuing)" } + } + $driverConfigJson = @{ + mxc = @{ + command = @( + "$shareDirToml/node.exe", + "$shareDirToml/openclaw-capture.mjs", + "gateway", "run", "--dev", "--allow-unconfigured", + "--auth", "token", "--bind", "loopback", "--port", "$TargetPort" + ) + cwd = $shareDirToml + } + } | ConvertTo-Json -Compress -Depth 5 + $createArgs = @( + "sandbox", "create", "--name", $SandboxName, "--policy", $policyUsed, + "--driver-config-json", $driverConfigJson, + "--env-from", "SYSTEMROOT", "--env-from", "WINDIR", + "--env-from", "PATH", "--env-from", "COMSPEC", + "--env-from", "OPENCLAW_GATEWAY_TOKEN", + "--env", "OPENCLAW_NO_UPDATE_CHECK=1", + "--env", "NO_UPDATE_NOTIFIER=1", + "--env", "LOCALAPPDATA=$shareDirToml/local", + "--env", "HOME=$shareDirToml/home", + "--env", "USERPROFILE=$shareDirToml/home", + "--env", "TEMP=$shareDirToml/temp", "--env", "TMP=$shareDirToml/temp", + "--env", "NEMOCLAW_MXC_CAPTURE_ENTRY=$shareDirToml/runtime/node_modules/openclaw/openclaw.mjs", + "--env", "NEMOCLAW_MXC_CAPTURE_LOG=$shareDirToml/openclaw-capture.log", + "--env", "NEMOCLAW_MXC_CAPTURE_SELF_PROBE_PORT=$TargetPort", + "--env", "NODE_OPTIONS=--use-env-proxy", + "--env", "NEMOCLAW_MXC_EGRESS_PROOF=1", + "--env", "NEMOCLAW_MXC_EGRESS_ALLOWED_URL=https://example.com/", + "--env", "NEMOCLAW_MXC_EGRESS_DENIED_URL=https://example.org/", + "--env", "NEMOCLAW_MXC_EGRESS_DIRECT_HOST=1.1.1.1", + "--env", "NEMOCLAW_MXC_EGRESS_LOOPBACK_PORT=29999", + "--no-tty", "--", "exit" + ) + try { $createOut = & $cli @createArgs 2>&1; $createCode = $LASTEXITCODE } + catch { $createOut = $_.Exception.Message; $createCode = 1 } + $createBenign = Show-SandboxCreate $createOut $SandboxName + if ($createCode -ne 0 -and -not $createBenign) { + throw "sandbox create '$SandboxName' failed (exit $createCode): $($createOut | Out-String)" + } + + # 9. Wait for OpenClaw's gateway to report ready, by tailing the gateway's + # own log for the line it prints on successful startup (forwarded from + # the sandbox's stdout via "wxc-exec stdout:"). Generous timeout: Node + # startup + AppContainer/UAC elevation + plugin warmup can take a while + # on a cold run. + Step "Wait for OpenClaw gateway readiness" + $readyDeadline = (Get-Date).AddSeconds(90) + $openclawReady = $false + while ((Get-Date) -lt $readyDeadline) { + if (Test-Path $gwLog) { + # `.*` (not `\s+`) between "[gateway]" and "ready": OpenClaw wraps its + # log lines in ANSI color codes whenever it inherits enough of the host + # env to detect a color-capable terminal -- which happens with + # mxc-openclaw-localnet.toml (-UseLocalNetwork), since that config + # doesn't set pc_minimal_env and so inherits the full host env, unlike + # mxc-openclaw-gateway.toml's curated minimal set. A strict \s+ match + # missed this entirely and timed out waiting for a line that had + # already printed. Those codes render in this log as LITERAL backslash- + # escaped text (e.g. "...\x1b[36mready..."), not real ESC bytes -- so + # "m" from "36m" directly abuts "ready" with no word boundary, which is + # why a \bready\b tightening (tried once) also failed to match; a bare + # substring check is what actually works here. The resulting collision + # risk with "already" is theoretical -- no such line has been observed + # on this "[gateway]"-tagged forwarded-stdout path in practice. + if (Select-String -Path $gwLog -Pattern '\[gateway\].*ready' -Quiet -ErrorAction SilentlyContinue) { $openclawReady = $true; break } + } + Start-Sleep -Seconds 2 + } + if (-not $openclawReady) { throw "OpenClaw did not report ready within 90s (see gateway.log in the results bundle)" } + Ok "OpenClaw gateway ready" + + # 10. openshell forward service: opens a fresh, on-demand relay for this + # one call and bridges TargetPort (inside the sandbox) to + # ForwardLocalPort (on this host). No port needs to be pre-declared + # anywhere except pc_relay_target_port's startup liveness check. + Step "openshell forward service --target-port $TargetPort --local $ForwardLocalPort" + $fwdProc = Start-Process -FilePath $cli ` + -ArgumentList @("forward", "service", "--target-port", "$TargetPort", "--local", "$ForwardLocalPort", $SandboxName) ` + -WorkingDirectory $here -PassThru -NoNewWindow ` + -RedirectStandardOutput $fwdLog -RedirectStandardError $fwdErrLog + Info "forward pid $($fwdProc.Id)" + $fwdDeadline = (Get-Date).AddSeconds(20); $fwdUp = $false + while ((Get-Date) -lt $fwdDeadline) { + if ($fwdProc.HasExited) { throw "forward process exited early (code $($fwdProc.ExitCode)); see forward.log/forward.err.log" } + if ((Test-Path $fwdLog) -and (Select-String -Path $fwdLog -Pattern 'Forwarding' -Quiet -ErrorAction SilentlyContinue)) { $fwdUp = $true; break } + if ((Test-Path $fwdErrLog) -and (Select-String -Path $fwdErrLog -Pattern 'Forwarding' -Quiet -ErrorAction SilentlyContinue)) { $fwdUp = $true; break } + Start-Sleep -Milliseconds 500 + } + if (-not $fwdUp) { throw "forward did not report 'Forwarding ...' within 20s; see forward.log/forward.err.log" } + Ok "forward active: 127.0.0.1:$ForwardLocalPort -> sandbox:$TargetPort" + + # 11. Real OpenClaw client, on the HOST, through the forwarded port. This + # is the actual end-to-end proof: authenticate + get a real response + # from the sandboxed gateway via the relay, exactly as an external + # client would use `openshell forward service` in practice. + # + # Retried: OpenClaw's own log declares a startup-grace window ("[health- + # monitor] started (interval: 300s, startup-grace: 60s, ...)") after + # printing "[gateway] ready" -- on a slower/more heavily-loaded machine + # (observed on a domain-joined box with corporate AV/EDR) it can still + # be settling internally for longer than that, and a request landing in + # that window gets silently dropped with ZERO trace in any log (not a + # WS close, not an error -- the client's own 10s timeout just fires). + # Each attempt already blocks for up to 10s on failure, so a handful of + # attempts comfortably covers the declared 60s grace without a fixed + # sleep that would either undershoot on a slow box or waste time on a + # fast one. + Step "OpenClaw client: gateway health via the forwarded port" + $healthArgs = @($openClawEntry, "gateway", "health", "--port", "$ForwardLocalPort", "--token", $GatewayToken, "--json") + # Isolate the 2026.7.1 host client from any newer ~/.openclaw schema/state. + $savedOpenClawConfigPath = $env:OPENCLAW_CONFIG_PATH + $savedOpenClawStateDir = $env:OPENCLAW_STATE_DIR + $cleanOpenClawStateDir = Join-Path $ShareDir "home\.openclaw" + try { + $env:OPENCLAW_CONFIG_PATH = Join-Path $cleanOpenClawStateDir "openclaw.json" + $env:OPENCLAW_STATE_DIR = $cleanOpenClawStateDir + # Bumped from 6 -> 14 (2026-09-10): on this box OpenClaw's actual startup + # (port bind -> SQLite agent-db open -> HTTP server listening -> "ready") + # measured ~90s wall clock, longer than 6 attempts' ~60s budget covers -- + # the sandbox was torn down mid-startup before the health check could ever + # succeed. 14 attempts at up to 10s each comfortably covers 90s+ without + # a fixed sleep that would undershoot on a slower box. + $healthAttempts = 14 + for ($attempt = 1; $attempt -le $healthAttempts; $attempt++) { + $healthRaw = & $NodeExePath @healthArgs 2>&1 + $healthRaw | Out-File (Join-Path $resultDir "openclaw-health-raw.txt") -Encoding UTF8 + # --json output is PRETTY-PRINTED (multi-line), not compact -- extract from + # the first '{' to the last '}' across the whole output rather than + # assuming any single line is a complete JSON document. + $rawJoined = ($healthRaw | ForEach-Object { [string]$_ }) -join "`n" + $startIdx = $rawJoined.IndexOf('{') + $endIdx = $rawJoined.LastIndexOf('}') + $healthJson = $null + if ($startIdx -ge 0 -and $endIdx -gt $startIdx) { + $jsonText = $rawJoined.Substring($startIdx, $endIdx - $startIdx + 1) + try { $healthJson = $jsonText | ConvertFrom-Json } catch { Info "could not parse health JSON: $($_.Exception.Message)" } + } + if ($healthJson -and $healthJson.ok -eq $true) { + $passed = $true + Ok "gateway health: ok=true (attempt $attempt/$healthAttempts)" + break + } else { + Info "attempt $attempt/${healthAttempts}: no ok=true response yet$(if ($attempt -lt $healthAttempts) { ' -- retrying (still inside OpenClaws own startup-grace window)' })" + } + } + if (-not $passed) { + Bad "gateway health did not report ok=true after $healthAttempts attempts" + $healthRaw | ForEach-Object { Info "$_" } + } + + # Treat egress as a qualification gate, not just diagnostic output. The + # capture script runs these probes before importing OpenClaw, so the record + # is available by the time gateway health succeeds. + Step "Verify governed egress evidence" + $capturePath = Join-Path $ShareDir "openclaw-capture.log" + $proofMatch = Select-String -Path $capturePath -Pattern '^\[egress-proof\] (?\{.*\})$' -ErrorAction SilentlyContinue | Select-Object -Last 1 + $proof = $null + if ($proofMatch) { + try { $proof = $proofMatch.Matches[0].Groups['json'].Value | ConvertFrom-Json } + catch { Info "could not parse egress proof JSON: $($_.Exception.Message)" } + } + $proofPassed = $proof -and + $proof.proxyConfigured -eq $true -and + $proof.allowedViaProxy.connected -eq $true -and + $proof.deniedViaProxy.connected -eq $false -and + $proof.directInternetBypass.connected -eq $false + if ($proofPassed) { + Ok "allowed host passed proxy; denied host and direct Internet bypass were blocked" + Info "unrelated host loopback reachable: $($proof.unrelatedHostLoopback.connected) (known limitation)" + } else { + $passed = $false + Bad "governed egress proof failed or was not recorded" + } + } finally { + if ($null -eq $savedOpenClawConfigPath) { + Remove-Item Env:OPENCLAW_CONFIG_PATH -ErrorAction SilentlyContinue + } else { + $env:OPENCLAW_CONFIG_PATH = $savedOpenClawConfigPath + } + if ($null -eq $savedOpenClawStateDir) { + Remove-Item Env:OPENCLAW_STATE_DIR -ErrorAction SilentlyContinue + } else { + $env:OPENCLAW_STATE_DIR = $savedOpenClawStateDir + } + } +} +catch { + Bad $_.Exception.Message +} +finally { + # Stop the forward before the sandbox so its relay tears down cleanly. + if ($fwdProc -and -not $fwdProc.HasExited) { + try { Stop-Process -Id $fwdProc.Id -Force -ErrorAction SilentlyContinue } catch {} + } + + # Tear down the sandbox while the gateway is still up (delete needs it). + if ($cli -and $SandboxName) { + try { & $cli sandbox delete $SandboxName 2>&1 | Out-Null } + catch { Info "sandbox teardown '$SandboxName': $($_.Exception.Message) (continuing)" } + } + + if ($KeepRunning -and $gw -and -not $gw.HasExited) { + Info "leaving gateway pid $($gw.Id) running (-KeepRunning); stop with: Stop-Process -Id $($gw.Id) -Force" + } elseif ($gw -and -not $gw.HasExited) { + Step "Cleanup"; Stop-Process -Id $gw.Id -Force -ErrorAction SilentlyContinue + try { $gw.WaitForExit(5000) | Out-Null } catch {} + Info "stopped gateway pid $($gw.Id)" + } + + Step "Gateway log (tail)" + if (Test-Path $gwLog) { + Get-Content $gwLog -Tail 30 -Encoding UTF8 -ErrorAction SilentlyContinue | ForEach-Object { Info $_ } + } + + # Copy the OpenClaw capture log (if it made it far enough to write one) for + # post-hoc debugging, then extract only the credential-free target-side + # self-probe outcome. The probe records a byte count, never response data. + # It is diagnostic and cannot make the end-to-end verdict pass: only the + # authenticated host-side OpenClaw client above owns that verdict. + $captureLog = Join-Path $shareDirNorm "openclaw-capture.log" + if (Test-Path $captureLog) { + Copy-Item $captureLog (Join-Path $resultDir "openclaw-capture.log") -Force -ErrorAction SilentlyContinue + $selfProbeLine = Select-String -Path $captureLog -Pattern '\[self-probe\] outcome=([^ ]+) response_bytes=([0-9]+)' -AllMatches -ErrorAction SilentlyContinue | Select-Object -Last 1 + if ($selfProbeLine -and $selfProbeLine.Matches.Count -gt 0) { + $selfProbeOutcome = $selfProbeLine.Matches[0].Groups[1].Value + $selfProbeResponseBytes = [int64]$selfProbeLine.Matches[0].Groups[2].Value + } elseif (Select-String -Path $captureLog -Pattern '\[self-probe\] invalid_port' -Quiet -ErrorAction SilentlyContinue) { + $selfProbeOutcome = "invalid-port" + } elseif (Select-String -Path $captureLog -Pattern '\[self-probe-attempt\] started' -Quiet -ErrorAction SilentlyContinue) { + $selfProbeOutcome = "started-no-completion" + } + } + if ($selfProbeOutcome -eq "response" -and $selfProbeResponseBytes -gt 0) { + Info "target-side self-probe: response ($selfProbeResponseBytes bytes); OpenClaw serviced a local sandbox connection" + } else { + Info "target-side self-probe: $selfProbeOutcome ($selfProbeResponseBytes bytes); inspect the sandboxed OpenClaw target/event loop" + } + + Step "RESULT" + $verdict = if ($passed) { "PASS" } else { "FAIL" } + $summary = @" +OpenShell MXC OpenClaw + dynamic forward test +===================================================================== +timestamp : $stamp +machine : $env:COMPUTERNAME +verdict : $verdict +sandbox : $SandboxName +backend : $Backend +config : $tomlName +target_port : $TargetPort (inside sandbox) +forward_local_port : $ForwardLocalPort (on this host) +wxc_exec : $WxcExecPath +node_exe : $NodeExePath +openclaw_install : $OpenClawInstallDir +target_self_probe : $selfProbeOutcome ($selfProbeResponseBytes response bytes; diagnostic only) + +What PASS means: the gateway created a sandbox on the $Backend backend (no +in-sandbox supervisor process; ProcessContainer also has no inbound network +capability at all); openshell-supervisor-relay launched OpenClaw's gateway +inside it via the driver's control channel; +`openshell forward service` opened a fresh, on-demand WebSocket relay for +this one call (nothing pre-declared beyond the startup liveness port); and a +REAL OpenClaw client running on this host, talking only through that +forwarded port, authenticated with a token and got back a real 'ok: true' +health response. The egress proof also required an allowed HTTPS request to +pass through the OpenShell proxy while a denied host and direct Internet +bypass were blocked. Host loopback remains broadly reachable because dynamic +forwarding uses ephemeral loopback ports. + +Files in this bundle: + transcript.txt full console transcript + gateway.log/.err.log gateway stdout/stderr (includes + forwarded sandbox stdout/stderr, tagged + "wxc-exec stdout:"/"wxc-exec stderr:") + forward.log/.err.log `openshell forward service` stdout/stderr + openclaw-health-raw.txt raw output of the OpenClaw health client + openclaw-capture.log OpenClaw's own captured stdout/stderr + plus credential-free target self-probe + outcome/byte count (no response payload) + (if the sandbox got far enough to write it) + ${tomlBaseName}.used.toml exact config used (wxc_exec_path patched) + openclaw-gateway.used.yaml exact policy used +"@ + Set-Content -Path (Join-Path $resultDir "summary.txt") -Value $summary -Encoding UTF8 + Write-Host $summary -ForegroundColor ($(if ($passed) { "Green" } else { "Red" })) + + try { Stop-Transcript | Out-Null } catch {} + try { + $zip = Join-Path $here "results-openclaw-forward-$stamp.zip" + if (Test-Path $zip) { Remove-Item $zip -Force } + Compress-Archive -Path (Join-Path $resultDir "*") -DestinationPath $zip -Force + Write-Host "`nBUNDLE: $zip" -ForegroundColor Yellow + Write-Host "Hand that zip back for evaluation." -ForegroundColor Yellow + } catch { Write-Host "zip failed: $($_.Exception.Message)" -ForegroundColor Red } +} + +if ($passed) { exit 0 } else { exit 1 } diff --git a/crates/openshell-driver-mxc/examples/run-provider-credential-test.ps1 b/crates/openshell-driver-mxc/examples/run-provider-credential-test.ps1 new file mode 100644 index 0000000000..daffcbe424 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/run-provider-credential-test.ps1 @@ -0,0 +1,461 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# End-to-end MXC provider credential scenario. +# +# The sandbox receives a revision-scoped GITHUB_TOKEN placeholder, never the +# raw token. The MXC host CONNECT proxy resolves it for api.github.com and +# rejects the same placeholder at policy-allowed github.com because that host is +# outside this test profile's sole api.github.com credential binding. +# +# Prerequisites: +# $env:GITHUB_TOKEN = "github_pat_..." +# mise run --skip-tools windows:build:x64 +# +# Run from a local demo-package folder containing openshell-gateway.exe, +# openshell.exe, the PowerShell probe, and the three configuration fixtures +# beside this script, or pass explicit local gateway and CLI paths. When the +# script is copied to a network share, keep the executables on a local volume; +# Windows Application Control commonly rejects unsigned development binaries +# launched from UNC or mapped network paths. +# +# powershell -NoProfile -ExecutionPolicy Bypass ` +# -File .\run-provider-credential-test.ps1 ` +# -GatewayPath .\target\x86_64-pc-windows-msvc\release\openshell-gateway.exe ` +# -CliPath .\target\x86_64-pc-windows-msvc\release\openshell.exe +# +# PowerShell 5.1-compatible. The script never prints GITHUB_TOKEN and scans all +# result artifacts for accidental raw-token leakage before creating the bundle. + +[CmdletBinding()] +param( + [string] $ShareDir = "C:\work\openshell-mxc-provider", + [string] $WxcExecPath = "C:\mxc-kit\bin\wxc-exec.exe", + [string] $GatewayPath, + [string] $CliPath, + [int] $Port = 17670, + [string] $GatewayName = "openshell-mxc-provider-e2e" +) + +$ErrorActionPreference = "Stop" +$PSNativeCommandUseErrorActionPreference = $false +try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch {} +$OutputEncoding = [System.Text.Encoding]::UTF8 + +$here = if ($PSScriptRoot) { $PSScriptRoot } else { (Get-Location).Path } +$utf8NoBom = New-Object System.Text.UTF8Encoding($false) +$stamp = Get-Date -Format "yyyyMMdd-HHmmss" +$resultDir = Join-Path $here "results-provider-credential-$stamp" +New-Item -ItemType Directory -Force $resultDir | Out-Null + +function Step([string]$message) { + $script:failureStage = $message + Write-Host "`n=== $message ===" -ForegroundColor Cyan +} +function Info([string]$message) { Write-Host " $message" } +function Ok([string]$message) { Write-Host "[OK] $message" -ForegroundColor Green } +function Bad([string]$message) { Write-Host "[FAIL] $message" -ForegroundColor Red } + +function Resolve-Artifact([string]$explicit, [string]$leaf) { + if (-not [string]::IsNullOrWhiteSpace($explicit)) { + return $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($explicit) + } + return (Join-Path $here $leaf) +} + +function Escape-Toml([string]$value) { return $value.Replace('\', '\\') } + +function Test-NetworkPath([string]$value) { + if ($value.StartsWith('\\')) { return $true } + if ($value -notmatch '^([A-Za-z]):[\\/]') { return $false } + + $drive = Get-PSDrive -Name $Matches[1] -PSProvider FileSystem -ErrorAction SilentlyContinue + return $null -ne $drive -and + -not [string]::IsNullOrWhiteSpace($drive.DisplayRoot) -and + $drive.DisplayRoot.StartsWith('\\') +} + +function Assert-LocalExecutable([string]$label, [string]$path) { + if (-not (Test-NetworkPath $path)) { return } + + throw "$label executable resolves to network path '$path'. Windows Application Control can block unsigned development binaries launched from network locations. Pass -GatewayPath and -CliPath pointing to local build outputs (for example, the repository's target\x86_64-pc-windows-msvc\release directory); the script and result artifacts may remain on the network share." +} + +function Get-LaunchFailureMessage([string]$label, [string]$path, [System.Exception]$exception) { + $messages = New-Object System.Collections.Generic.List[string] + $currentException = $exception + while ($null -ne $currentException) { + if (-not [string]::IsNullOrWhiteSpace($currentException.Message)) { + [void]$messages.Add($currentException.Message) + } + $currentException = $currentException.InnerException + } + $message = ($messages -join ' | ') + + if ($message -match '(?i)Application Control policy has blocked this file') { + $sha256 = try { (Get-FileHash -LiteralPath $path -Algorithm SHA256 -ErrorAction Stop).Hash } catch { "unavailable" } + $signature = try { (Get-AuthenticodeSignature -LiteralPath $path -ErrorAction Stop).Status } catch { "unavailable" } + return "Application Control blocked $label launch '$path' (SHA256=$sha256; Authenticode=$signature). Use a local, policy-approved binary and review the applicable App Control event log if the local launch is also blocked. Original error: $message" + } + + return "failed to launch $label '$path': $message" +} + +# Build one CreateProcess-compatible command-line argument. Windows PowerShell +# 5.1 removes embedded quotes from JSON passed to native commands through the +# call operator, which corrupts --driver-config-json before the CLI parses it. +function Quote-NativeArgument([string]$value) { + if ($value.Length -gt 0 -and $value -notmatch '[\s"]') { return $value } + + $quoted = New-Object System.Text.StringBuilder + [void]$quoted.Append('"') + $backslashes = 0 + foreach ($ch in $value.ToCharArray()) { + if ($ch -eq '\') { + $backslashes++ + continue + } + if ($ch -eq '"') { + [void]$quoted.Append(('\' * (2 * $backslashes + 1))) + [void]$quoted.Append('"') + } else { + if ($backslashes -gt 0) { [void]$quoted.Append(('\' * $backslashes)) } + [void]$quoted.Append($ch) + } + $backslashes = 0 + } + if ($backslashes -gt 0) { [void]$quoted.Append(('\' * (2 * $backslashes))) } + [void]$quoted.Append('"') + return $quoted.ToString() +} + +function Invoke-Cli([string[]]$CommandArgs, [switch]$AllowFailure) { + $startInfo = New-Object System.Diagnostics.ProcessStartInfo + $startInfo.FileName = $cli + $startInfo.Arguments = (($CommandArgs | ForEach-Object { Quote-NativeArgument $_ }) -join ' ') + $startInfo.UseShellExecute = $false + $startInfo.CreateNoWindow = $true + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + + $process = New-Object System.Diagnostics.Process + $process.StartInfo = $startInfo + try { + if (-not $process.Start()) { throw "failed to start $cli" } + } catch { + throw (Get-LaunchFailureMessage "CLI" $cli $_.Exception) + } + + $stdout = $process.StandardOutput.ReadToEndAsync() + $stderr = $process.StandardError.ReadToEndAsync() + $process.WaitForExit() + $exitCode = $process.ExitCode + $text = (@($stdout.Result, $stderr.Result) | Where-Object { + -not [string]::IsNullOrWhiteSpace($_) + }) -join [Environment]::NewLine + $text = $text.Trim() + if (-not $AllowFailure -and $exitCode -ne 0) { + throw "openshell $($CommandArgs -join ' ') failed (exit $exitCode): $text" + } + return @{ ExitCode = $exitCode; Text = $text } +} + +function Wait-ForProbeResult([string]$path, [string]$sandbox, [int]$seconds) { + $deadline = (Get-Date).AddSeconds($seconds) + while ((Get-Date) -lt $deadline -and -not (Test-Path $path)) { + $status = Invoke-Cli @("sandbox", "get", $sandbox, "--output", "json") -AllowFailure + if ($status.ExitCode -eq 0) { + $details = $null + try { $details = $status.Text | ConvertFrom-Json } catch {} + if ($details -and $details.phase -eq "Error") { + throw "sandbox $sandbox entered Error before producing the probe result; inspect $gwLog and $gwErrLog" + } + } + Start-Sleep -Milliseconds 500 + } + return (Test-Path $path) +} + +function Copy-ProbeArtifacts { + $artifacts = @( + @{ Source = $resultFile; Destination = "mxc-provider-credential-result.txt" }, + @{ Source = (Join-Path $ShareDir "github-user-response.json"); Destination = "github-user-response.json" }, + @{ Source = (Join-Path $ShareDir "credential-mismatch-response.json"); Destination = "credential-mismatch-response.json" } + ) + foreach ($artifact in $artifacts) { + if (Test-Path $artifact.Source) { + Copy-Item $artifact.Source (Join-Path $resultDir $artifact.Destination) -Force + } + } +} + +$gateway = Resolve-Artifact $GatewayPath "openshell-gateway.exe" +$cli = Resolve-Artifact $CliPath "openshell.exe" +$powerShellExe = Join-Path $env:SystemRoot "System32\WindowsPowerShell\v1.0\powershell.exe" +$probeTemplate = Join-Path $here "mxc-provider-credential-probe.ps1" +$tomlTemplate = Join-Path $here "mxc-provider-credential.toml" +$policyTemplate = Join-Path $here "mxc-provider-credential-policy.yaml" +$profileTemplate = Join-Path $here "mxc-github-provider-profile.yml" +$tomlUsed = Join-Path $resultDir "mxc-provider-credential.used.toml" +$policyUsed = Join-Path $resultDir "mxc-provider-credential-policy.used.yaml" +$profileUsed = Join-Path $resultDir "mxc-github-provider-profile.used.yml" +$resultFile = Join-Path $ShareDir "mxc-provider-credential-result.txt" +$wxcProbeFile = Join-Path $resultDir "wxc-probe.json" +$gwLog = Join-Path $resultDir "gateway.log" +$gwErrLog = Join-Path $resultDir "gateway.err.log" +$gw = $null +$sandboxName = "mxc-gh-$(Get-Date -Format 'MMddHHmmss')" +$providerName = "mxc-github-e2e" +$passed = $false +$rawTokenLeak = $false +$artifactScanFailed = $false +$failureReason = "" +$failureStage = "initialization" +$githubToken = $env:GITHUB_TOKEN + +try { + Step "Validate prerequisites" + if ([string]::IsNullOrWhiteSpace($env:GITHUB_TOKEN)) { + throw "GITHUB_TOKEN is not set. Set it in this PowerShell session; do not pass it on the command line." + } + foreach ($file in @($gateway, $cli, $powerShellExe, $probeTemplate, $tomlTemplate, $policyTemplate, $profileTemplate, $WxcExecPath)) { + if (-not (Test-Path $file)) { throw "missing artifact: $file" } + Info "found $file" + } + Assert-LocalExecutable "gateway" $gateway + Assert-LocalExecutable "CLI" $cli + if (Get-NetTCPConnection -State Listen -LocalPort $Port -ErrorAction SilentlyContinue) { + throw "gateway port $Port is already in use" + } + $previous = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { + $wxcProbeLines = & $WxcExecPath --probe 2>&1 + $wxcProbeExit = $LASTEXITCODE + } finally { + $ErrorActionPreference = $previous + } + $wxcProbeText = (($wxcProbeLines | ForEach-Object { + if ($_ -is [System.Management.Automation.ErrorRecord]) { + $_.Exception.Message + } else { + $_.ToString() + } + }) -join [Environment]::NewLine).Trim() + [System.IO.File]::WriteAllText($wxcProbeFile, $wxcProbeText, $utf8NoBom) + if ($wxcProbeExit -ne 0) { + throw "wxc-exec --probe failed (exit $wxcProbeExit); inspect $wxcProbeFile" + } + Ok "prerequisites available; token value was not printed" + + Step "Render disposable config and stage probe" + $shareFwd = $ShareDir.Replace('\', '/') + $powerShellFwd = $powerShellExe.Replace('\', '/') + New-Item -ItemType Directory -Force $ShareDir | Out-Null + $stagedProbe = Join-Path $ShareDir "mxc-provider-credential-probe.ps1" + Copy-Item $probeTemplate $stagedProbe -Force + Remove-Item ` + $resultFile, ` + (Join-Path $ShareDir "github-user-response.json"), ` + (Join-Path $ShareDir "credential-mismatch-response.json"), ` + (Join-Path $ShareDir "github-user-response.json.stderr"), ` + (Join-Path $ShareDir "credential-mismatch-response.json.stderr") ` + -Force -ErrorAction SilentlyContinue + + $tomlText = [System.IO.File]::ReadAllText($tomlTemplate, [System.Text.Encoding]::UTF8) + $tomlText = [regex]::Replace( + $tomlText, + '(?m)^wxc_exec_path\s*=.*$', + "wxc_exec_path = `"$(Escape-Toml $WxcExecPath)`"" + ) + [System.IO.File]::WriteAllText($tomlUsed, $tomlText, $utf8NoBom) + + $policyText = [System.IO.File]::ReadAllText($policyTemplate, [System.Text.Encoding]::UTF8).Replace("C:/work/openshell-mxc-provider", $shareFwd) + $policyText = $policyText.Replace("C:/Windows/System32/WindowsPowerShell/v1.0/powershell.exe", $powerShellFwd) + [System.IO.File]::WriteAllText($policyUsed, $policyText, $utf8NoBom) + $profileText = [System.IO.File]::ReadAllText($profileTemplate, [System.Text.Encoding]::UTF8) + $profileText = $profileText.Replace("C:/Windows/System32/WindowsPowerShell/v1.0/powershell.exe", $powerShellFwd) + [System.IO.File]::WriteAllText($profileUsed, $profileText, $utf8NoBom) + $driverConfig = @{ + mxc = @{ + command = @( + $powerShellFwd, + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-File", + "$shareFwd/mxc-provider-credential-probe.ps1", + $shareFwd + ) + cwd = $shareFwd + } + } | ConvertTo-Json -Compress -Depth 4 + Ok "staged probe and rendered config without credential material" + + Step "Start gateway" + $env:OPENSHELL_DRIVERS = "mxc" + $env:OPENSHELL_GATEWAY_CONFIG = $tomlUsed + Remove-Item Env:OPENSHELL_MXC_MOCK_WXC -ErrorAction SilentlyContinue + # The CLI, not the gateway process environment, supplies the provider + # credential. Temporarily remove GITHUB_TOKEN while spawning the gateway so + # a successful test cannot be attributed to gateway environment inheritance. + Remove-Item Env:GITHUB_TOKEN -ErrorAction SilentlyContinue + try { + try { + $gw = Start-Process -FilePath $gateway ` + -ArgumentList @("--disable-tls", "--db-url", "sqlite::memory:", "--log-level", "info") ` + -WorkingDirectory $here -PassThru -NoNewWindow ` + -RedirectStandardOutput $gwLog -RedirectStandardError $gwErrLog + } catch { + throw (Get-LaunchFailureMessage "gateway" $gateway $_.Exception) + } + } finally { + $env:GITHUB_TOKEN = $githubToken + } + $deadline = (Get-Date).AddSeconds(30) + while ((Get-Date) -lt $deadline) { + if ($gw.HasExited) { throw "gateway exited early (code $($gw.ExitCode))" } + if (Get-NetTCPConnection -State Listen -LocalPort $Port -ErrorAction SilentlyContinue) { break } + Start-Sleep -Milliseconds 400 + } + if (-not (Get-NetTCPConnection -State Listen -LocalPort $Port -ErrorAction SilentlyContinue)) { + throw "gateway did not listen on port $Port within 30 seconds" + } + Ok "gateway listening on 127.0.0.1:$Port" + + Step "Configure provider and effective policy" + $env:OPENSHELL_GATEWAY = "" + $gatewayAdd = Invoke-Cli @( + "gateway", "add", "http://127.0.0.1:$Port", "--local", "--name", $GatewayName + ) -AllowFailure + if ($gatewayAdd.ExitCode -ne 0 -and $gatewayAdd.Text -notmatch '(?i)already exists') { + throw "gateway registration failed (exit $($gatewayAdd.ExitCode)): $($gatewayAdd.Text)" + } + Invoke-Cli @("gateway", "select", $GatewayName) | Out-Null + Invoke-Cli @("provider", "profile", "lint", "--file", $profileUsed) | Out-Null + Invoke-Cli @("provider", "profile", "import", "--file", $profileUsed) | Out-Null + Invoke-Cli @("provider", "create", "--name", $providerName, "--type", "mxc-github-e2e", "--credential", "GITHUB_TOKEN") | Out-Null + Ok "created attached-provider inputs without adding GITHUB_TOKEN to the sandbox environment" + + Step "Create MXC sandbox and run credential probe" + # MXC launches the per-sandbox command itself and exposes no supervisor/SSH + # relay. Structured output makes the CLI return after the sandbox reaches + # Ready instead of trying to connect or exec a command. + $createArgs = @( + "sandbox", "create", + "--name", $sandboxName, + "--provider", $providerName, + "--policy", $policyUsed, + "--driver-config-json", $driverConfig, + # PowerShell uses SystemRoot to locate inbox curl.exe and PATHEXT to + # recognize the fully qualified path as an executable command. + "--env", "SystemRoot=$env:SystemRoot", + "--env", "PATHEXT=$env:PATHEXT", + "--env", "USERPROFILE=$shareFwd", + "--env", "LOCALAPPDATA=$shareFwd", + "--env", "TEMP=$shareFwd", + "--env", "TMP=$shareFwd", + "--output", "json" + ) + $create = Invoke-Cli $createArgs + if ($create.Text) { Info $create.Text } + if (-not (Wait-ForProbeResult $resultFile $sandboxName 150)) { + throw "probe did not produce $resultFile within 150 seconds" + } + $resultText = [System.IO.File]::ReadAllText($resultFile, [System.Text.Encoding]::UTF8) + if (-not [string]::IsNullOrWhiteSpace($githubToken) -and $resultText.Contains($githubToken)) { + $rawTokenLeak = $true + $resultText = $resultText.Replace($githubToken, "***REDACTED***") + [System.IO.File]::WriteAllText($resultFile, $resultText, $utf8NoBom) + } + Write-Host $resultText + if ($resultText -notmatch 'OVERALL: PASS') { + throw "in-sandbox provider credential checks failed" + } + + $effective = Invoke-Cli @("policy", "get", $sandboxName, "--full", "--output", "json") + [System.IO.File]::WriteAllText((Join-Path $resultDir "effective-policy.json"), $effective.Text, $utf8NoBom) + if ($effective.Text -notmatch '_provider_mxc_github_e2e' -or $effective.Text -notmatch 'api\.github\.com') { + throw "effective policy did not contain the attached provider's GitHub rule" + } + $passed = $true + Ok "placeholder isolation, authorized rewrite, and endpoint mismatch all passed" +} +catch { + $failureReason = ($_.Exception.Message -replace '\r?\n', ' | ').Trim() + if (-not [string]::IsNullOrWhiteSpace($githubToken)) { + $failureReason = $failureReason.Replace($githubToken, "***REDACTED***") + } + Bad $failureReason +} +finally { + if ($cli -and $sandboxName -and $gw -and -not $gw.HasExited) { + try { Invoke-Cli @("sandbox", "delete", $sandboxName) -AllowFailure | Out-Null } catch {} + } + + if ($gw -and -not $gw.HasExited) { + Stop-Process -Id $gw.Id -Force -ErrorAction SilentlyContinue + try { $gw.WaitForExit(5000) | Out-Null } catch {} + } + + # Preserve probe output on both success and failure. These files contain + # response bodies and redacted diagnostics, never the raw provider token. + try { Copy-ProbeArtifacts } catch { Info "could not collect probe artifacts: $($_.Exception.GetType().Name)" } + + # Scan after the gateway exits so redirected log handles are flushed and + # closed. Any match is redacted and turns the scenario into a failure. + if (-not [string]::IsNullOrWhiteSpace($githubToken)) { + Get-ChildItem $resultDir -File -ErrorAction SilentlyContinue | ForEach-Object { + $artifact = $_ + try { + $contents = [System.IO.File]::ReadAllText($artifact.FullName, [System.Text.Encoding]::UTF8) + if ($contents.Contains($githubToken)) { + $rawTokenLeak = $true + [System.IO.File]::WriteAllText($artifact.FullName, $contents.Replace($githubToken, "***REDACTED***"), $utf8NoBom) + } + } catch { + $artifactScanFailed = $true + $passed = $false + Bad "could not inspect result artifact $($artifact.FullName): $($_.Exception.GetType().Name)" + } + } + } + if ($rawTokenLeak) { + $passed = $false + Bad "raw GITHUB_TOKEN appeared in a result artifact; it was redacted" + } + + $verdict = if ($passed) { "PASS" } else { "FAIL" } + $summary = @" +OpenShell MXC provider credential example +========================================= +verdict : $verdict +sandbox : $sandboxName +backend : process_container +provider : $providerName +share_path : $ShareDir +stage : $failureStage +failure : $(if ([string]::IsNullOrWhiteSpace($failureReason)) { "none" } else { $failureReason }) + +PASS proves: + - MXC received a revision-scoped GITHUB_TOKEN placeholder, not the token. + - api.github.com accepted the credential after host-proxy substitution. + - policy-allowed github.com could not resolve the api.github.com-bound placeholder. + - the raw token did not appear in collected result artifacts. +"@ + [System.IO.File]::WriteAllText((Join-Path $resultDir "summary.txt"), $summary, $utf8NoBom) + Write-Host "`n$summary" -ForegroundColor ($(if ($passed) { "Green" } else { "Red" })) + + if ($artifactScanFailed) { + Info "result bundle was not created because one or more artifacts could not be scanned" + } else { + try { + $zip = Join-Path $here "results-provider-credential-$stamp.zip" + Compress-Archive -Path (Join-Path $resultDir "*") -DestinationPath $zip -Force + Write-Host "BUNDLE: $zip" -ForegroundColor Yellow + } catch { Info "could not create result bundle: $($_.Exception.Message)" } + } +} + +if ($passed) { exit 0 } else { exit 1 } diff --git a/crates/openshell-driver-mxc/examples/run-ws-agent-test.ps1 b/crates/openshell-driver-mxc/examples/run-ws-agent-test.ps1 new file mode 100644 index 0000000000..0564d17fc5 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/run-ws-agent-test.ps1 @@ -0,0 +1,747 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# run-ws-agent-test.ps1 - WebSocket agent lifecycle test for OpenShell MXC ProcessContainer. +# +# Tests the full lifecycle of a WebSocket sandbox reached via dynamic +# `openshell forward service` bridging (the same mechanism +# run-openclaw-forward-test.ps1 exercises against a real OpenClaw gateway -- +# this test uses a small built-in WS echo server instead): +# +# 1. Start openshell-gateway configured for ProcessContainer, with +# mxc-ws-agent.exe (server mode) wrapped by openshell-supervisor-relay.exe +# (pc_relay_spawner_path / pc_relay_target_port). +# 2. Create a sandbox using ws-agent.yaml policy. The gateway launches +# openshell-supervisor-relay.exe inside the AppContainer, which spawns +# the WebSocket echo server on port 22000 once the driver's "launch" +# handshake completes. +# 3. Wait for port 22000 to become available (server is ready). +# 4. `openshell forward service --target-port 22000` opens a fresh, +# on-demand relay; connect a WebSocket client through it, send a +# message, verify the echo. +# 5. Delete the sandbox. The driver sends a "shutdown" control-channel +# request (and kills wxc-exec as a backstop regardless) -> the spawner +# kills the server directly -> AppContainer tears down -> port 22000 +# freed. +# 6. Verify port 22000 is freed within the drain timeout. +# +# In -Mock mode: steps 3-4 and 6 are skipped because wxc-exec is not invoked +# and the server never starts. The test validates gateway startup, sandbox +# create, and sandbox delete only. +# +# PowerShell 5.1-compatible (no && / || / ternary operators). ASCII only. +# +# Usage (from the directory containing openshell-gateway.exe / openshell.exe): +# +# # Real run against a live MXC backend: +# .\run-ws-agent-test.ps1 -WxcExecPath C:\mxc-kit\bin\wxc-exec.exe +# +# # Wiring-only smoke test (no wxc-exec required): +# .\run-ws-agent-test.ps1 -Mock +# +# # Override the agent directory (default: C:\work\openshell-mxc-ws): +# .\run-ws-agent-test.ps1 -WxcExecPath ... -AgentDir C:\work\openshell-mxc-ws +# +# Exit code: 0 = PASS, 1 = FAIL. + +[CmdletBinding()] +param( + # Path to wxc-exec.exe. Required for real runs; ignored in mock mode. + [string] $WxcExecPath = "", + + # Working directory the AppContainer can read/write. mxc-ws-agent.exe is + # expected alongside this script; + # the script copies it here if needed. + [string] $AgentDir = "C:\work\openshell-mxc-ws", + + # Gateway gRPC port (matches the openshell-gateway default). + [int] $Port = 17670, + + # Gateway name registered with the CLI. + [string] $GatewayName = "openshell-mxc-ws-test", + + # Port the WebSocket server binds inside the AppContainer. NOT actually + # overridable today -- it's a compile-time const in mxc-ws-agent.rs; any + # other value is rejected below rather than silently ignored. + [int] $WsPort = 22000, + + # Local host port `openshell forward service` binds for this run's + # on-demand relay. Host clients connect here; the CLI bridges them to the + # in-sandbox server via the driver's dynamic forward (ForwardSink:: + # open_dynamic_forward). Freely overridable -- unlike -WsPort, this one + # actually is wired through end to end. + [int] $RelayPort = 22001, + + # WebSocket echo message sent during the connectivity check. + [string] $WsMessage = "hello-ws", + + # Skip wxc-exec invocation and AppContainer enforcement; validates gateway + # startup, sandbox create/delete lifecycle only. + [switch] $Mock, + + # Keep the gateway running after the test (useful for manual inspection). + [switch] $KeepRunning +) + +$ErrorActionPreference = "Stop" +$PSNativeCommandUseErrorActionPreference = $false + +try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch {} +$OutputEncoding = [System.Text.Encoding]::UTF8 + +$here = if ($PSScriptRoot) { $PSScriptRoot } else { (Get-Location).Path } + +# --- Results bundle ----------------------------------------------------------- + +$stamp = Get-Date -Format "yyyyMMdd-HHmmss" +$resultDir = Join-Path $here "results-ws-$stamp" +New-Item -ItemType Directory -Force $resultDir | Out-Null +$transcriptStarted = $false + +# --- Helpers ------------------------------------------------------------------ + +function Step([string]$m) { Write-Host "`n=== $m ===" -ForegroundColor Cyan } +function Info([string]$m) { Write-Host " $m" } +function Ok([string]$m) { Write-Host "[OK] $m" -ForegroundColor Green } +function Bad([string]$m) { Write-Host "[FAIL] $m" -ForegroundColor Red } +function Warn([string]$m) { Write-Host "[WARN] $m" -ForegroundColor Yellow } + +# Escape backslashes for TOML basic strings. +function Esc([string]$p) { return $p.Replace('\', '\\') } + +# Convert Windows path to forward-slash form (TOML values). +function Fwd([string]$p) { return $p.Replace('\', '/') } + +# -WsPort is NOT actually wired through end to end: the in-sandbox server's +# port is a compile-time const (WS_PORT = 22000 in mxc-ws-agent.rs) -- the +# TOML generation below doesn't patch it. Rather than silently accept an +# override that has no effect, reject it explicitly so a caller doesn't waste +# time debugging a "port already in use" against a port this test never +# actually uses. -RelayPort has no such restriction: it's just the local +# port passed to `openshell forward service --local`, freely chosen per run. +if ($WsPort -ne 22000) { + throw "-WsPort is not wired through to the sandboxed server (compile-time const in mxc-ws-agent.rs); only the default 22000 is supported." +} + +# --- Path variables ----------------------------------------------------------- + +$gateway = Join-Path $here "openshell-gateway.exe" +$cli = Join-Path $here "openshell.exe" +$tomlSrc = Join-Path $here "mxc-ws-gateway.toml" +$toml = Join-Path $resultDir "mxc-ws-gateway.toml" +$policyFile = Join-Path $here "e2e-policies\ws-agent.yaml" +$policyUsed = Join-Path $resultDir "ws-agent.yaml" + +$agentExeSrc = Join-Path $here "mxc-ws-agent.exe" +$agentExe = Join-Path $AgentDir "mxc-ws-agent.exe" + +# openshell-supervisor-relay.exe wraps the per-sandbox command (see mxc-ws-gateway.toml's +# pc_relay_spawner_path) so the driver has a control channel into the sandbox, +# which dynamic forwarding depends on. +$relayExeSrc = Join-Path $here "openshell-supervisor-relay.exe" +$relayExe = Join-Path $AgentDir "openshell-supervisor-relay.exe" + +$gwLog = Join-Path $resultDir "gateway.log" +$gwErrLog = Join-Path $resultDir "gateway.err.log" +$fwdLog = Join-Path $resultDir "forward.log" +$fwdErrLog = Join-Path $resultDir "forward.err.log" + +$script:gwProc = $null +$script:fwdProc = $null +$runId = Get-Date -Format 'MMddHHmmss' +$sandboxName = "mxc-ws-$runId" + +# --- Gateway management ------------------------------------------------------- + +function Start-Gw { + Remove-Item $gwLog, $gwErrLog -Force -ErrorAction SilentlyContinue + $env:OPENSHELL_GATEWAY_CONFIG = $toml + $env:OPENSHELL_DRIVERS = "mxc" + $env:OPENSHELL_MXC_SHARE_DIR = $AgentDir + $p = Start-Process -FilePath $gateway ` + -ArgumentList @("--disable-tls", "--db-url", "sqlite::memory:", "--log-level", "info", "--port", $Port) ` + -WorkingDirectory $here -PassThru -NoNewWindow ` + -RedirectStandardOutput $gwLog -RedirectStandardError $gwErrLog + $deadline = (Get-Date).AddSeconds(30) + while ((Get-Date) -lt $deadline) { + if ($p.HasExited) { + Get-Content $gwLog, $gwErrLog -Encoding UTF8 -ErrorAction SilentlyContinue | + ForEach-Object { Info $_ } + throw "gateway exited early (code $($p.ExitCode)). See $gwLog." + } + if (Get-NetTCPConnection -State Listen -LocalPort $Port -ErrorAction SilentlyContinue) { + return $p + } + Start-Sleep -Milliseconds 400 + } + if (-not $p.HasExited) { Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue } + throw "gateway did not start within 30 s." +} + +function Stop-Gw($p) { + if ($p -and -not $p.HasExited) { + Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue + } + Start-Sleep -Milliseconds 700 +} + +# --- CLI registration --------------------------------------------------------- + +function Register-Cli { + $env:OPENSHELL_GATEWAY = "" + $addMsg = "" + try { + & $cli gateway add "http://127.0.0.1:$Port" --local --name $GatewayName 2>&1 | + ForEach-Object { $addMsg += "$_`n"; Info $_ } + } catch { + $addMsg = $_.Exception.Message + Info "gateway add: $addMsg" + } + if ($addMsg -match 'different endpoint') { + # Registered at a stale port; remove and re-add. + Info "removing stale gateway registration and re-adding at port $Port" + try { & $cli gateway remove $GatewayName 2>&1 | Out-Null } catch {} + try { + & $cli gateway add "http://127.0.0.1:$Port" --local --name $GatewayName 2>&1 | + ForEach-Object { Info $_ } + } catch { Info "gateway add retry: $($_.Exception.Message) (continuing)" } + } + try { & $cli gateway select $GatewayName 2>&1 | ForEach-Object { Info $_ } } + catch { Info "gateway select: $($_.Exception.Message) (continuing)" } +} + +# --- Port polling ------------------------------------------------------------- + +function Wait-PortOpen([int]$port, [int]$seconds) { + $deadline = (Get-Date).AddSeconds($seconds) + while ((Get-Date) -lt $deadline) { + if (Get-NetTCPConnection -State Listen -LocalPort $port -ErrorAction SilentlyContinue) { + return $true + } + Start-Sleep -Milliseconds 500 + } + return $false +} + +function Wait-PortClosed([int]$port, [int]$seconds) { + $deadline = (Get-Date).AddSeconds($seconds) + while ((Get-Date) -lt $deadline) { + if (-not (Get-NetTCPConnection -State Listen -LocalPort $port -ErrorAction SilentlyContinue)) { + return $true + } + Start-Sleep -Milliseconds 500 + } + return $false +} + +# --- WebSocket echo test ------------------------------------------------------ +# +# Uses System.Net.WebSockets.ClientWebSocket (.NET 4.5+ / PS 5.1). +# Sends $msg over WebSocket and checks the server echoes it back unchanged. + +function Test-WsEcho([string]$wsHost, [int]$port, [string]$msg) { + $uri = [Uri]("ws://" + $wsHost + ":" + $port) + $ws = New-Object System.Net.WebSockets.ClientWebSocket + $cts = New-Object System.Threading.CancellationTokenSource(10000) + + try { + Info "connecting to $uri ..." + $ws.ConnectAsync($uri, $cts.Token).Wait() + if ($ws.State -ne [System.Net.WebSockets.WebSocketState]::Open) { + throw ("WebSocket did not open (state: " + $ws.State + ")") + } + Info "connected" + + # Send a text frame. + $sendBytes = [System.Text.Encoding]::UTF8.GetBytes($msg) + $segment = New-Object System.ArraySegment[byte] (,$sendBytes) + $ws.SendAsync($segment, [System.Net.WebSockets.WebSocketMessageType]::Text, + $true, $cts.Token).Wait() + Info "sent: $msg" + + # Receive the echo. + $recvBuf = New-Object byte[] 4096 + $recvSeg = New-Object System.ArraySegment[byte] (,$recvBuf) + $result = $ws.ReceiveAsync($recvSeg, $cts.Token).Result + $echo = [System.Text.Encoding]::UTF8.GetString($recvBuf, 0, $result.Count) + Info "received: $echo" + + $echoMatched = ($echo -eq $msg) + + # Graceful close -- best-effort. The relay may not complete the WS + # Close handshake, so ignore close errors when the echo already matched. + try { + $ws.CloseAsync([System.Net.WebSockets.WebSocketCloseStatus]::NormalClosure, + "test done", $cts.Token).Wait() + } catch {} + + return $echoMatched + } catch { + Warn ("WebSocket test error: " + $_.Exception.GetBaseException().Message) + return $false + } finally { + $cts.Dispose() + $ws.Dispose() + } +} + +# --- Render gateway TOML ------------------------------------------------------ + +function Render-Toml { + if (-not (Test-Path $tomlSrc)) { + throw "base TOML not found at $tomlSrc" + } + $t = Get-Content $tomlSrc -Raw + + if (-not $Mock) { + $t = [regex]::Replace($t, '(?m)^\s*#?\s*wxc_exec_path\s*=.*$', + "wxc_exec_path = `"$(Esc $WxcExecPath)`"") + } + + $relayExeFwd = Fwd $relayExe + + $t = [regex]::Replace($t, '(?m)^\s*#?\s*pc_relay_spawner_path\s*=.*$', + "pc_relay_spawner_path = `"$relayExeFwd`"") + + Set-Content $toml -Value $t -Encoding UTF8 +} + +# --- Render policy (disposable copy) ------------------------------------------ + +# The policy's read_write grant is the only source of filesystem access now +# (the driver never adds a workload directory automatically) -- it hardcodes +# the default AgentDir, so it needs the same -AgentDir substitution or an override loses its grant +# entirely and the wrapped server can't even read its own binary/DLLs. +function Render-Policy { + if (-not (Test-Path $policyFile)) { + throw "policy not found at $policyFile" + } + $p = Get-Content $policyFile -Raw + $defaultAgentDirPolicy = "C:/work/openshell-mxc-ws" + $agentDirPolicy = (Fwd $AgentDir) + if ($agentDirPolicy -ne $defaultAgentDirPolicy) { + $p = $p.Replace($defaultAgentDirPolicy, $agentDirPolicy) + } + Set-Content $policyUsed -Value $p -Encoding UTF8 +} + +# --- Results tracking --------------------------------------------------------- + +$checks = New-Object System.Collections.ArrayList +$harnessError = $null + +function Record([string]$name, [bool]$pass, [string]$detail) { + $resultStr = if ($pass) { "PASS" } else { "FAIL" } + $r = [pscustomobject]@{ Check = $name; Result = $resultStr; Detail = $detail } + [void]$checks.Add($r) + if ($pass) { Ok ($name + ": " + $detail) } else { Bad ($name + ": " + $detail) } +} + +# ============================================================================= +# MAIN +# ============================================================================= + +try { + Start-Transcript -Path (Join-Path $resultDir "transcript.txt") -Force | Out-Null + $transcriptStarted = $true + + # --- Pre-flight ----------------------------------------------------------- + + Step "Pre-flight" + + if ($Mock) { + Info "mock mode: OPENSHELL_MXC_MOCK_WXC=1 -- wxc-exec not invoked, WS connectivity skipped" + $env:OPENSHELL_MXC_MOCK_WXC = "1" + } else { + Remove-Item Env:OPENSHELL_MXC_MOCK_WXC -ErrorAction SilentlyContinue + if ([string]::IsNullOrWhiteSpace($WxcExecPath) -or -not (Test-Path $WxcExecPath)) { + throw "wxc-exec not found at '$WxcExecPath'. Pass -WxcExecPath or use -Mock." + } + Ok "wxc-exec: $WxcExecPath" + + # A real run exercises process_container with egress_proxy = true + # (mxc-ws-gateway.toml). MXC schema 0.8.0-alpha's network_json() + # (mxc.rs) now emits a direct egress.allow rule for 127.0.0.0/8 + # instead of runtimeConfig.networkProxy when a proxy is configured, + # so the driver no longer calls the elevation-only + # NetworkIsolationSetAppContainerConfig -- process_container + + # egress_proxy selects the BaseContainer/PSEC tier and runs + # non-elevated. Elevation is therefore no longer required here; keep + # logging the elevation state for diagnostics only. + $wid = [Security.Principal.WindowsIdentity]::GetCurrent() + $wp = New-Object Security.Principal.WindowsPrincipal($wid) + $admin = $wp.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) + Info "elevated=$admin (not required for this build)" + } + + foreach ($f in @($gateway, $cli, $tomlSrc, $policyFile)) { + if (-not (Test-Path $f)) { + throw "Missing artifact: $f -- Build first or run from a release package folder." + } + } + Ok "gateway, CLI, TOML template, policy file: present" + + # Prepare the agent directory and copy the built binaries. + New-Item -ItemType Directory -Force $AgentDir | Out-Null + # Remove stale files from previous runs. + Remove-Item (Join-Path $AgentDir "openshell-shutdown.signal") -Force -ErrorAction SilentlyContinue + Remove-Item (Join-Path $AgentDir "appcontainer-sid.txt") -Force -ErrorAction SilentlyContinue + Remove-Item (Join-Path $AgentDir "outbound-probe.txt") -Force -ErrorAction SilentlyContinue + Remove-Item (Join-Path $AgentDir "outbound-probe-addr.txt") -Force -ErrorAction SilentlyContinue + if (Test-Path $agentExeSrc) { + try { + Copy-Item $agentExeSrc $agentExe -Force + Ok "mxc-ws-agent.exe copied from release build" + } catch { + # File is locked by a stale process from a previous run. + # If an existing copy is present it is safe to proceed - the lock + # just means an AppContainer is still holding the old image. + if (Test-Path $agentExe) { + Warn ("Could not overwrite mxc-ws-agent.exe (file in use): " + $_.Exception.Message) + Warn "Proceeding with the existing copy -- it may be an older build." + } else { + throw + } + } + } elseif (-not (Test-Path $agentExe)) { + throw ("mxc-ws-agent.exe not found at " + $agentExeSrc + " or " + $agentExe + ". " + + "Run from the package folder (mxc-ws-agent.exe should sit alongside this script), " + + "or build with: cargo build --release --target x86_64-pc-windows-msvc -p openshell-driver-mxc --example mxc-ws-agent") + } else { + Info "mxc-ws-agent.exe already in $AgentDir (using existing)" + } + + if (Test-Path $relayExeSrc) { + try { + Copy-Item $relayExeSrc $relayExe -Force + Ok "openshell-supervisor-relay.exe copied from release build" + } catch { + if (Test-Path $relayExe) { + Warn ("Could not overwrite openshell-supervisor-relay.exe (file in use): " + $_.Exception.Message) + Warn "Proceeding with the existing copy -- it may be an older build." + } else { + throw + } + } + } elseif (-not (Test-Path $relayExe)) { + throw ("openshell-supervisor-relay.exe not found at " + $relayExeSrc + " or " + $relayExe + ". " + + "Run from the package folder (it should sit alongside this script), " + + "or build with: cargo build --release --target x86_64-pc-windows-msvc -p openshell-supervisor-relay") + } else { + Info "openshell-supervisor-relay.exe already in $AgentDir (using existing)" + } + + # --- Port availability ---------------------------------------------------- + + Step "Check ports" + $busyGw = Get-NetTCPConnection -State Listen -LocalPort $Port -ErrorAction SilentlyContinue + if ($busyGw) { + throw "gateway port $Port already in use (pid $($busyGw.OwningProcess)). Stop stale process first." + } + Ok "gateway port $Port is free" + + $busyWs = Get-NetTCPConnection -State Listen -LocalPort $WsPort -ErrorAction SilentlyContinue + if ($busyWs) { + throw "WebSocket port $WsPort already in use (pid $($busyWs.OwningProcess)). Free it before running this test." + } + Ok "WebSocket port $WsPort is free" + + $busyRelay = Get-NetTCPConnection -State Listen -LocalPort $RelayPort -ErrorAction SilentlyContinue + if ($busyRelay) { + throw "relay port $RelayPort already in use (pid $($busyRelay.OwningProcess)). Free it before running this test." + } + Ok "relay port $RelayPort is free" + + # --- Render TOML + start gateway ------------------------------------------ + + Step "Render gateway TOML" + Render-Toml + Render-Policy + Copy-Item $toml (Join-Path $resultDir "mxc-ws-gateway.rendered.toml") -Force -ErrorAction SilentlyContinue + Info "rendered TOML: $toml" + Info "policy: $policyUsed" + + Step "Start gateway (port $Port)" + $script:gwProc = Start-Gw + Info "gateway pid $($script:gwProc.Id)" + Record "gateway-start" $true "pid $($script:gwProc.Id), port $Port" + + # --- Register CLI --------------------------------------------------------- + + Step "Register CLI" + Register-Cli + Ok "gateway '$GatewayName' registered" + + # --- Create sandbox ------------------------------------------------------- + + Step "Create sandbox '$sandboxName'" + $createOut = $null; $createExitCode = 0 + $driverConfigJson = @{ + mxc = @{ + command = @((Fwd $agentExe), "server") + cwd = (Fwd $AgentDir) + } + } | ConvertTo-Json -Compress -Depth 4 + try { + # MXC exec-in-driver has no SSH server, so any `sandbox create` invocation + # that attempts SSH will fail with connection-refused and exit non-zero. + # Use the same pattern as run-mxc-e2e.ps1: pass --no-tty with a no-op + # command so the CLI fires the SSH attempt, fails quickly (connection + # refused), and returns. Do NOT gate on exit code here. + $createOut = & $cli sandbox create ` + --name $sandboxName ` + --policy $policyUsed ` + --driver-config-json $driverConfigJson ` + --no-tty ` + -- cmd.exe /c exit 0 ` + 2>&1 + $createExitCode = $LASTEXITCODE + } catch { + $createOut = $_.Exception.Message; $createExitCode = 1 + } + $createStr = ($createOut -join "`n") + Info "create exit: $createExitCode (non-zero expected for MXC -- no SSH server)" + + # Verify the sandbox actually exists by fetching it. + Start-Sleep -Milliseconds 500 + $getOut = $null; $getExitCode = 0 + try { + $getOut = & $cli sandbox get $sandboxName 2>&1 + $getExitCode = $LASTEXITCODE + } catch { + $getOut = $_.Exception.Message; $getExitCode = 1 + } + $getStr = ($getOut -join "`n") + # `sandbox get`'s text output prints "Phase: " (see run.rs); + # require Ready, not just presence -- a sandbox that exists but is stuck + # Provisioning/Error is not actually usable for the WebSocket check below. + $createOk = ($getExitCode -eq 0) -and ($getStr -notmatch 'not found|does not exist') -and ($getStr -match '(?m)^\s*Phase:\s*Ready\s*$') + Record "sandbox-create" $createOk "sandbox $sandboxName $(if ($createOk) {'exists and is Ready'} else {'not found or not Ready after create'})" + + if (-not $createOk) { + Info "create output: $createStr" + Info "get output: $getStr" + throw "sandbox create failed: sandbox does not exist or is not Ready after create" + } + + # --- WebSocket connectivity (real mode only) ------------------------------ + + if (-not $Mock) { + + Step "Wait for WebSocket server on port $WsPort" + $serverUp = Wait-PortOpen -port $WsPort -seconds 30 + if ($serverUp) { + Record "server-port-open" $true "port $WsPort is listening" + } else { + $gwText = (Get-Content $gwLog, $gwErrLog -Raw -ErrorAction SilentlyContinue) -join "`n" + $detail = "port $WsPort did NOT open within 30 s" + if ($gwText -match 'CreateProcessW failed') { + $detail = $detail + " (gateway log: agent launch failure)" + } + Record "server-port-open" $false $detail + } + + # Cross-check server-port-open against openshell-supervisor-relay's own + # confirmation, from inside the sandbox: it detects target-port + # readiness itself (wait_for_port_ready, gated by the "launch" + # handshake) and logs it, forwarded into the gateway log the same way + # as every other wxc-exec stdout/stderr line. No share_dir file + # needed -- this is the same information the marker file used to + # carry, just sourced from the spawner's own diagnostic instead. + if ($serverUp) { + Step "Verify spawner's own port-ready confirmation (gateway log)" + # The spawner's own polling (wait_for_port_ready, 300ms interval) + # runs independently of this script's Wait-PortOpen above -- its + # log line can land a couple of seconds after the raw TCP connect + # already succeeded (observed up to ~2.3s). Poll for it rather + # than checking once immediately, or this races and fails spuriously. + $readyPattern = "port $WsPort ready after" + $readyDeadline = (Get-Date).AddSeconds(15) + $readyOk = $false + while ((Get-Date) -lt $readyDeadline -and -not $readyOk) { + $readyOk = (Test-Path $gwLog -PathType Leaf) -and (Select-String -Path $gwLog -Pattern $readyPattern -Quiet -ErrorAction SilentlyContinue) + if (-not $readyOk) { + $readyOk = (Test-Path $gwErrLog -PathType Leaf) -and (Select-String -Path $gwErrLog -Pattern $readyPattern -Quiet -ErrorAction SilentlyContinue) + } + if (-not $readyOk) { Start-Sleep -Milliseconds 300 } + } + if ($readyOk) { + Record "ws-server-marker" $true "spawner logged '$readyPattern' in the gateway log" + } else { + Record "ws-server-marker" $false "spawner's port-ready log line not found in gateway.log/gateway.err.log within 15 s" + } + } else { + Warn "skipping ws-server-marker: server did not start" + } + + # `openshell forward service` opens a fresh, on-demand relay for this + # one call, bridging $WsPort (inside the sandbox) to $RelayPort (on + # this host). No port needs to be pre-declared anywhere except + # pc_relay_target_port's own startup liveness check. Mirrors + # run-openclaw-forward-test.ps1's step 10. + if ($serverUp) { + Step "openshell forward service --target-port $WsPort --local $RelayPort" + $script:fwdProc = Start-Process -FilePath $cli ` + -ArgumentList @("forward", "service", "--target-port", "$WsPort", "--local", "$RelayPort", $sandboxName) ` + -WorkingDirectory $here -PassThru -NoNewWindow ` + -RedirectStandardOutput $fwdLog -RedirectStandardError $fwdErrLog + Info "forward pid $($script:fwdProc.Id)" + $fwdDeadline = (Get-Date).AddSeconds(20); $fwdUp = $false + while ((Get-Date) -lt $fwdDeadline) { + if ($script:fwdProc.HasExited) { break } + if ((Test-Path $fwdLog) -and (Select-String -Path $fwdLog -Pattern 'Forwarding' -Quiet -ErrorAction SilentlyContinue)) { $fwdUp = $true; break } + if ((Test-Path $fwdErrLog) -and (Select-String -Path $fwdErrLog -Pattern 'Forwarding' -Quiet -ErrorAction SilentlyContinue)) { $fwdUp = $true; break } + Start-Sleep -Milliseconds 500 + } + + if ($fwdUp) { + Ok "forward active: 127.0.0.1:$RelayPort -> sandbox:$WsPort" + + Step "WebSocket echo test via forwarded port (ws://127.0.0.1:$RelayPort)" + $echoOk = Test-WsEcho -wsHost "127.0.0.1" -port $RelayPort -msg $WsMessage + if ($echoOk) { + Record "ws-echo" $true ("'" + $WsMessage + "' echoed via forwarded port $RelayPort") + } else { + Record "ws-echo" $false "echo failed via forwarded port $RelayPort -- see transcript" + } + } else { + $exitDetail = if ($script:fwdProc.HasExited) { " (forward process exited early, code $($script:fwdProc.ExitCode))" } else { "" } + Record "ws-echo" $false "forward did not report 'Forwarding ...' within 20 s$exitDetail -- see forward.log/forward.err.log" + } + } else { + Warn "skipping ws-echo: server did not start" + } + + } else { + Info "[mock] skipping server-port-open and ws-echo" + } + + # Stop the forward before deleting the sandbox it points at. + if ($script:fwdProc -and -not $script:fwdProc.HasExited) { + try { Stop-Process -Id $script:fwdProc.Id -Force -ErrorAction SilentlyContinue } catch {} + } + + # --- Delete sandbox ------------------------------------------------------- + + Step "Delete sandbox '$sandboxName'" + $deleteOut = $null; $deleteExitCode = 0 + try { + $deleteOut = & $cli sandbox delete $sandboxName 2>&1 + $deleteExitCode = $LASTEXITCODE + } catch { + $deleteOut = $_.Exception.Message; $deleteExitCode = 1 + } + $deleteStr = ($deleteOut -join "`n") + Info "delete exit: $deleteExitCode" + if ($deleteExitCode -ne 0) { Info "output: $deleteStr" } + Record "sandbox-delete" ($deleteExitCode -eq 0) "exit $deleteExitCode" + + # --- Port freed (real mode only) ------------------------------------------ + + if (-not $Mock) { + Step "Verify port $WsPort is released after delete" + # The driver sends a "shutdown" control-channel request (openshell- + # supervisor-relay kills the server directly) and kills wxc-exec as a + # backstop regardless. Allow 30 s for that plus the OS to release the + # port. + $portClosed = Wait-PortClosed -port $WsPort -seconds 30 + if ($portClosed) { + Record "port-freed" $true "port $WsPort released within 30 s" + } else { + Record "port-freed" $false "port $WsPort still bound after 30 s" + } + } else { + Info "[mock] skipping port-freed check" + } + +} catch { + $harnessError = $_.Exception.Message + Bad "harness error: $harnessError" +} finally { + # --- Teardown ------------------------------------------------------------- + + # Best-effort forward/sandbox cleanup in case the test failed mid-run. + if ($script:fwdProc -and -not $script:fwdProc.HasExited) { + try { Stop-Process -Id $script:fwdProc.Id -Force -ErrorAction SilentlyContinue } catch {} + } + try { & $cli sandbox delete $sandboxName 2>&1 | Out-Null } catch {} + + if (-not $KeepRunning) { + Stop-Gw $script:gwProc + $script:gwProc = $null + } elseif ($script:gwProc) { + Info "gateway pid $($script:gwProc.Id) left running (-KeepRunning)" + } + + # --- Summary -------------------------------------------------------------- + + Step "Summary" + $checks | Format-Table -AutoSize + + $failCount = @($checks | Where-Object { $_.Result -eq "FAIL" }).Count + $passCount = @($checks | Where-Object { $_.Result -eq "PASS" }).Count + Write-Host "PASS=$passCount FAIL=$failCount" + + $verdict = if ($harnessError -or $failCount -gt 0) { "FAIL" } else { "PASS" } + $checkLines = ($checks | ForEach-Object { " " + $_.Result + " " + $_.Check + ": " + $_.Detail }) -join "`n" + $modeStr = if ($Mock) { "MOCK (no wxc-exec, no WS connectivity)" } else { "REAL" } + $wxcStr = if ($Mock) { "(mock)" } else { $WxcExecPath } + $errStr = if ($harnessError) { "harness_error: $harnessError" } else { "" } + + $summary = "OpenShell MXC WebSocket agent test`n" + + "====================================`n" + + "timestamp : $stamp`n" + + "machine : $env:COMPUTERNAME`n" + + "verdict : $verdict`n" + + "mode : $modeStr`n" + + "gateway : $gateway (port $Port)`n" + + "agent_dir : $AgentDir`n" + + "agent_exe : $agentExe`n" + + "relay_exe : $relayExe`n" + + "policy : $policyUsed`n" + + "sandbox : $sandboxName`n" + + "ws_port : $WsPort`n" + + "relay_port : $RelayPort (on this host)`n" + + "ws_message : $WsMessage`n" + + "wxc_exec : $wxcStr`n" + + "totals : PASS=$passCount FAIL=$failCount`n" + + "$errStr`n" + + "`nChecks:`n$checkLines`n" + + "`nFiles in this bundle ($resultDir):`n" + + " transcript.txt full console transcript`n" + + " gateway.log / gateway.err.log gateway stdout / stderr`n" + + " forward.log / forward.err.log 'openshell forward service' stdout / stderr`n" + + " mxc-ws-gateway.rendered.toml exact gateway config used`n" + + " ws-agent.yaml sandbox policy used`n" + + "`nWhat PASS means:`n" + + " gateway-start gateway bound port $Port within 30 s`n" + + " sandbox-create sandbox reached Ready after create (CLI exit may be non-zero on MXC without SSH)`n" + + " server-port-open WS server bound port $WsPort within 30 s`n" + + " ws-server-marker spawner logged its own port-ready confirmation in the gateway log`n" + + " ws-echo '$WsMessage' echoed via a dynamic 'openshell forward service' relay at 127.0.0.1:$RelayPort`n" + + " (fresh, on-demand relay for this one call -- no static bridge, nothing pre-declared)`n" + + " sandbox-delete CLI returned exit 0 for sandbox delete`n" + + " port-freed port $WsPort released within 30 s of sandbox delete`n" + + Set-Content (Join-Path $resultDir "summary.txt") -Value $summary -Encoding UTF8 + $color = if ($verdict -eq "PASS") { "Green" } else { "Red" } + Write-Host $summary -ForegroundColor $color + + if ($transcriptStarted) { try { Stop-Transcript | Out-Null } catch {} } + + # Zip the bundle. + try { + $zip = Join-Path $here "results-ws-$stamp.zip" + if (Test-Path $zip) { Remove-Item $zip -Force } + Compress-Archive -Path (Join-Path $resultDir "*") -DestinationPath $zip -Force + Write-Host "`nResults bundle: $zip" -ForegroundColor Yellow + } catch { Write-Host "zip failed: $($_.Exception.Message)" -ForegroundColor Red } +} + +if ($harnessError -or (@($checks | Where-Object { $_.Result -eq "FAIL" }).Count -gt 0)) { + Write-Host "`nTEST FAILED" -ForegroundColor Red + exit 1 +} else { + Write-Host "`nTEST PASSED" -ForegroundColor Green + exit 0 +} diff --git a/crates/openshell-driver-mxc/src/control_channel.rs b/crates/openshell-driver-mxc/src/control_channel.rs new file mode 100644 index 0000000000..673b82177e --- /dev/null +++ b/crates/openshell-driver-mxc/src/control_channel.rs @@ -0,0 +1,410 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Request/response JSON control channel to a sandboxed process, riding +//! `wxc-exec`'s inherited stdin/stdout (STDIO passthrough) — see the +//! `openshell-supervisor-relay` crate's module docs for the protocol +//! and why this needs no `AppContainer` network capability at all: it's +//! inherited process handles, not network traffic. + +use serde_json::Value; +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Duration; +use tokio::io::AsyncWriteExt; +use tokio::process::ChildStdin; +use tokio::sync::{Mutex, oneshot}; + +#[derive(Debug, thiserror::Error)] +pub enum ControlChannelError { + #[error("control channel write failed: {0}")] + Write(#[source] std::io::Error), + #[error("control channel response sender dropped")] + Dropped, + #[error("control channel request timed out after {0:?}")] + Timeout(Duration), + #[error("control channel serialize failed: {0}")] + Serialize(#[from] serde_json::Error), +} + +type PendingMap = Mutex>>; +/// Slot for one of the spawner's one-time, unsolicited events -- startup- +/// ready (see `try_route_ready`) and target-ready (see +/// `try_route_target_ready`) each get their own instance of this type. +/// Not part of `PendingMap`: neither has a correlation id or is a reply to +/// anything the driver sent. The payload is `Ok(())` for a normal fire, or +/// `Err(reason)` when the event fired but something about it was rejected +/// (currently only the "ready" event's protocol version check uses this; +/// `"target_ready"` always sends `Ok(())`). +pub type ReadySlot = Mutex>>>; + +/// Wire protocol version this driver requires from +/// `openshell-supervisor-relay`'s startup `"ready"` event (see +/// `try_route_ready`). Must match that crate's own `PROTOCOL_VERSION` +/// constant -- duplicated rather than shared via a common crate, matching +/// how the rest of this wire protocol (event/op names, the auth nonce +/// encoding, etc.) is already duplicated across the two sides. Bump both +/// together whenever the control-channel protocol changes in a way an +/// out-of-sync peer can't safely ignore (e.g. the "nonce" field added to +/// "forward", or the `"target_ready"` event itself) -- an independently +/// staged, stale relay binary then fails fast with a clear error instead of +/// hanging or misbehaving against fields/events it doesn't understand. +const REQUIRED_SUPERVISOR_RELAY_PROTOCOL_VERSION: u64 = 2; + +/// One control channel per sandboxed process. `request()` is safe to call +/// concurrently — each call gets its own correlation id and awaits only its +/// own response, so multiple in-flight requests (e.g. concurrent `forward` +/// calls) don't interfere with each other. +pub struct ControlChannel { + stdin: Mutex, + next_id: AtomicU64, + pending: Arc, +} + +impl ControlChannel { + pub fn new(stdin: ChildStdin) -> Self { + Self { + stdin: Mutex::new(stdin), + next_id: AtomicU64::new(1), + pending: Arc::new(Mutex::new(HashMap::new())), + } + } + + /// A clonable handle to the pending-requests map, for the stdout-reader + /// task (which owns the read side) to route responses into. + pub fn pending_handle(&self) -> Arc { + self.pending.clone() + } + + /// Try to parse `line` as a control-channel response and complete the + /// matching pending request. Returns `true` if `line` was consumed this + /// way; `false` means the caller should treat it as plain log text + /// instead (covers wxc-exec's own banner/config-dump lines, which are + /// never `{"id":...}`-shaped). + pub async fn try_route_response(pending: &PendingMap, line: &str) -> bool { + let Ok(value) = serde_json::from_str::(line) else { + return false; + }; + let Some(id) = value.get("id").and_then(Value::as_u64) else { + return false; + }; + let mut map = pending.lock().await; + map.remove(&id).is_some_and(|tx| { + let _ = tx.send(value); + true + }) + } + + /// Try to recognize `line` as the spawner's unsolicited startup-ready + /// event (`{"event":"ready","protocol_version":N}`) -- sent once, + /// before it's spawned anything, so the driver knows when to send the + /// `"launch"` request carrying the real command/env (see driver.rs's + /// launch handshake and the `openshell-supervisor-relay` crate's module + /// docs). Unlike a query response this has no correlation id, so it + /// can't go through `try_route_response`. Returns `true` if `line` was + /// consumed this way (regardless of whether the version check passed -- + /// the caller distinguishes that via the channel payload). + /// + /// Validates `protocol_version` against + /// `REQUIRED_SUPERVISOR_RELAY_PROTOCOL_VERSION` so an independently + /// staged, out-of-sync relay binary (e.g. left over from an older + /// package drop in a shared `share_dir`) fails the sandbox immediately + /// with a clear "wrong version" error instead of hanging or misbehaving + /// later against a "launch"/"forward" field or a `"target_ready"` event it + /// doesn't understand. A missing field means a pre-versioning binary -- + /// also rejected, since there's no version to compare. + pub async fn try_route_ready(ready: &ReadySlot, line: &str) -> bool { + Self::try_route_named_event(ready, line, "ready", |value| { + match value.get("protocol_version").and_then(Value::as_u64) { + Some(v) if v == REQUIRED_SUPERVISOR_RELAY_PROTOCOL_VERSION => Ok(()), + Some(v) => Err(format!( + "openshell-supervisor-relay reports protocol_version {v}, this driver requires {REQUIRED_SUPERVISOR_RELAY_PROTOCOL_VERSION} -- restage a matching build" + )), + None => Err(format!( + "openshell-supervisor-relay's ready event has no protocol_version field (pre-versioning binary); this driver requires {REQUIRED_SUPERVISOR_RELAY_PROTOCOL_VERSION} -- restage a matching build" + )), + } + }) + .await + } + + /// Try to recognize `line` as the spawner's unsolicited target-ready + /// event (`{"event":"target_ready"}`) -- sent once the spawner has + /// actually spawned the target and confirmed its configured port is + /// accepting connections (see `wait_for_port_ready` in + /// `openshell-supervisor-relay`). Distinct from the `"launch"` + /// control-channel *response*, which only confirms the command/env + /// arrived, not that the target is running: driver.rs awaits this event + /// too before publishing the sandbox `Ready=True`, so a caller acting on + /// `Ready` can't race a target that hasn't bound its port yet. Returns + /// `true` if `line` was consumed this way. No version gate here -- the + /// startup "ready" handshake above already rejected an incompatible + /// peer long before this could fire. + pub async fn try_route_target_ready(target_ready: &ReadySlot, line: &str) -> bool { + Self::try_route_named_event(target_ready, line, "target_ready", |_| Ok(())).await + } + + async fn try_route_named_event( + slot: &ReadySlot, + line: &str, + event_name: &str, + validate: impl FnOnce(&Value) -> Result<(), String>, + ) -> bool { + let Ok(value) = serde_json::from_str::(line) else { + return false; + }; + if value.get("event").and_then(|v| v.as_str()) != Some(event_name) { + return false; + } + let sender = slot.lock().await.take(); + if let Some(tx) = sender { + let _ = tx.send(validate(&value)); + } + true + } + + /// Fail every currently pending request with `Dropped`, e.g. when the + /// stdout-reader task observes EOF/error on the child's stdout: once the + /// reader is gone, no response will ever arrive for these ids, so let + /// callers fail fast instead of sitting out their individual timeouts. + /// Dropping each sender (rather than sending a value) is what makes the + /// waiting `request()` call observe `ControlChannelError::Dropped`. + pub async fn fail_all_pending(pending: &PendingMap) { + let mut map = pending.lock().await; + map.clear(); + } + + /// Send `{"id":N,"op":op,"data":data}` and await the correlated + /// response, or an error on write failure, timeout, or a dropped sender + /// (the reader task exited, e.g. the process died). + pub async fn request( + &self, + op: &str, + data: Value, + timeout: Duration, + ) -> Result { + let id = self.next_id.fetch_add(1, Ordering::Relaxed); + let (tx, rx) = oneshot::channel(); + self.pending.lock().await.insert(id, tx); + + let req = serde_json::json!({"id": id, "op": op, "data": data}); + let mut line = serde_json::to_string(&req)?; + line.push('\n'); + + let write_result = { + let mut stdin = self.stdin.lock().await; + match stdin.write_all(line.as_bytes()).await { + Ok(()) => stdin.flush().await, + Err(e) => Err(e), + } + }; + if let Err(e) = write_result { + self.pending.lock().await.remove(&id); + return Err(ControlChannelError::Write(e)); + } + + match tokio::time::timeout(timeout, rx).await { + Ok(Ok(value)) => Ok(value), + Ok(Err(_)) => Err(ControlChannelError::Dropped), + Err(_) => { + self.pending.lock().await.remove(&id); + Err(ControlChannelError::Timeout(timeout)) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn empty_ready_slot() -> ReadySlot { + Mutex::new(None) + } + + fn armed_ready_slot() -> (ReadySlot, oneshot::Receiver>) { + let (tx, rx) = oneshot::channel(); + (Mutex::new(Some(tx)), rx) + } + + // ── try_route_response ────────────────────────────────────────────── + + #[tokio::test] + async fn try_route_response_completes_matching_pending_id() { + let pending: PendingMap = Mutex::new(HashMap::new()); + let (tx, rx) = oneshot::channel(); + pending.lock().await.insert(7, tx); + + let consumed = + ControlChannel::try_route_response(&pending, r#"{"id":7,"ok":true,"data":42}"#).await; + + assert!(consumed); + let value = rx.await.unwrap(); + assert_eq!(value["data"], 42); + assert!(pending.lock().await.is_empty()); + } + + #[tokio::test] + async fn try_route_response_ignores_unknown_id() { + let pending: PendingMap = Mutex::new(HashMap::new()); + let (tx, _rx) = oneshot::channel(); + pending.lock().await.insert(1, tx); + + let consumed = ControlChannel::try_route_response(&pending, r#"{"id":99,"ok":true}"#).await; + + assert!( + !consumed, + "an id with no pending sender must not be consumed" + ); + assert_eq!( + pending.lock().await.len(), + 1, + "the real pending entry survives" + ); + } + + #[tokio::test] + async fn try_route_response_ignores_non_json_and_id_less_lines() { + let pending: PendingMap = Mutex::new(HashMap::new()); + + assert!(!ControlChannel::try_route_response(&pending, "not json at all").await); + assert!(!ControlChannel::try_route_response(&pending, r#"{"event":"ready"}"#).await); + } + + // ── try_route_ready (protocol-version handshake) ──────────────────── + + #[tokio::test] + async fn try_route_ready_accepts_matching_protocol_version() { + let (slot, rx) = armed_ready_slot(); + + let consumed = + ControlChannel::try_route_ready(&slot, r#"{"event":"ready","protocol_version":2}"#) + .await; + + assert!(consumed); + assert_eq!(rx.await.unwrap(), Ok(())); + } + + #[tokio::test] + async fn try_route_ready_rejects_mismatched_protocol_version() { + let (slot, rx) = armed_ready_slot(); + + let consumed = + ControlChannel::try_route_ready(&slot, r#"{"event":"ready","protocol_version":1}"#) + .await; + + assert!( + consumed, + "a recognized ready event is consumed even when rejected" + ); + let err = rx.await.unwrap().expect_err("version 2 must be rejected"); + assert!( + err.contains('2'), + "error should name the offending version: {err}" + ); + assert!( + err.contains('1'), + "error should name the required version: {err}" + ); + } + + #[tokio::test] + async fn try_route_ready_rejects_missing_protocol_version_field() { + let (slot, rx) = armed_ready_slot(); + + let consumed = ControlChannel::try_route_ready(&slot, r#"{"event":"ready"}"#).await; + + assert!(consumed); + let err = rx + .await + .unwrap() + .expect_err("a missing field must be rejected"); + assert!( + err.contains("pre-versioning"), + "error should call out the pre-versioning case: {err}" + ); + } + + #[tokio::test] + async fn try_route_ready_ignores_other_events_and_non_json() { + let slot = empty_ready_slot(); + + assert!(!ControlChannel::try_route_ready(&slot, r#"{"event":"target_ready"}"#).await); + assert!(!ControlChannel::try_route_ready(&slot, "garbage").await); + } + + // ── try_route_target_ready ─────────────────────────────────────────── + + #[tokio::test] + async fn try_route_target_ready_fires_ok_with_no_version_gate() { + let (slot, rx) = armed_ready_slot(); + + // No protocol_version field at all -- unlike "ready", "target_ready" + // must not be gated on one (see the doc comment on + // try_route_target_ready). + let consumed = + ControlChannel::try_route_target_ready(&slot, r#"{"event":"target_ready"}"#).await; + + assert!(consumed); + assert_eq!(rx.await.unwrap(), Ok(())); + } + + #[tokio::test] + async fn try_route_target_ready_ignores_ready_event() { + let slot = empty_ready_slot(); + + // "ready" and "target_ready" must not be cross-routed into each + // other's slot. + let consumed = ControlChannel::try_route_target_ready( + &slot, + r#"{"event":"ready","protocol_version":2}"#, + ) + .await; + + assert!(!consumed); + } + + #[tokio::test] + async fn try_route_named_event_is_a_safe_no_op_once_the_slot_is_already_empty() { + let (slot, rx) = armed_ready_slot(); + + assert!(ControlChannel::try_route_target_ready(&slot, r#"{"event":"target_ready"}"#).await); + // The slot's sender was taken (and used) on the first fire. A + // repeat of the same event on the wire is still recognized as a + // "target_ready" line (so the caller doesn't mistake it for plain + // log text) but must not panic just because the slot is now empty. + let consumed_again = + ControlChannel::try_route_target_ready(&slot, r#"{"event":"target_ready"}"#).await; + + assert!( + consumed_again, + "still recognized as the event, even as a no-op" + ); + assert_eq!( + rx.await.unwrap(), + Ok(()), + "only the first fire's Ok(()) was ever sent" + ); + } + + // ── fail_all_pending ────────────────────────────────────────────────── + + #[tokio::test] + async fn fail_all_pending_drops_every_sender() { + let pending: PendingMap = Mutex::new(HashMap::new()); + let (tx1, rx1) = oneshot::channel(); + let (tx2, rx2) = oneshot::channel(); + pending.lock().await.insert(1, tx1); + pending.lock().await.insert(2, tx2); + + ControlChannel::fail_all_pending(&pending).await; + + assert!(pending.lock().await.is_empty()); + assert!( + rx1.await.is_err(), + "dropped sender must surface as a recv error" + ); + assert!(rx2.await.is_err()); + } +} diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index 60f824516f..ce728cbeec 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -4,8 +4,11 @@ //! MXC compute backend: lifecycle logic, in-memory registry, exec-in-driver, //! and self-reported readiness. +use crate::control_channel::ControlChannel; use crate::mxc::{MxcFilesystem, MxcNetwork, MxcProcess, MxcProcessContainer, WxcExecInvoker}; use crate::policy::{EmbeddedPolicyMapper, MapCtx, MappedConfig, PolicyMapper}; +use crate::relay; +use base64::Engine as _; use futures::Stream; use openshell_core::gpu::{driver_gpu_requirements, effective_driver_gpu_count}; use openshell_core::proto::SandboxPolicy; @@ -15,14 +18,16 @@ use openshell_core::proto::compute::v1::{ WatchSandboxesPlatformEvent, WatchSandboxesSandboxEvent, watch_sandboxes_event, }; use openshell_core::proto_struct::struct_to_json_value; +use openshell_core::provider_credentials::ProviderCredentialState; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::net::SocketAddr; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::pin::Pin; -use std::sync::Arc; -use tokio::sync::{Mutex, broadcast, mpsc, watch}; -use tokio::task::JoinHandle; +use std::sync::{Arc, Mutex as StdMutex}; +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::process::Child; +use tokio::sync::{Mutex, broadcast, mpsc, oneshot, watch}; use tokio_stream::wrappers::ReceiverStream; use tracing::{info, warn}; @@ -49,12 +54,22 @@ pub enum MxcBackend { ProcessContainer, } +impl MxcBackend { + const fn containment(self) -> &'static str { + match self { + Self::IsolationSession => "isolation_session", + Self::ProcessContainer => "processcontainer", + } + } +} + /// Configuration for the MXC compute driver. /// /// Loaded from `[openshell.drivers.mxc]` in the gateway TOML file, or from /// environment variables / CLI flags via the standard gateway precedence chain. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] +#[allow(clippy::struct_excessive_bools)] // Independent, existing gateway TOML options. pub struct MxcComputeConfig { /// Path to `wxc-exec.exe`. Required for live runs. pub wxc_exec_path: String, @@ -64,12 +79,53 @@ pub struct MxcComputeConfig { pub pc_least_privilege: bool, /// `processContainer` only: `AppContainer` capabilities to grant. pub pc_capabilities: Vec, + /// `processContainer` only: inject a network section with + /// `defaultPolicy: "allow"` so the `AppContainer` has unrestricted outbound + /// TCP access. Required when `pc_capabilities` alone is insufficient to + /// enable network access in the target wxc-exec build. + pub pc_network_allow: bool, + /// `processContainer` only: include `"allowLocalNetwork": true` in the + /// MXC network section. Required for node.js (and other runtimes that + /// need loopback during DLL initialization) to start inside a + /// processcontainer. + pub pc_allow_local_network: bool, + /// `processContainer` only: when `true`, start with an EMPTY process env + /// (not even `MINIMAL_WINDOWS_BOOTSTRAP_ENV`) instead of the safe + /// default -- only the sandbox's explicit per-request environment is + /// passed to the process. + /// Use for agents like Node.js that fail with `STATUS_DLL_INIT_FAILED` + /// when unrecognised host env vars are present; the caller is then + /// responsible for supplying `SYSTEMROOT`/`WINDIR`/`PATH`/`COMSPEC`/ + /// `LOCALAPPDATA` through `sandbox create --env/--env-from` if needed + /// (`CreateProcessW` itself won't succeed without `LOCALAPPDATA` at + /// least -- see `MINIMAL_WINDOWS_BOOTSTRAP_ENV`). + /// + /// The sandbox's explicit per-request environment is layered on top. + pub pc_minimal_env: bool, + /// `processContainer` only: path to a generic spawn+relay-bridge binary + /// (see the `openshell-supervisor-relay` crate). When non-empty (and + /// `pc_relay_target_port != 0`), the driver launches this binary instead + /// of the per-sandbox workload command directly, sending the command/env + /// over the control channel once the spawner announces readiness (the + /// "launch" handshake) rather than writing them to the workload directory. This + /// decouples the relay-bridging logic from the target application (e.g. + /// `OpenClaw`) entirely — the target needs no awareness of the relay + /// protocol. It's also what gives the driver a control channel into the + /// sandbox at all, which `ForwardSink::open_dynamic_forward` (dynamic + /// `openshell forward service` bridging) depends on regardless of any + /// particular port being pre-declared. + pub pc_relay_spawner_path: String, + /// `processContainer` only: the TCP port the workload's target process + /// binds, which `pc_relay_spawner_path` bridges to the gateway relay. + /// Ignored unless `pc_relay_spawner_path` is set. `0` disables spawner + /// wrapping (default) — the per-sandbox command runs directly. + pub pc_relay_target_port: u16, /// MXC `configurationId` for isolation session. Default: `"composable"`. /// Never use `"small"` (known OS bug). pub default_configuration_id: String, - /// Enable Pattern-C governed egress. When true, MXC receives filesystem - /// grants plus a `network.proxy` redirect and the host CONNECT proxy - /// receives the trimmed network-only policy. + /// Enable Pattern-C governed egress. When true, MXC permits loopback-only + /// egress, the driver injects proxy environment variables, and the host + /// CONNECT proxy receives the full network policy. pub egress_proxy: bool, /// Loopback `IP:PORT` seed for MXC `network.proxy` while governed egress is /// enabled. The driver preserves the loopback IP and allocates a unique @@ -91,6 +147,11 @@ impl Default for MxcComputeConfig { backend: MxcBackend::default(), pc_least_privilege: false, pc_capabilities: Vec::new(), + pc_network_allow: false, + pc_relay_spawner_path: String::new(), + pc_relay_target_port: 0, + pc_allow_local_network: false, + pc_minimal_env: false, default_configuration_id: crate::mxc::DEFAULT_CONFIGURATION_ID.into(), egress_proxy: false, egress_proxy_addr: String::new(), @@ -126,13 +187,49 @@ struct SandboxEntry { iso_sandbox_id: Option, isolation_stopped: bool, phase_state: PhaseState, - /// Serializes stop/delete with provisioning and process launch. + /// Serializes stop/delete with provisioning and process launch: taken as + /// an owned guard (`startup_guard`) in `create_sandbox` before the entry + /// is published, and only released once `run_lifecycle` has installed + /// `exec_child`/`shutdown_tx`/`terminated_rx`/`control_channel` (or + /// failed). `stop_sandbox`/`delete_sandbox` block on this same gate + /// before touching any of those fields, so a stop/delete arriving while + /// a sandbox is still starting can't race a launch that hasn't finished + /// wiring the kill/shutdown machinery yet. lifecycle_gate: Arc>, - monitor_cancel: Option>, - monitor_task: Option>, + exec_child: Option, + /// Fires when `delete_sandbox` is called on a `ProcessContainer` sandbox so + /// `monitor_exec` can kill the `wxc-exec` child and release all resources + /// (including ports bound inside the `AppContainer`) before the entry is + /// removed from the registry. + shutdown_tx: Option>, + /// Set to `true` (from `monitor_exec`) once the `wxc-exec` child has + /// genuinely exited -- whether that's a natural exit or the forced kill + /// triggered via `shutdown_tx` above. Lets `stop_sandbox`/`delete_sandbox` + /// await *confirmed* termination (bounded by a timeout) instead of firing + /// the kill signal and immediately reporting success regardless of + /// whether the process actually died. + /// + /// A `watch::Receiver` rather than a `oneshot::Receiver` deliberately: + /// it's `.clone()`d (never `.take()`n) by callers, so it survives a + /// caller that times out and retries -- unlike a consumed oneshot, the + /// retry can still observe the same underlying completion instead of + /// silently skipping the wait because the field looks empty. + terminated_rx: Option>, + /// Path to the shutdown signal file written by `delete_sandbox` so + /// `mxc-ws-agent.rs` (set directly as the sandbox command, no control + /// channel) can detect a deletion and exit cleanly. Only set for that + /// case -- when spawner wrapping is active, `delete_sandbox` sends a + /// `"shutdown"` control-channel request to `openshell-supervisor-relay` + /// instead, so this stays `None`. + signal_file: Option, trimmed_policy: Option, proxy_addr: Option, host_proxy: Option, + /// JSON request/response control channel over the spawner's inherited + /// stdin/stdout (see `control_channel.rs`). Only present when spawner + /// wrapping is active (`pc_relay_spawner_path` configured); dropped on + /// delete, which closes the child's stdin. + control_channel: Option>, } impl std::fmt::Debug for SandboxEntry { @@ -197,6 +294,10 @@ pub struct MxcComputeBackend { registry: Arc>>, watch_tx: Arc>, policy_mapper: Arc, + /// Provider resolver snapshots staged by the gateway immediately before + /// create. The driver consumes each entry exactly once; only placeholder + /// child environment values cross into MXC. + pending_provider_credentials: Arc>>, /// In-process ETW → OCSF audit consumer (Plane A). `Some` only when /// `config.etw_audit` is set and the session started; kept alive here so it /// stops when the backend is dropped (held purely for its `Drop`, hence @@ -241,24 +342,8 @@ fn sandbox_config(sandbox: &DriverSandbox) -> Result Vec { - // Released wxc-exec ProcessContainer builds start from the explicit - // process environment. Seed only the non-secret Windows bootstrap values; - // copying the gateway's full environment would leak unrelated host secrets - // into untrusted sandbox workloads. - let mut environment = MINIMAL_WINDOWS_BOOTSTRAP_ENV - .iter() - .filter_map(|key| { - std::env::var(key) - .ok() - .map(|value| ((*key).to_string(), value)) - }) - .collect::>(); + let mut environment = HashMap::new(); if let Some(spec) = sandbox.spec.as_ref() { if let Some(template) = spec.template.as_ref() { environment.extend(template.environment.clone()); @@ -273,13 +358,88 @@ fn sandbox_environment(sandbox: &DriverSandbox) -> Vec { environment } +/// Merge provider-owned child environment values into MXC `process.env`. +/// +/// Provider entries win case-insensitively, matching Windows environment +/// semantics. Secret values have already been replaced by revision-scoped +/// placeholders; explicitly classified GCP configuration is resolved by the +/// shared credential state because SDKs consume it before making a request. +fn append_provider_child_env( + env: &mut Vec, + provider_credentials: Option<&ProviderCredentialState>, +) { + let Some(provider_credentials) = provider_credentials else { + return; + }; + let mut provider_env = provider_credentials + .child_env_with_gcp_resolved() + .into_iter() + .collect::>(); + provider_env.sort_by(|(left, _), (right, _)| left.cmp(right)); + env.retain(|entry| { + let key = entry.split_once('=').map_or(entry.as_str(), |(key, _)| key); + !provider_env + .iter() + .any(|(provider_key, _)| key.eq_ignore_ascii_case(provider_key)) + }); + env.extend( + provider_env + .into_iter() + .map(|(key, value)| format!("{key}={value}")), + ); +} + +/// Rejects provider credential environment keys that would collide once +/// injected into the sandbox process, before any staging or launch happens. +/// +/// Windows environment variables are case-insensitive, so two provider keys +/// that differ only by case (or shadow one of the reserved TLS trust keys +/// `append_tls_env_vars` injects later) would otherwise merge or get silently +/// overwritten with no diagnostic, leaving the sandbox with an ambiguous, +/// wrong, or missing credential. +fn validate_provider_child_env_keys( + provider_credentials: Option<&ProviderCredentialState>, +) -> Result<(), tonic::Status> { + let Some(provider_credentials) = provider_credentials else { + return Ok(()); + }; + let mut seen: HashMap = HashMap::new(); + let mut collisions: Vec = Vec::new(); + let mut keys = provider_credentials + .child_env_with_gcp_resolved() + .into_keys() + .collect::>(); + keys.sort_unstable(); + for key in keys { + let folded = key.to_ascii_uppercase(); + if TLS_ENV_KEYS.iter().any(|reserved| folded == *reserved) { + collisions.push(format!("{key} (reserved for TLS trust configuration)")); + continue; + } + if let Some(existing) = seen.insert(folded, key.clone()) + && existing != key + { + collisions.push(format!("{key} (collides with {existing})")); + } + } + if collisions.is_empty() { + Ok(()) + } else { + Err(tonic::Status::failed_precondition(format!( + "provider credential environment keys are ambiguous on Windows (case-insensitive) \ + or reserved: {}", + collisions.join(", ") + ))) + } +} + fn configured_egress_addr(config: &MxcComputeConfig) -> Result, tonic::Status> { if !config.egress_proxy { return Ok(None); } if config.backend == MxcBackend::IsolationSession { return Err(tonic::Status::invalid_argument( - "mxc governed egress requires process_container; network.proxy is not supported on isolation_session until MXC M1 lands", + "mxc governed egress requires process_container; isolation_session cannot enforce the loopback-only proxy path", )); } let raw = config.egress_proxy_addr.trim(); @@ -295,7 +455,7 @@ fn configured_egress_addr(config: &MxcComputeConfig) -> Result String { - args.iter() - .map(|arg| quote_windows_argument(arg)) - .collect::>() - .join(" ") -} - -fn quote_windows_argument(arg: &str) -> String { - if !arg.is_empty() && !arg.chars().any(|ch| ch.is_whitespace() || ch == '"') { - return arg.to_string(); - } +/// Minimum Windows environment variables required just for `CreateProcessW` +/// / `AppContainer`-DACL process creation to succeed at all -- independent of +/// whatever per-sandbox runtime command is selected. Confirmed empirically: +/// without `LOCALAPPDATA` specifically, `CreateProcessW` itself fails with +/// `ERROR_ENVVAR_NOT_FOUND` (Win32 203) under the appcontainer-dacl fallback +/// tier, before the agent binary is ever reached -- a Windows `AppContainer` +/// requirement, not specific to Node.js or any other agent. None of these +/// are secrets, so resolving them from the gateway host is safe; this is +/// the per-sandbox environment layers on top of. See `pc_minimal_env` on +/// `MxcComputeConfig` for the explicit empty-baseline option. +const MINIMAL_WINDOWS_BOOTSTRAP_ENV: [&str; 5] = + ["SYSTEMROOT", "WINDIR", "PATH", "COMSPEC", "LOCALAPPDATA"]; - let mut quoted = String::from("\""); - let mut backslashes = 0; - for ch in arg.chars() { - match ch { - '\\' => backslashes += 1, - '"' => { - quoted.push_str(&"\\".repeat(backslashes * 2 + 1)); - quoted.push('"'); - backslashes = 0; - } - _ => { - quoted.push_str(&"\\".repeat(backslashes)); - backslashes = 0; - quoted.push(ch); - } - } - } - quoted.push_str(&"\\".repeat(backslashes * 2)); - quoted.push('"'); - quoted -} fn host_proxy_binary_path(config: &MxcSandboxConfig) -> PathBuf { config .command @@ -360,18 +500,19 @@ const TLS_ENV_KEYS: [&str; 6] = [ "GIT_SSL_CAINFO", ]; +/// Replace client trust overrides with the proxy's public CA paths. +/// Curated `ProcessContainers` receive copies staged under the authorized share, +/// rather than paths inside the host proxy's private temporary directory. fn append_tls_env_vars(env: &mut Vec, ca_paths: Option<&(PathBuf, PathBuf)>) { let Some((ca_cert_path, combined_bundle_path)) = ca_paths else { return; }; - env.retain(|entry| { let key = entry.split_once('=').map_or(entry.as_str(), |(key, _)| key); !TLS_ENV_KEYS .iter() .any(|candidate| key.eq_ignore_ascii_case(candidate)) }); - let ca_cert_path = ca_cert_path.display().to_string(); let combined_bundle_path = combined_bundle_path.display().to_string(); env.extend([ @@ -384,6 +525,173 @@ fn append_tls_env_vars(env: &mut Vec, ca_paths: Option<&(PathBuf, PathBu ]); } +fn stage_tls_ca_files( + ca_paths: Option<&(PathBuf, PathBuf)>, + workload_dir: &str, + sandbox_id: &str, +) -> std::io::Result> { + let Some((ca_cert_path, combined_bundle_path)) = ca_paths else { + return Ok(None); + }; + if workload_dir.trim().is_empty() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "mxc driver_config.cwd must be set when staging proxy CA files", + )); + } + if sandbox_id.is_empty() + || !sandbox_id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "sandbox_id must be a non-empty alphanumeric, hyphen or underscore component", + )); + } + let target_dir = PathBuf::from(workload_dir) + .join(".openshell-proxy") + .join(sandbox_id); + std::fs::create_dir_all(&target_dir)?; + let staged_ca = target_dir.join("openshell-ca.pem"); + let staged_bundle = target_dir.join("ca-bundle.pem"); + std::fs::copy(ca_cert_path, &staged_ca)?; + std::fs::copy(combined_bundle_path, &staged_bundle)?; + Ok(Some((staged_ca, staged_bundle))) +} + +/// PROTOTYPE (2026-09-10): env-var-based governed egress, as an alternative +/// to MXC's own `network.proxy`/`runtimeConfig.networkProxy` transparent +/// redirect (both confirmed broken for this driver's use case -- see +/// `network_json()` in mxc.rs for the elevation/loopback-block history). +/// `HTTP_PROXY`/`HTTPS_PROXY` are honored voluntarily by well-behaved HTTP +/// clients (curl, most language HTTP libraries, Node fetch, git, etc.), not +/// enforced by the OS -- but paired with the sandbox's own default-deny +/// egress (only 127.0.0.1 allowed, see `network_json()`), that's actually +/// sufficient: compliant agents route through the host CONNECT proxy this +/// way, and anything that ignores these vars and tries to connect directly +/// just hits the WFP deny-by-default wall instead of silently bypassing +/// governance. Lowercase forms included too since some tools (e.g. curl) +/// prefer them, and both are common in the wild. +const PROXY_ENV_KEYS: [&str; 6] = [ + "HTTP_PROXY", + "http_proxy", + "HTTPS_PROXY", + "https_proxy", + "NO_PROXY", + "no_proxy", +]; + +const SANDBOX_PROXY_USERNAME: &str = "openshell"; + +struct SandboxProxyAuth { + password: String, +} + +impl SandboxProxyAuth { + fn generate() -> Self { + let random: [u8; 32] = rand::random(); + Self { + password: base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(random), + } + } + + fn proxy_url(&self, addr: SocketAddr) -> String { + format!("http://{SANDBOX_PROXY_USERNAME}:{}@{addr}", self.password) + } + + fn host_client_auth(&self) -> openshell_supervisor_network::host::HostProxyClientAuth { + openshell_supervisor_network::host::HostProxyClientAuth::basic( + SANDBOX_PROXY_USERNAME, + &self.password, + ) + } +} + +fn append_proxy_env_vars( + env: &mut Vec, + proxy_addr: Option, + proxy_auth: Option<&SandboxProxyAuth>, +) { + let (Some(addr), Some(proxy_auth)) = (proxy_addr, proxy_auth) else { + return; + }; + env.retain(|entry| { + let key = entry.split_once('=').map_or(entry.as_str(), |(key, _)| key); + !PROXY_ENV_KEYS + .iter() + .any(|candidate| key.eq_ignore_ascii_case(candidate)) + }); + let proxy_url = proxy_auth.proxy_url(addr); + env.extend([ + format!("HTTP_PROXY={proxy_url}"), + format!("http_proxy={proxy_url}"), + format!("HTTPS_PROXY={proxy_url}"), + format!("https_proxy={proxy_url}"), + "NO_PROXY=".to_string(), + "no_proxy=".to_string(), + ]); +} + +/// Not called: the release wxc-exec (`BaseContainer` dispatcher) requires +/// write-DAC permission on every path in `readonlyPaths` to set up +/// `AppContainer` ACLs, and adding the TLS CA cert temp directory here +/// causes it to fail with a DACL error (empirically confirmed) -- the CA +/// cert paths are available to the agent via TLS env vars instead (see +/// `append_tls_env_vars`). Kept for a future build where that DACL +/// requirement no longer applies. +#[allow(dead_code)] +fn append_tls_readonly_grant( + readonly_paths: &mut Vec, + ca_paths: Option<&(PathBuf, PathBuf)>, +) { + let Some((ca_cert_path, _)) = ca_paths else { + return; + }; + let Some(dir) = ca_cert_path.parent().map(Path::to_path_buf) else { + return; + }; + let dir = dir.display().to_string(); + if !readonly_paths + .iter() + .any(|existing| existing.eq_ignore_ascii_case(&dir)) + { + readonly_paths.push(dir); + } +} +fn encode_windows_command_line(args: &[String]) -> String { + args.iter() + .map(|arg| quote_windows_argument(arg)) + .collect::>() + .join(" ") +} + +fn quote_windows_argument(arg: &str) -> String { + if !arg.is_empty() && !arg.chars().any(|ch| ch.is_whitespace() || ch == '"') { + return arg.to_string(); + } + + let mut quoted = String::from("\""); + let mut backslashes = 0; + for ch in arg.chars() { + match ch { + '\\' => backslashes += 1, + '"' => { + quoted.push_str(&"\\".repeat(backslashes * 2 + 1)); + quoted.push('"'); + backslashes = 0; + } + _ => { + quoted.push_str(&"\\".repeat(backslashes)); + backslashes = 0; + quoted.push(ch); + } + } + } + quoted.push_str(&"\\".repeat(backslashes * 2)); + quoted.push('"'); + quoted +} fn append_tls_readwrite_grant( readwrite_paths: &mut Vec, ca_paths: Option<&(PathBuf, PathBuf)>, @@ -435,11 +743,31 @@ impl MxcComputeBackend { // Production policy translation is always handled by the embedded // mapper before any MXC lifecycle side effects begin. policy_mapper: Arc::new(EmbeddedPolicyMapper), + pending_provider_credentials: Arc::new(StdMutex::new(HashMap::new())), etw_session, attribution, } } + /// Returns a cheap, cloneable handle exposing MXC's dynamic port-forward + /// capability, so the gateway's `ComputeRuntime` can grab it (before + /// `self` is consumed into `Arc`) and call it directly + /// from `handle_forward_tcp` for sandboxes with no `ConnectSupervisor` + /// session -- MXC has no supervisor at all, so that path is otherwise + /// permanently dead for it. + pub fn forward_sink(&self) -> ForwardSink { + ForwardSink { + registry: self.registry.clone(), + } + } + + /// Return the in-process create-time provider credential side channel. + pub fn provider_credentials_sink( + &self, + ) -> Arc>> { + self.pending_provider_credentials.clone() + } + /// Test-only constructor wiring the in-process mock `wxc-exec` shim. #[cfg(test)] pub(crate) fn new_mocked(config: MxcComputeConfig) -> Self { @@ -459,10 +787,11 @@ impl MxcComputeBackend { resource_capabilities: None, rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, + supports_ui_policy: self.config.backend == MxcBackend::ProcessContainer, } } - fn validate_sandbox_fields(sandbox: &DriverSandbox) -> Result<(), tonic::Status> { + fn validate_sandbox_fields(&self, sandbox: &DriverSandbox) -> Result<(), tonic::Status> { if let Some(spec) = &sandbox.spec { if effective_driver_gpu_count(driver_gpu_requirements( spec.resource_requirements.as_ref(), @@ -498,13 +827,14 @@ impl MxcComputeBackend { &MapCtx { sandbox_id: sandbox_id.to_string(), egress, + containment: self.config.backend.containment().into(), }, ) .map_err(|error| tonic::Status::invalid_argument(error.to_string())) } pub fn validate_sandbox_create(&self, sandbox: &DriverSandbox) -> Result<(), tonic::Status> { - Self::validate_sandbox_fields(sandbox)?; + self.validate_sandbox_fields(sandbox)?; let policy = sandbox.spec.as_ref().and_then(|spec| spec.policy.as_ref()); let egress_addr = configured_egress_addr(&self.config)?; self.map_sandbox_policy(&sandbox.id, policy, egress_addr)?; @@ -526,7 +856,16 @@ impl MxcComputeBackend { pub async fn create_sandbox(&self, sandbox: &DriverSandbox) -> Result<(), tonic::Status> { let sandbox_id = sandbox.id.clone(); - Self::validate_sandbox_fields(sandbox)?; + // Consume before any fallible validation so rejected creates cannot + // retain real provider material in the staging map. + let provider_credentials = self + .pending_provider_credentials + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(&sandbox_id); + validate_provider_child_env_keys(provider_credentials.as_ref())?; + + self.validate_sandbox_fields(sandbox)?; let sandbox_config = sandbox_config(sandbox)?; let (egress_addr, reserved_proxy_listener) = match configured_egress_addr(&self.config)? { Some(configured_addr) => { @@ -547,6 +886,15 @@ impl MxcComputeBackend { // synchronously at the CreateSandbox boundary. let policy = sandbox.spec.as_ref().and_then(|spec| spec.policy.as_ref()); let mapped = self.map_sandbox_policy(&sandbox_id, policy, egress_addr)?; + if provider_credentials + .as_ref() + .is_some_and(ProviderCredentialState::requires_proxy_resolution) + && egress_addr.is_none() + { + return Err(tonic::Status::failed_precondition( + "mxc provider credentials require governed egress; enable egress_proxy so placeholders can be resolved by the host proxy", + )); + } if sandbox .spec @@ -592,11 +940,14 @@ impl MxcComputeBackend { isolation_stopped: false, phase_state: PhaseState::Starting, lifecycle_gate, - monitor_cancel: None, - monitor_task: None, - trimmed_policy: None, - proxy_addr: None, + exec_child: None, + shutdown_tx: None, + terminated_rx: None, + signal_file: None, + trimmed_policy: mapped.trimmed_policy.clone(), + proxy_addr: mapped.proxy_addr, host_proxy: None, + control_channel: None, }, ); } @@ -617,6 +968,7 @@ impl MxcComputeBackend { sandbox, sandbox_config, mapped, + provider_credentials, reserved_proxy_listener, startup_guard, ) @@ -637,8 +989,19 @@ impl MxcComputeBackend { (entry.sandbox.id.clone(), entry.lifecycle_gate.clone()) }; + // Blocks until any in-flight create_sandbox/run_lifecycle has either + // finished wiring shutdown_tx/control_channel or failed -- closes the + // race where a stop arriving mid-startup would otherwise find both + // `None` and silently no-op (see the `lifecycle_gate` field doc). let _lifecycle_guard = lifecycle_gate.lock().await; - let (iso_id, mut isolation_stopped, cancel, monitor_task) = { + let ( + iso_id, + mut isolation_stopped, + shutdown_tx, + terminated_rx, + control_channel, + host_proxy, + ) = { let mut registry = self.registry.lock().await; let entry = registry.get_mut(&sandbox_id).ok_or_else(|| { tonic::Status::not_found(format!("sandbox {sandbox_name} not found")) @@ -646,25 +1009,76 @@ impl MxcComputeBackend { ( entry.iso_sandbox_id.clone(), entry.isolation_stopped, - entry.monitor_cancel.take(), - entry.monitor_task.take(), + // Only ProcessContainer entries have these; isolation_session + // relies on invoker.stop() below instead. .take() the kill + // signal so a concurrent stop can't double-fire it, but + // .clone() terminated_rx (a watch::Receiver, not a oneshot) + // and the control channel (an Arc) -- both need to survive a + // caller that times out below and retries: a fresh clone of + // the same watch::Receiver still observes the SAME + // underlying completion, whereas .take()-ing it would make a + // retry silently skip the wait (see the matching fix in + // delete_sandbox and MR !98's review thread on this). + entry.shutdown_tx.take(), + entry.terminated_rx.clone(), + entry.control_channel.clone(), + entry.host_proxy.take(), ) }; - if let Some(cancel) = cancel { - let _ = cancel.send(true); - } - if let Some(task) = monitor_task { - task.await.map_err(|error| { - tonic::Status::internal(format!("mxc process monitor failed: {error}")) - })?; - } - if let Some(ref iso_id) = iso_id - && !isolation_stopped - { - self.invoker.stop(iso_id).await.map_err(|error| { - tonic::Status::internal(format!("wxc-exec stop failed: {error}")) - })?; - isolation_stopped = true; + drop(host_proxy); + + if let Some(ref iso_id) = iso_id { + if !isolation_stopped { + self.invoker.stop(iso_id).await.map_err(|error| { + tonic::Status::internal(format!("wxc-exec stop failed: {error}")) + })?; + isolation_stopped = true; + } + } else { + // ProcessContainer has no persistent iso id -- the sandbox IS + // the one-shot wxc-exec process, so without this block stop had + // nothing to act on and just relabeled the sandbox Stopped while + // wxc-exec (and everything inside the AppContainer) kept + // running. Ask nicely first over the control channel (bounded + // by request()'s own 3s timeout, same as delete_sandbox), then + // trigger the shutdown_tx kill backstop. + if let Some(channel) = control_channel { + match channel + .request( + "shutdown", + serde_json::Value::Null, + std::time::Duration::from_secs(3), + ) + .await + { + Ok(_) => { + info!(sandbox = %sandbox_name, "control-channel shutdown acknowledged"); + } + Err(e) => { + warn!(sandbox = %sandbox_name, "control-channel shutdown failed: {e}"); + } + } + } + if let Some(tx) = shutdown_tx { + let _ = tx.send(()); + } + // Await *confirmed* termination via terminated_rx -- not just + // firing the kill signal and reporting success regardless -- + // before this returns Ok. This runs whenever terminated_rx is + // present, independent of whether THIS call sent the kill + // signal above: shutdown_tx is None either because the process + // already exited naturally, or because an earlier (possibly + // timed-out) stop/delete attempt already sent it -- either way, + // this call still needs to observe genuine completion, not + // assume it. + if let Some(mut rx) = terminated_rx + && !wait_for_termination(&mut rx).await + { + warn!(sandbox = %sandbox_name, "timed out waiting for ProcessContainer termination on stop"); + return Err(tonic::Status::deadline_exceeded(format!( + "sandbox {sandbox_name} did not terminate within the stop timeout" + ))); + } } let mut registry = self.registry.lock().await; @@ -707,8 +1121,17 @@ impl MxcComputeBackend { entry.lifecycle_gate.clone() }; + // See stop_sandbox's matching comment on lifecycle_gate. let _lifecycle_guard = lifecycle_gate.lock().await; - let (iso_id, isolation_stopped, cancel, monitor_task) = { + let ( + iso_id, + isolation_stopped, + shutdown_tx, + terminated_rx, + signal_file, + control_channel, + host_proxy, + ) = { let mut registry = self.registry.lock().await; let Some(entry) = registry.get_mut(sandbox_id) else { return Ok(false); @@ -716,18 +1139,17 @@ impl MxcComputeBackend { ( entry.iso_sandbox_id.clone(), entry.isolation_stopped, - entry.monitor_cancel.take(), - entry.monitor_task.take(), + entry.shutdown_tx.take(), + // .clone(), not .take() -- see stop_sandbox's matching + // comment: a watch::Receiver survives a caller that times + // out and retries, unlike a consumed oneshot. + entry.terminated_rx.clone(), + entry.signal_file.take(), + entry.control_channel.take(), + entry.host_proxy.take(), ) }; - if let Some(cancel) = cancel { - let _ = cancel.send(true); - } - if let Some(task) = monitor_task { - task.await.map_err(|error| { - tonic::Status::internal(format!("mxc process monitor failed: {error}")) - })?; - } + drop(host_proxy); if let Some(ref iso_id) = iso_id { if !isolation_stopped { self.invoker.stop(iso_id).await.map_err(|error| { @@ -743,6 +1165,75 @@ impl MxcComputeBackend { self.invoker.deprovision(iso_id).await.map_err(|error| { tonic::Status::internal(format!("wxc-exec deprovision failed: {error}")) })?; + } else { + // Prefer telling the spawner directly over the control channel + // (a "shutdown" request -- see the launch handshake) so it can + // proactively kill its target and exit before the AppContainer + // teardown below, since that teardown alone can leave sandboxed + // processes running well past this call returning. Awaited + // (bounded by request()'s own 3s timeout) rather than + // fire-and-forget: a detached task races the shutdown_tx + // backstop below instead of being superseded by it, so the + // graceful path can lose to its own fallback. The backstop + // still always runs afterward regardless of outcome here -- + // this only orders "ask nicely" before "force it". Only + // present when the driver launched openshell-supervisor-relay + // (spawner wrapping); mxc-ws-agent.rs (no control channel) + // still uses the older signal-file mechanism. + if let Some(channel) = control_channel { + match channel + .request( + "shutdown", + serde_json::Value::Null, + std::time::Duration::from_secs(3), + ) + .await + { + Ok(_) => { + info!(sandbox = %sandbox_name, "control-channel shutdown acknowledged"); + } + Err(e) => { + warn!(sandbox = %sandbox_name, "control-channel shutdown failed: {e}"); + } + } + } + // Write the shutdown signal file so the spawner inside the + // AppContainer detects deletion and exits cleanly, freeing ports + // and child processes even if MXC does not cascade-kill them when + // wxc-exec is terminated. Only set for mxc-ws-agent.rs (no + // control channel) -- see above. + if let Some(ref path) = signal_file + && let Err(e) = std::fs::write(path, b"") + { + warn!(sandbox = %sandbox_name, path = %path.display(), error = %e, + "failed to write ProcessContainer shutdown signal file"); + } + // Signal monitor_exec to kill wxc-exec as a backstop. + if let Some(tx) = shutdown_tx { + let _ = tx.send(()); + } + // Await *confirmed* termination via terminated_rx -- not just + // firing the signal and reporting success regardless -- before + // this removes the registry entry and returns Ok(true). Without + // this, delete_sandbox could report success while the + // ProcessContainer (and whatever it launched) is still alive, + // retaining ports and file locks (see stop_sandbox's matching + // comment). Runs whenever terminated_rx is present, independent + // of whether THIS call sent the kill signal above -- shutdown_tx + // is None either because the process already exited naturally, + // or because an earlier (possibly timed-out) stop/delete attempt + // already sent it. A retry must still confirm genuine + // completion via the persisted watch value rather than assuming + // it, or it can remove the registry entry and report success + // while the process is still alive (MR !98 review thread). + if let Some(mut rx) = terminated_rx + && !wait_for_termination(&mut rx).await + { + warn!(sandbox = %sandbox_name, "timed out waiting for ProcessContainer termination on delete"); + return Err(tonic::Status::deadline_exceeded(format!( + "sandbox {sandbox_name} did not terminate within the delete timeout" + ))); + } } let mut registry = self.registry.lock().await; @@ -803,6 +1294,91 @@ impl MxcComputeBackend { } } +// ── Dynamic port forwarding ─────────────────────────────────────────────────── +// +// Closes the `openshell forward service` gap for MXC: `handle_forward_tcp` +// normally requires a live `ConnectSupervisor` session, which MXC's +// exec-in-driver design never registers (no in-sandbox supervisor process +// exists). This gives the gateway an alternate path straight into a running +// sandbox's control channel instead, bypassing that requirement entirely. + +#[derive(Debug, thiserror::Error)] +pub enum OpenDynamicForwardError { + #[error("sandbox {0} not found")] + SandboxNotFound(String), + #[error( + "sandbox {0} has no control channel (not launched via a relay spawner, or not yet Ready)" + )] + NoControlChannel(String), + #[error("failed to bind ephemeral relay listener: {0}")] + RelayBind(#[source] std::io::Error), + #[error("control channel request failed: {0}")] + ControlChannel(#[from] crate::control_channel::ControlChannelError), + #[error("sandbox rejected forward request: {0}")] + Rejected(String), +} + +/// Cheap, cloneable handle exposing MXC's dynamic port-forward capability — +/// see `MxcComputeBackend::forward_sink`. +#[derive(Clone)] +pub struct ForwardSink { + registry: Arc>>, +} + +impl ForwardSink { + /// Open a new, independent relay bridge to `target_port` inside the + /// given sandbox's `AppContainer`, on demand (not pre-declared in the + /// gateway TOML). Returns the ephemeral relay's address — reachable + /// directly by the gateway process itself, no `AppContainer` boundary on + /// that leg — a per-forward auth nonce the caller MUST send as the first + /// bytes on its own connection to that address (see `relay.rs` module + /// docs: the relay is host-interface-bound, so another reachable process + /// could otherwise race to connect first and hijack the forward), and a + /// [`relay::RelayHandle`] the caller must hold for as long as the + /// forward should stay open, then `.stop()` (or just drop) to tear it + /// down. + /// + /// Target host is always `127.0.0.1` inside the `AppContainer` (matching + /// `TcpRelayTarget`'s existing loopback-only restriction at the gRPC + /// layer), so there's no separate `target_host` parameter to thread + /// through — the sandbox-side `forward` op only ever dials loopback. + pub async fn open_dynamic_forward( + &self, + sandbox_id: &str, + target_port: u16, + ) -> Result<(SocketAddr, [u8; relay::NONCE_LEN], relay::RelayHandle), OpenDynamicForwardError> + { + let (control_channel, sandbox_name) = { + let reg = self.registry.lock().await; + let entry = reg + .get(sandbox_id) + .ok_or_else(|| OpenDynamicForwardError::SandboxNotFound(sandbox_id.to_string()))?; + let channel = entry + .control_channel + .clone() + .ok_or_else(|| OpenDynamicForwardError::NoControlChannel(sandbox_id.to_string()))?; + (channel, entry.sandbox.name.clone()) + }; + + // Fresh per forward -- see relay.rs module docs for why this matters + // on a host-interface listener. + let nonce: [u8; relay::NONCE_LEN] = rand::random(); + + let bind_addr: SocketAddr = "127.0.0.1:0".parse().unwrap(); + let (relay_handle, relay_addr) = relay::start_control_channel_relay( + bind_addr, + sandbox_name, + nonce, + control_channel, + target_port, + ) + .await + .map_err(OpenDynamicForwardError::RelayBind)?; + + Ok((relay_addr, nonce, relay_handle)) + } +} + // ── Lifecycle task ──────────────────────────────────────────────────────────── #[allow(clippy::too_many_arguments)] @@ -815,15 +1391,18 @@ async fn run_lifecycle( sandbox: DriverSandbox, sandbox_config: MxcSandboxConfig, mapped: MappedConfig, + provider_credentials: Option, mut reserved_proxy_listener: Option, - _startup_guard: tokio::sync::OwnedMutexGuard<()>, + startup_guard: tokio::sync::OwnedMutexGuard<()>, ) { let sandbox_id = sandbox.id.clone(); let sandbox_name = sandbox.name.clone(); - let trimmed_policy = mapped.trimmed_policy.clone(); let proxy_addr = mapped.proxy_addr; + let proxy_auth = proxy_addr.map(|_| SandboxProxyAuth::generate()); + let trimmed_policy = mapped.trimmed_policy.clone(); let host_proxy = if !invoker.is_mock() - && let (Some(addr), Some(proxy_policy)) = (proxy_addr, trimmed_policy.clone()) + && let (Some(addr), Some(proxy_policy), Some(proxy_auth)) = + (proxy_addr, trimmed_policy.clone(), proxy_auth.as_ref()) { drop(reserved_proxy_listener.take()); match openshell_supervisor_network::host::start_host_proxy( @@ -831,10 +1410,11 @@ async fn run_lifecycle( bind_addr: addr, policy: proxy_policy, binary_path: host_proxy_binary_path(&sandbox_config), + client_auth: proxy_auth.host_client_auth(), sandbox_id: Some(sandbox_id.clone()), sandbox_name: Some(sandbox_name.clone()), openshell_endpoint: None, - provider_credentials: None, + provider_credentials: provider_credentials.clone(), agent_proposals: openshell_core::proposals::AgentProposals::default(), denial_tx: None, activity_tx: None, @@ -858,8 +1438,35 @@ async fn run_lifecycle( } else { None }; - let host_proxy_ca_paths = host_proxy.as_ref().and_then(|proxy| proxy.ca_file_paths()); - drop(reserved_proxy_listener.take()); + let host_proxy_ca_paths = host_proxy + .as_ref() + .and_then(openshell_supervisor_network::host::HostProxyHandle::ca_file_paths); + // A curated ProcessContainer cannot read the host's private temp folder. + // Stage only the public CA material beneath the per-sandbox working directory, + // DACL is already granted by the policy, so HTTPS clients can authenticate + // the OpenShell inspection proxy without broadening filesystem access. + let agent_proxy_ca_paths = if config.pc_minimal_env && host_proxy_ca_paths.is_some() { + match stage_tls_ca_files( + host_proxy_ca_paths.as_ref(), + &sandbox_config.cwd, + &sandbox_id, + ) { + Ok(paths) => paths, + Err(error) => { + set_failed( + ®istry, + &watch_tx, + &sandbox, + &sandbox_id, + &format!("failed to stage MXC egress proxy CA files: {error}"), + ) + .await; + return; + } + } + } else { + host_proxy_ca_paths.clone() + }; if let Some(addr) = proxy_addr { { let mut registry = registry.lock().await; @@ -876,15 +1483,12 @@ async fn run_lifecycle( )); } - // Released wxc-exec BaseContainer builds cannot provision the generated - // TLS directory as a read-only share because that path fails its WRITE_DAC - // setup. Grant the sandbox-unique directory read-write instead so the - // AppContainer can actually read the injected trust paths. The directory - // contains only public CA certificates; the CA private key remains in the - // host proxy's in-memory TLS state. let mut readwrite_paths = mapped.readwrite_paths; - append_tls_readwrite_grant(&mut readwrite_paths, host_proxy_ca_paths.as_ref()); + if !config.pc_minimal_env { + append_tls_readwrite_grant(&mut readwrite_paths, host_proxy_ca_paths.as_ref()); + } let readonly_paths = mapped.readonly_paths; + let ui = mapped.ui; let filesystem = MxcFilesystem { readwrite_paths, readonly_paths, @@ -893,18 +1497,79 @@ async fn run_lifecycle( denied_paths: Vec::new(), }; let command_line = encode_windows_command_line(&sandbox_config.command); - let mut environment = sandbox_environment(&sandbox); - append_tls_env_vars(&mut environment, host_proxy_ca_paths.as_ref()); - info!(sandbox = %sandbox_name, count = environment.len(), "MXC process env vars"); + // ProcessContainer starts with a completely blank environment — no PATH, + // no SystemRoot, nothing. Start with either an empty environment or the + // safe Windows bootstrap set, then layer the per-request environment from + // the CreateSandbox spec and the TLS/proxy variables required by governed + // egress. + let mut env_map: HashMap = if config.pc_minimal_env { + HashMap::new() + } else { + MINIMAL_WINDOWS_BOOTSTRAP_ENV + .iter() + .filter_map(|&key| std::env::var(key).ok().map(|v| (key.to_string(), v))) + .collect() + }; + + for entry in sandbox_environment(&sandbox) { + if let Some(pos) = entry.find('=') { + env_map.insert(entry[..pos].to_string(), entry[pos + 1..].to_string()); + } + } + + let mut env: Vec = env_map + .into_iter() + .map(|(k, v)| format!("{k}={v}")) + .collect(); + append_provider_child_env(&mut env, provider_credentials.as_ref()); + // Layer proxy configuration for every env tier. Curated ProcessContainers + // use the staged CA copies above; other tiers use the original paths. + append_tls_env_vars(&mut env, agent_proxy_ca_paths.as_ref()); + append_proxy_env_vars(&mut env, proxy_addr, proxy_auth.as_ref()); + env.sort(); // deterministic order for logging / debugging + info!(sandbox = %sandbox_name, count = env.len(), "MXC process env vars"); + + // When spawner wrapping is configured, launch openshell-supervisor-relay + // instead of the per-sandbox command directly. The real command/env are sent over + // the control channel once the spawner announces readiness (see the + // "launch" handshake below) rather than written to the working directory as + // agent-cmd.txt/agent-env.txt -- this keeps command/env (which can carry + // secrets, e.g. OPENCLAW_GATEWAY_TOKEN) off disk entirely and eliminates + // the file-staleness/namespace-mismatch bug class that existed when they + // were file-based. The target application (e.g. OpenClaw) + // stays entirely unaware of the relay protocol either way. + let spawner_wrapping_active = + !config.pc_relay_spawner_path.is_empty() && config.pc_relay_target_port != 0; + let effective_command_line = if spawner_wrapping_active { + // Quoted: pc_relay_spawner_path is a filesystem path and may contain + // spaces (e.g. under "Program Files"); unquoted, wxc-exec would + // parse the executable path incorrectly and the launch would fail + // before the control-channel handshake ever starts. + format!( + "\"{}\" {}", + config.pc_relay_spawner_path, config.pc_relay_target_port + ) + } else { + command_line.clone() + }; + + // Downstream logging/ETW attribution should reflect what's actually + // launched (openshell-supervisor-relay, when wrapping is active), not + // the original workload command -- shadow command_line with the effective + // value. + let command_line = effective_command_line; let process = MxcProcess { command_line: command_line.clone(), - cwd: sandbox_config.cwd, - env: environment, + cwd: sandbox_config.cwd.clone(), + // Cloned: the launch handshake below (spawner_wrapping_active case) + // needs its own copy of `env` to send over the control channel. + env: env.clone(), timeout: 0, }; let network = proxy_addr.map(|addr| MxcNetwork { default_policy: "block".into(), proxy: Some(addr), + allow_local_network: false, }); let child = match config.backend { @@ -967,11 +1632,52 @@ async fn run_lifecycle( least_privilege: config.pc_least_privilege, capabilities: config.pc_capabilities.clone(), }; - match invoker - .run_oneshot(&sandbox_id, filesystem, process_container, process, network) - .await + // Build the effective network config: + // - egress_proxy: use the proxy-based network (already in `network`) + // - pc_network_allow: inject allow-all (fallback for builds without capability support) + // - pc_allow_local_network: block-default but with allowLocalNetwork=true so + // intra-container loopback works and the spawner can reach the relay on the + // host's route-selected private interface without a full egress proxy. + let effective_network = if network.is_none() + && (config.pc_allow_local_network || config.pc_network_allow) { - Ok(child) => child, + // Both flags apply to the same no-proxy startup case and + // aren't mutually exclusive -- honor both instead of letting + // pc_allow_local_network's branch silently force + // default_policy back to "block" and drop pc_network_allow's + // unrestricted-egress intent. + Some(MxcNetwork { + default_policy: if config.pc_network_allow { + "allow".into() + } else { + "block".into() + }, + proxy: None, + allow_local_network: config.pc_allow_local_network, + }) + } else { + // `network` is Some here (egress_proxy configured). Preserve + // config.pc_allow_local_network instead of unconditionally + // clearing it -- MxcNetwork already carries both `proxy` and + // `allow_local_network` together, so a proxy and local-network + // access aren't mutually exclusive. + network.map(|mut n| { + n.allow_local_network = config.pc_allow_local_network; + n + }) + }; + match invoker + .run_oneshot( + &sandbox_id, + filesystem, + process_container, + process, + effective_network, + ui, + ) + .await + { + Ok(child) => child, Err(error) => { set_failed( ®istry, @@ -988,28 +1694,162 @@ async fn run_lifecycle( }; info!(sandbox = %sandbox_name, command = %command_line, backend = ?config.backend, "MXC agent launched"); - let ready_sandbox = make_sandbox_with_condition( - &sandbox, - &DriverCondition { - r#type: "Ready".into(), - status: "True".into(), - reason: "AgentRunning".into(), - message: format!("Agent exec launched: {command_line}"), - last_transition_time: String::new(), - }, - false, - ); - let (cancel_tx, cancel_rx) = watch::channel(false); - { - // Publish cancellation state before the monitor can observe a fast - // process exit. Holding the registry lock while spawning prevents a - // completed child from being overwritten with AgentRunning. - let mut registry_guard = registry.lock().await; - let Some(entry) = registry_guard.get_mut(&sandbox_id) else { - // The sandbox was deleted between agent launch and readiness. Bail - // without seeding ETW attribution (a stale key would misroute later - // events to a dead sandbox), without reporting Ready, and without - // spawning the exec monitor. `delete` already tore down the process. + // Stream wxc-exec's stdout/stderr into the gateway log as it runs, so the + // agent's live output is visible in the gateway console instead of sitting + // unread in the OS pipe until the process exits. + let mut child = child; + + // Control channel: correlate JSON responses in the stdout stream with + // pending requests sent over stdin (see control_channel.rs). Only + // meaningful when the process on the other end is + // openshell-supervisor-relay (spawner wrapping active) -- an arbitrary + // workload target wouldn't understand this protocol, so stdin is + // left untouched (and unpiped expectations unaffected) otherwise. + let control_channel: Option> = if spawner_wrapping_active { + if let Some(stdin) = child.stdin.take() { + Some(Arc::new(ControlChannel::new(stdin))) + } else { + // wxc-exec didn't give us a piped stdin even though spawner + // wrapping was requested. Without a control channel, + // openshell-supervisor-relay would wait forever for a + // "launch" request that can never arrive -- a silent hang, + // not a failure. Fail the sandbox now instead. + let err = "wxc-exec stdin is not piped; control-channel launch cannot proceed"; + set_failed(®istry, &watch_tx, &sandbox, &sandbox_id, err).await; + let _ = child.kill().await; + let _ = child.wait().await; + return; + } + } else { + // No control channel on the direct-agent path. Both spawn_exec and + // run_oneshot now always pipe stdin (needed for the control-channel + // case above), so without this the write end stays open inside + // `child` for the sandbox's full lifetime -- any workload that + // reads stdin until EOF would then block forever, since EOF never + // arrives. Drop it so stdin readers see EOF immediately instead. + drop(child.stdin.take()); + None + }; + let pending_responses = control_channel.as_ref().map(|c| c.pending_handle()); + // Startup-ready signal from the spawner (see control_channel.rs's + // try_route_ready). Fired once, before the "launch" handshake below. + // Carries Err(reason) instead of firing at all when the spawner's + // reported protocol_version doesn't match what this driver requires -- + // see try_route_ready's doc comment. + let (ready_slot, ready_rx) = if spawner_wrapping_active { + let (tx, rx) = oneshot::channel::>(); + (Some(Arc::new(Mutex::new(Some(tx)))), Some(rx)) + } else { + (None, None) + }; + // Target-ready signal from the spawner (see control_channel.rs's + // try_route_target_ready) -- fired once the target is actually running + // and its configured port is accepting connections, distinct from the + // "launch" response below (which only confirms the command/env + // arrived). Awaited after "launch" succeeds and before publishing + // Ready=True, so Ready can't be reported while the target is still + // unreachable. Always Ok(()) when it fires (no version gate on this + // event -- see try_route_target_ready). + let (target_ready_slot, target_ready_rx) = if spawner_wrapping_active { + let (tx, rx) = oneshot::channel::>(); + (Some(Arc::new(Mutex::new(Some(tx)))), Some(rx)) + } else { + (None, None) + }; + + if let Some(stdout) = child.stdout.take() { + let sandbox_name_out = sandbox_name.clone(); + let ready_slot = ready_slot.clone(); + let target_ready_slot = target_ready_slot.clone(); + tokio::spawn(async move { + let mut lines = BufReader::new(stdout).lines(); + loop { + match lines.next_line().await { + Ok(Some(line)) => { + let routed_ready = match &ready_slot { + Some(slot) => ControlChannel::try_route_ready(slot, &line).await, + None => false, + }; + let routed_target_ready = match &target_ready_slot { + Some(slot) => ControlChannel::try_route_target_ready(slot, &line).await, + None => false, + }; + let routed = routed_ready + || routed_target_ready + || match &pending_responses { + Some(pending) => { + ControlChannel::try_route_response(pending, &line).await + } + None => false, + }; + if !routed { + info!(sandbox = %sandbox_name_out, "wxc-exec stdout: {line}"); + } + } + Ok(None) => break, + Err(e) => { + warn!(sandbox = %sandbox_name_out, "wxc-exec stdout read error: {e}"); + break; + } + } + } + // Stdout is gone (EOF or read error): no control-channel response + // will ever arrive again. Fail any still-pending requests now + // instead of leaving them to time out individually. + if let Some(pending) = &pending_responses { + ControlChannel::fail_all_pending(pending).await; + } + }); + } + // Drop this scope's Arc clones now that the stdout task holds its own: + // if the spawner exits before ever sending "ready"/"target_ready", the + // stdout task's clone is the only thing keeping the + // Mutex> alive, so its loop ending (EOF) drops the last + // reference -- which drops the still-`Some` Sender and makes + // ready_rx/target_ready_rx below observe a dropped sender immediately + // instead of waiting out the full timeout. + drop(ready_slot); + drop(target_ready_slot); + if let Some(stderr) = child.stderr.take() { + let sandbox_name_err = sandbox_name.clone(); + tokio::spawn(async move { + let mut lines = BufReader::new(stderr).lines(); + loop { + match lines.next_line().await { + Ok(Some(line)) => warn!(sandbox = %sandbox_name_err, "wxc-exec stderr: {line}"), + Ok(None) => break, + Err(e) => { + warn!(sandbox = %sandbox_name_err, "wxc-exec stderr read error: {e}"); + break; + } + } + } + }); + } + // Publish a cancellable handle (exec_child, and for ProcessContainer + // shutdown_tx/terminated_rx too) and release the startup gate now, + // rather than holding it until the target-readiness wait below (up to + // ~430s worst case: 120s ready + 310s target_ready) completes or times + // out. stop_sandbox/delete_sandbox block on lifecycle_gate before doing + // anything else, so holding it this long meant a stop/delete arriving + // while a target is slow to (or never does) come up had no way to + // interrupt that wait -- it just queued up behind it. See also imp.rs's + // matching fix: openshell-supervisor-relay now races its own + // port-readiness wait against a "shutdown" request instead of only + // observing shutdown once that wait finishes. + let shutdown_rx = { + let mut reg = registry.lock().await; + let Some(entry) = reg.get_mut(&sandbox_id) else { + // The sandbox was deleted between agent launch and now. `delete` + // already tore down the *previous* process entry, but `child` + // here was spawned after that -- it was never registered, so + // nothing else will kill it. `tokio::process::Child` does not + // kill-on-drop, so without this the wxc-exec process (and its + // AppContainer) would keep running past `delete` reporting + // success. + drop(reg); + let _ = child.kill().await; + let _ = child.wait().await; return; }; @@ -1017,8 +1857,8 @@ async fn run_lifecycle( // `delete` cannot remove the sandbox after we register (which would leave // a stale key). The `wxc-exec` pid we just spawned is the collision-proof // anchor that ties the `Sandboxing` provider's events back to this - // `sandbox_id` while the child is alive. Command text is never an - // attribution key. No-op unless the ETW consumer is running. + // `sandbox_id` while the exact process generation is alive. Command + // text is never an attribution key. if config.etw_audit && let Some(pid) = child.id() { @@ -1029,72 +1869,337 @@ async fn run_lifecycle( } } Err(error) => { - warn!( - sandbox = %sandbox_name, - pid, - error, - "failed to obtain wxc-exec process generation key; PID-based ETW attribution disabled for this launch" - ); + warn!(sandbox = %sandbox_name, pid, error, + "failed to obtain wxc-exec process generation key; ETW attribution disabled for this launch"); + } + } + } + + entry.exec_child = Some(child); + entry.control_channel.clone_from(&control_channel); + + // For ProcessContainer, wire a kill channel so stop_sandbox/ + // delete_sandbox can terminate the wxc-exec process and cause the + // AppContainer (and all in-sandbox processes, including long-lived + // servers) to be torn down, as a backstop regardless of how shutdown + // is signaled below. `terminated_rx` is the other half of the pair + // `monitor_exec` uses to report back once the process has actually + // exited, so callers can await confirmed termination instead of + // just firing the kill and hoping. + if matches!(config.backend, MxcBackend::ProcessContainer) { + let (tx, rx) = oneshot::channel::<()>(); + let (done_tx, done_rx) = watch::channel(false); + entry.shutdown_tx = Some(tx); + entry.terminated_rx = Some(done_rx); + // The generic spawner (openshell-supervisor-relay) gets its + // shutdown notice over the control channel (see delete_sandbox's + // "shutdown" request) -- no file needed. Only mxc-ws-agent.rs + // (set directly as the sandbox command, not spawner-wrapped, no + // control channel) still polls a signal file for it. + if !spawner_wrapping_active && !sandbox_config.cwd.is_empty() { + entry.signal_file = + Some(PathBuf::from(&sandbox_config.cwd).join("openshell-shutdown.signal")); + } + Some((rx, done_tx)) + } else { + None + } + }; + + // 6. Monitor exec completion in background. + let registry2 = registry.clone(); + let watch_tx2 = watch_tx.clone(); + let sandbox2 = sandbox.clone(); + let sandbox_id2 = sandbox_id.clone(); + tokio::spawn(async move { + monitor_exec( + registry2, + watch_tx2, + attribution, + sandbox2, + sandbox_id2, + shutdown_rx, + ) + .await; + }); + + // A cancellable handle now exists in the registry (exec_child, plus + // shutdown_tx/terminated_rx for ProcessContainer) -- stop_sandbox/ + // delete_sandbox arriving from here on can act immediately instead of + // waiting out the target-readiness wait below. + drop(startup_guard); + + // When spawner wrapping is active, openshell-supervisor-relay.rs hasn't + // spawned the real target yet -- it waits for a "launch" request over + // the control channel instead of reading agent-cmd.txt/agent-env.txt + // from files in the workload directory (see its module docs). Wait for its startup-ready + // event, then send the real command/env directly; this keeps them off + // disk (they can carry secrets, e.g. OPENCLAW_GATEWAY_TOKEN) and also + // proves the correlated request/response path works end to end -- the + // old unconditional "ping" this replaces only logged a warning on + // failure, but failure here is fatal: nothing was ever spawned. + if let (Some(channel), Some(ready_rx), Some(target_ready_rx)) = + (control_channel.clone(), ready_rx, target_ready_rx) + { + // Generous timeout: this fires right after spawn, so it's racing UAC + // elevation + AppContainer creation (observed up to several + // seconds), not just the control channel itself. The `forward` path + // won't have this constraint -- it only runs once the sandbox is + // already Ready, long past this window. + let ready_timeout = std::time::Duration::from_mins(2); + let ready_err = match tokio::time::timeout(ready_timeout, ready_rx).await { + Ok(Ok(Ok(()))) => None, + // Protocol version mismatch (see try_route_ready) -- an + // independently staged, out-of-sync relay binary. Reject fast + // and clearly instead of proceeding into a "launch" handshake + // it may not understand. + Ok(Ok(Err(version_err))) => Some(version_err), + Ok(Err(_)) => Some("spawner exited before sending its ready event".to_string()), + Err(_) => Some(format!( + "timed out after {ready_timeout:?} waiting for spawner ready event" + )), + }; + let launch_err = if let Some(e) = ready_err { + Some(e) + } else { + let launch_data = serde_json::json!({ + "command": sandbox_config.command, + "env": env, + }); + match channel + .request("launch", launch_data, std::time::Duration::from_mins(2)) + .await + { + Ok(resp) if resp.get("ok").and_then(serde_json::Value::as_bool) == Some(true) => { + info!(sandbox = %sandbox_name, "control-channel launch acknowledged"); + // The "launch" response above only confirms the + // command/env reached the spawner -- it still needs + // to spawn the target and confirm its configured + // port is accepting connections + // (openshell-supervisor-relay's own + // wait_for_port_ready, up to ~300s worst case across + // its own retries). Await that distinct + // "target_ready" event before treating launch as + // successful, so a caller acting on Ready=True below + // can never race a target that hasn't bound its port + // yet. + let target_ready_timeout = std::time::Duration::from_secs(310); + match tokio::time::timeout(target_ready_timeout, target_ready_rx).await { + Ok(Ok(Ok(()))) => { + info!(sandbox = %sandbox_name, "control-channel target ready"); + None + } + // No version gate on this event, so this arm + // never actually fires today -- see + // try_route_target_ready -- but match it + // explicitly rather than unreachable!(), in case + // that ever changes. + Ok(Ok(Err(target_err))) => Some(target_err), + Ok(Err(_)) => { + Some("spawner exited before its target became ready".to_string()) + } + Err(_) => Some(format!( + "timed out after {target_ready_timeout:?} waiting for target to become ready" + )), + } } + Ok(resp) => Some( + resp.get("error") + .and_then(|v| v.as_str()) + .unwrap_or("launch rejected") + .to_string(), + ), + Err(e) => Some(e.to_string()), } + }; + if let Some(err) = launch_err { + warn!(sandbox = %sandbox_name, "control-channel launch failed: {err}"); + set_failed(®istry, &watch_tx, &sandbox, &sandbox_id, &err).await; + // `child` was already moved into the registry (and possibly + // already claimed by monitor_exec, spawned above) once a + // cancellable handle was published. ProcessContainer has + // shutdown_tx/terminated_rx for exactly this: signal it and + // await confirmed termination, the same way stop_sandbox/ + // delete_sandbox would. Otherwise fall back to reclaiming + // exec_child directly and killing it (a monitor_exec race that + // hasn't claimed exec_child yet -- rare, but possible), or, for + // isolation_session (which has neither shutdown_tx/terminated_rx + // nor -- by this point -- a leftover exec_child, since + // monitor_exec almost always already claimed it), an explicit + // invoker.stop() on the isolation session. + let (shutdown_tx, terminated_rx, leftover_child, iso_id, isolation_stopped) = { + let mut reg = registry.lock().await; + match reg.get_mut(&sandbox_id) { + Some(entry) => ( + entry.shutdown_tx.take(), + // .clone(), not .take(): see stop_sandbox's matching + // comment on terminated_rx. + entry.terminated_rx.clone(), + entry.exec_child.take(), + entry.iso_sandbox_id.clone(), + entry.isolation_stopped, + ), + None => (None, None, None, None, false), + } + }; + if let Some(tx) = shutdown_tx { + let _ = tx.send(()); + } + if let Some(mut child) = leftover_child { + // We raced monitor_exec for exec_child and this branch's + // earlier `entry.exec_child.take()` won: monitor_exec will + // find the registry slot already empty and return before + // ever reaching its `done_tx.send(true)` (MR !98 review + // thread). Waiting on terminated_rx here would therefore + // wait out the full timeout for a signal that never comes, + // while `child` -- which does not kill-on-drop -- leaks. + // Kill and reap it directly instead. + let _ = child.kill().await; + let _ = child.wait().await; + } else if let Some(mut rx) = terminated_rx { + // leftover_child was None, so monitor_exec already claimed + // exec_child and is the one racing shutdown_tx against + // child.wait(); await its confirmed termination. + if !wait_for_termination(&mut rx).await { + warn!(sandbox = %sandbox_name, "timed out waiting for ProcessContainer termination after launch failure"); + } + } else if let Some(iso_id) = iso_id { + // isolation_session: no shutdown_tx/terminated_rx (those are + // ProcessContainer-only -- see the wiring site above) and no + // leftover_child either at this point, so without this the + // MXC session (and the wxc-exec `exec` child monitor_exec's + // own child.wait() is blocked on) would keep running + // indefinitely after a launch/target-ready failure here + // (MR !98 review thread). Best-effort: log rather than + // propagate, since this cleanup runs inside an + // already-failing path. + if !isolation_stopped { + if let Err(error) = invoker.stop(&iso_id).await { + warn!(sandbox = %sandbox_name, %error, "failed to stop isolation session after launch failure"); + } else { + let mut reg = registry.lock().await; + if let Some(entry) = reg.get_mut(&sandbox_id) { + entry.isolation_stopped = true; + } + } + } + } + return; } + } + // 5. Self-report Ready=True. The cancellable handle (exec_child, and for + // ProcessContainer shutdown_tx/terminated_rx) was already published to + // the registry and monitor_exec already spawned, above -- this only + // updates the sandbox's condition now that the target is confirmed + // reachable. + let ready_sandbox = make_sandbox_with_condition( + &sandbox, + &DriverCondition { + r#type: "Ready".into(), + status: "True".into(), + reason: "AgentRunning".into(), + message: format!("Agent exec launched: {command_line}"), + last_transition_time: String::new(), + }, + false, + ); + { + let mut reg = registry.lock().await; + let Some(entry) = reg.get_mut(&sandbox_id) else { + return; + }; + // Only Starting -> Running is valid here. This publish runs after + // run_lifecycle released its startup gate (see drop(startup_guard) + // above) specifically so a stop/delete arriving during the + // target-readiness wait isn't blocked behind it -- but that means a + // stop/delete may have already moved this sandbox past Starting by + // the time this runs. Overwriting that final state with a stale + // Running, or emitting a Ready event for an already-Stopped + // sandbox, would be wrong (MR !98 review thread; same reasoning as + // set_failed's matching guard). + if entry.phase_state != PhaseState::Starting { + return; + } entry.sandbox = ready_sandbox.clone(); entry.phase_state = PhaseState::Running; - entry.monitor_cancel = Some(cancel_tx); - entry.monitor_task = Some(tokio::spawn(monitor_exec( - registry.clone(), - watch_tx.clone(), - attribution.clone(), - sandbox.clone(), - sandbox_id.clone(), - cancel_rx, - child, - ))); } let _ = watch_tx.send(sandbox_event(ready_sandbox)); } +async fn wait_for_termination(rx: &mut watch::Receiver) -> bool { + matches!( + tokio::time::timeout( + std::time::Duration::from_secs(10), + rx.wait_for(|done| *done) + ) + .await, + Ok(Ok(_)) + ) +} + async fn monitor_exec( registry: Arc>>, watch_tx: Arc>, attribution: Arc>, sandbox: DriverSandbox, sandbox_id: String, - mut cancel_rx: watch::Receiver, - mut child: tokio::process::Child, + shutdown: Option<(oneshot::Receiver<()>, watch::Sender)>, ) { + let child = { + let mut reg = registry.lock().await; + reg.get_mut(&sandbox_id).and_then(|e| e.exec_child.take()) + }; + let Some(mut child) = child else { + return; + }; let wxc_pid = child.id(); - let status = tokio::select! { - status = child.wait() => Some(status), - changed = cancel_rx.changed() => { - let should_kill = changed.is_ok() && *cancel_rx.borrow_and_update(); - if should_kill { + + // For ProcessContainer sandboxes a stop/delete can arrive while the + // agent is still running. Race child exit against the kill signal so + // that the wxc-exec process — and therefore the entire AppContainer + // (including any long-lived servers bound to ports) — is terminated + // promptly. `done_tx`, when present, is signaled once the process has + // genuinely exited (natural exit or the forced kill below) so + // stop_sandbox/delete_sandbox can await *confirmed* termination instead + // of firing the kill signal and immediately reporting success. + let (wait_result, done_tx) = if let Some((rx, done_tx)) = shutdown { + tokio::select! { + res = child.wait() => (res, Some(done_tx)), + _ = rx => { if let Err(error) = child.kill().await { - warn!(sandbox = %sandbox.name, error = %error, "failed to terminate MXC agent process"); + warn!(sandbox = %sandbox.name, %error, "failed to terminate MXC process"); + return; } - // `kill` waits on current Tokio releases, but an explicit wait is - // harmless and guarantees the OS process handle is reaped. - let _ = child.wait().await; + if let Err(error) = child.wait().await { + warn!(sandbox = %sandbox.name, %error, "failed to confirm MXC process termination"); + return; + } + if let Some(pid) = wxc_pid + && let Ok(mut idx) = attribution.lock() + { + idx.retire_launch(&sandbox_id, pid); + } + info!(sandbox = %sandbox.name, "MXC ProcessContainer terminated for sandbox stop/delete"); + let _ = done_tx.send(true); + return; } - None } + } else { + (child.wait().await, None) }; - - // A Windows PID is authoritative only while the exact driver-owned child is - // alive. Retire it on every monitor exit path, including cancellation, before - // Windows can recycle it while the sandbox remains in the registry. - if let Some(pid) = wxc_pid - && let Ok(mut idx) = attribution.lock() - { - idx.retire_launch(&sandbox_id, pid); + if wait_result.is_ok() { + if let Some(pid) = wxc_pid + && let Ok(mut idx) = attribution.lock() + { + idx.retire_launch(&sandbox_id, pid); + } + if let Some(done_tx) = done_tx { + let _ = done_tx.send(true); + } } - let Some(status) = status else { - return; - }; - - match status { + match wait_result { Ok(status) if status.success() => { info!(sandbox = %sandbox.name, "MXC agent exec completed successfully"); let done = make_sandbox_with_condition( @@ -1170,11 +2275,24 @@ async fn set_failed( false, ); let mut reg = registry.lock().await; - if let Some(entry) = reg.get_mut(sandbox_id) { - entry.host_proxy = None; - entry.sandbox = failed.clone(); - entry.phase_state = PhaseState::Failed(message.to_string()); + let Some(entry) = reg.get_mut(sandbox_id) else { + return; + }; + // Only Starting -> Failed is valid here. Every call site before + // run_lifecycle releases its startup gate is safe by construction + // (phase_state is provably still Starting -- stop_sandbox/delete_sandbox + // can't touch the entry until the gate opens). The one call site after + // the gate is released (the control-channel launch-failure path) is not: + // a concurrent stop/delete may have already moved this sandbox past + // Starting while this lifecycle was still waiting on target-readiness, + // and overwriting that final state with a stale Failed -- or emitting a + // Failed event for an already-Stopped sandbox -- would be wrong + // (MR !98 review thread). + if entry.phase_state != PhaseState::Starting { + return; } + entry.sandbox = failed.clone(); + entry.phase_state = PhaseState::Failed(message.to_string()); drop(reg); let _ = watch_tx.send(sandbox_event(failed)); } @@ -1216,7 +2334,9 @@ mod lifecycle_tests { use futures::StreamExt; use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; use openshell_core::proto::{ - FilesystemPolicy, MiddlewareEndpointSelector, NetworkMiddlewareConfig, SandboxPolicy, + FilesystemPolicy, MiddlewareEndpointSelector, NetworkBinary, NetworkEndpoint, + NetworkMiddlewareConfig, NetworkPolicyRule, SandboxPolicy, StaticCredentialBinding, + StaticCredentialEndpointBinding, UiClipboardAccess, UiPolicy, }; use std::time::Duration; @@ -1224,6 +2344,33 @@ mod lifecycle_tests { driver_sandbox_with_command(id, "", vec!["cmd".into(), "/c".into(), "exit 0".into()]) } + #[tokio::test] + async fn termination_confirmation_rejects_closed_unconfirmed_channel() { + let (tx, mut rx) = watch::channel(false); + drop(tx); + assert!(!wait_for_termination(&mut rx).await); + } + + #[tokio::test] + async fn termination_confirmation_accepts_confirmed_exit() { + let (tx, mut rx) = watch::channel(false); + tx.send(true).unwrap(); + drop(tx); + assert!(wait_for_termination(&mut rx).await); + } + + #[test] + fn ui_policy_capability_tracks_configured_backend() { + let process_container = MxcComputeBackend::new_mocked(MxcComputeConfig::default()); + assert!(process_container.capabilities().supports_ui_policy); + + let isolation_session = MxcComputeBackend::new_mocked(MxcComputeConfig { + backend: MxcBackend::IsolationSession, + ..Default::default() + }); + assert!(!isolation_session.capabilities().supports_ui_policy); + } + fn driver_sandbox_with_command(id: &str, cwd: &str, command: Vec) -> DriverSandbox { let serde_json::Value::Object(driver_config) = serde_json::json!({ "command": command, @@ -1260,6 +2407,32 @@ mod lifecycle_tests { } } + fn github_provider_credentials() -> ProviderCredentialState { + ProviderCredentialState::from_bound_environment( + 42, + HashMap::from([( + "GITHUB_TOKEN".to_string(), + "raw-test-token-must-not-enter-mxc".to_string(), + )]), + HashMap::new(), + HashMap::new(), + HashMap::from([( + "GITHUB_TOKEN".to_string(), + StaticCredentialBinding { + endpoints: vec![StaticCredentialEndpointBinding { + host: "api.github.com".to_string(), + port: 443, + path: "/**".to_string(), + }], + credential_identity: "provider-github:GITHUB_TOKEN".to_string(), + workload_credential_handle: String::new(), + }, + )]), + Vec::new(), + ) + .expect("valid GitHub provider credential state") + } + fn with_policy(mut sandbox: DriverSandbox, policy: SandboxPolicy) -> DriverSandbox { sandbox.spec.as_mut().unwrap().policy = Some(policy); sandbox @@ -1302,6 +2475,17 @@ mod lifecycle_tests { assert!(config.egress_proxy_addr.is_empty()); } + #[test] + fn gateway_config_rejects_per_sandbox_workload_fields() { + for field in ["agent_command", "agent_cwd", "agent_env", "share_dir"] { + let mut config = serde_json::Map::new(); + config.insert(field.to_string(), serde_json::json!([])); + let error = serde_json::from_value::(config.into()) + .expect_err("workload fields must not be accepted in gateway config"); + assert!(error.to_string().contains(field)); + } + } + #[test] fn sandbox_proxy_addr_uses_ephemeral_loopback_port() { let configured = "127.0.0.1:18080".parse().unwrap(); @@ -1311,7 +2495,349 @@ mod lifecycle_tests { } #[test] - fn sandbox_environment_inherits_host_with_spec_precedence() { + fn governed_egress_rejects_non_loopback_and_isolation_session() { + let mut config = MxcComputeConfig { + egress_proxy: true, + egress_proxy_addr: "10.0.0.1:18080".into(), + ..Default::default() + }; + assert!( + configured_egress_addr(&config) + .unwrap_err() + .message() + .contains("127.0.0.1") + ); + + config.egress_proxy_addr = "127.0.0.1:18080".into(); + config.backend = MxcBackend::IsolationSession; + let error = configured_egress_addr(&config).unwrap_err(); + assert_eq!(error.code(), tonic::Code::InvalidArgument); + assert!(error.message().contains("requires process_container")); + } + + #[test] + fn provider_child_environment_overrides_agent_values_with_placeholders() { + let credentials = github_provider_credentials(); + let placeholder = credentials + .snapshot() + .child_env + .get("GITHUB_TOKEN") + .cloned() + .expect("GitHub placeholder"); + let mut env = vec![ + "github_token=agent-value".to_string(), + "UNCHANGED=value".to_string(), + ]; + + append_provider_child_env(&mut env, Some(&credentials)); + + assert!(env.contains(&"UNCHANGED=value".to_string())); + assert!(!env.iter().any(|entry| entry == "github_token=agent-value")); + assert!(env.contains(&format!("GITHUB_TOKEN={placeholder}"))); + assert!(!env.iter().any(|entry| entry.contains("raw-test-token"))); + } + + #[test] + fn provider_child_env_keys_reject_case_insensitive_collision() { + let credentials = ProviderCredentialState::from_bound_environment( + 1, + HashMap::from([ + ("github_token".to_string(), "a".to_string()), + ("GITHUB_TOKEN".to_string(), "b".to_string()), + ]), + HashMap::new(), + HashMap::new(), + HashMap::new(), + vec!["github_token".to_string(), "GITHUB_TOKEN".to_string()], + ) + .expect("valid provider credential state"); + + let error = validate_provider_child_env_keys(Some(&credentials)) + .expect_err("case-colliding provider keys must fail closed"); + assert_eq!(error.code(), tonic::Code::FailedPrecondition); + assert!(error.message().contains("collides")); + } + + #[test] + fn provider_child_env_keys_reject_tls_reserved_name() { + let credentials = ProviderCredentialState::from_bound_environment( + 1, + HashMap::from([("SSL_CERT_FILE".to_string(), "not-a-ca-bundle".to_string())]), + HashMap::new(), + HashMap::new(), + HashMap::new(), + vec!["SSL_CERT_FILE".to_string()], + ) + .expect("valid provider credential state"); + + let error = validate_provider_child_env_keys(Some(&credentials)) + .expect_err("TLS-reserved provider keys must fail closed"); + assert_eq!(error.code(), tonic::Code::FailedPrecondition); + assert!(error.message().contains("reserved for TLS")); + } + + #[test] + fn provider_child_env_keys_allow_distinct_names() { + let credentials = github_provider_credentials(); + validate_provider_child_env_keys(Some(&credentials)) + .expect("non-colliding, non-reserved provider keys are allowed"); + } + + #[tokio::test] + async fn provider_state_requiring_resolution_fails_closed_without_governed_egress() { + let backend = MxcComputeBackend::new_mocked(MxcComputeConfig::default()); + let sink = backend.provider_credentials_sink(); + sink.lock() + .expect("provider credential staging lock poisoned") + .insert( + "sb-provider-no-proxy".to_string(), + github_provider_credentials(), + ); + + let error = backend + .create_sandbox(&with_policy( + driver_sandbox("sb-provider-no-proxy"), + fs_policy(&[]), + )) + .await + .expect_err("provider credentials must require governed egress"); + + assert_eq!(error.code(), tonic::Code::FailedPrecondition); + assert!(error.message().contains("enable egress_proxy")); + assert!( + sink.lock() + .expect("provider credential staging lock poisoned") + .is_empty(), + "the driver must consume staged credential material on every create attempt" + ); + } + + #[tokio::test] + async fn governed_egress_puts_only_provider_placeholder_in_mxc_process_env() { + let mut config = MxcComputeConfig { + egress_proxy: true, + egress_proxy_addr: "127.0.0.1:18080".to_string(), + ..Default::default() + }; + config.backend = MxcBackend::ProcessContainer; + let backend = MxcComputeBackend::new_mocked(config); + let credentials = github_provider_credentials(); + let placeholder = credentials + .snapshot() + .child_env + .get("GITHUB_TOKEN") + .cloned() + .expect("GitHub placeholder"); + backend + .provider_credentials_sink() + .lock() + .expect("provider credential staging lock poisoned") + .insert("sb-provider-env".to_string(), credentials); + + let mut policy = fs_policy(&[]); + policy.network_policies.insert( + "github".to_string(), + NetworkPolicyRule { + name: "github".to_string(), + endpoints: vec![NetworkEndpoint { + host: "api.github.com".to_string(), + port: 443, + protocol: "rest".to_string(), + provider_credentialed: true, + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: "cmd".to_string(), + ..Default::default() + }], + }, + ); + let mut sandbox = with_policy(driver_sandbox("sb-provider-env"), policy); + sandbox + .spec + .as_mut() + .expect("sandbox spec") + .environment + .extend([ + ( + "GITHUB_TOKEN".to_string(), + "raw-agent-env-token".to_string(), + ), + ("UNCHANGED".to_string(), "value".to_string()), + ]); + + backend + .create_sandbox(&sandbox) + .await + .expect("create accepted"); + wait_for(&backend, "sb-provider-env", |_| { + crate::mxc::mock_recorded_config("sb-provider-env").is_some() + }) + .await + .expect("MXC config should be recorded"); + + let recorded = + crate::mxc::mock_recorded_config("sb-provider-env").expect("mock recorded config"); + let env = recorded["process"]["env"] + .as_array() + .expect("MXC process env") + .iter() + .filter_map(serde_json::Value::as_str) + .collect::>(); + assert!(env.contains(&"UNCHANGED=value")); + assert!(env.contains(&format!("GITHUB_TOKEN={placeholder}").as_str())); + let encoded = recorded.to_string(); + assert!(!encoded.contains("raw-agent-env-token")); + assert!(!encoded.contains("raw-test-token-must-not-enter-mxc")); + } + + #[test] + fn tls_state_replaces_user_trust_overrides_and_grants_ca_directory_once() { + let tls_dir = std::env::temp_dir().join("openshell-mxc-tls-test"); + let ca_cert = tls_dir.join("openshell-ca.pem"); + let bundle = tls_dir.join("ca-bundle.pem"); + let mut env = vec![ + "FOO=bar".to_string(), + "SSL_CERT_FILE=C:\\old\\bundle.pem".to_string(), + ]; + append_tls_env_vars(&mut env, Some(&(ca_cert.clone(), bundle.clone()))); + assert!(env.contains(&"FOO=bar".to_string())); + assert!( + !env.iter() + .any(|entry| entry == "SSL_CERT_FILE=C:\\old\\bundle.pem") + ); + assert!(env.contains(&format!("SSL_CERT_FILE={}", bundle.display()))); + + let existing = tls_dir.display().to_string().to_ascii_lowercase(); + let mut readonly = vec![existing.clone()]; + append_tls_readonly_grant(&mut readonly, Some(&(ca_cert, bundle))); + assert_eq!(readonly, vec![existing]); + } + + #[test] + fn tls_ca_files_are_staged_under_the_authorized_share() { + let source = tempfile::tempdir().unwrap(); + let share = tempfile::tempdir().unwrap(); + let ca = source.path().join("source-ca.pem"); + let bundle = source.path().join("source-bundle.pem"); + std::fs::write(&ca, b"ca").unwrap(); + std::fs::write(&bundle, b"bundle").unwrap(); + + let staged = stage_tls_ca_files( + Some(&(ca, bundle)), + share.path().to_str().expect("UTF-8 test path"), + "sandbox-a", + ) + .unwrap() + .expect("staged paths"); + + assert_eq!( + staged.0.parent().unwrap(), + share.path().join(".openshell-proxy").join("sandbox-a") + ); + assert_eq!(std::fs::read(staged.0).unwrap(), b"ca"); + assert_eq!(std::fs::read(staged.1).unwrap(), b"bundle"); + } + + #[test] + fn tls_ca_staging_keeps_sandboxes_in_the_same_share_independent() { + let source = tempfile::tempdir().unwrap(); + let share = tempfile::tempdir().unwrap(); + let paths = ( + source.path().join("ca.pem"), + source.path().join("bundle.pem"), + ); + std::fs::write(&paths.0, b"first-ca").unwrap(); + std::fs::write(&paths.1, b"first-bundle").unwrap(); + let first = stage_tls_ca_files(Some(&paths), share.path().to_str().unwrap(), "sandbox-a") + .unwrap() + .unwrap(); + std::fs::write(&paths.0, b"second-ca").unwrap(); + std::fs::write(&paths.1, b"second-bundle").unwrap(); + let second = stage_tls_ca_files(Some(&paths), share.path().to_str().unwrap(), "sandbox-b") + .unwrap() + .unwrap(); + + assert_ne!(first, second); + assert_eq!(std::fs::read(first.0).unwrap(), b"first-ca"); + assert_eq!(std::fs::read(first.1).unwrap(), b"first-bundle"); + assert_eq!(std::fs::read(second.0).unwrap(), b"second-ca"); + assert_eq!(std::fs::read(second.1).unwrap(), b"second-bundle"); + } + + #[test] + fn tls_ca_staging_rejects_empty_shares_and_unsafe_sandbox_components() { + let share = tempfile::tempdir().unwrap(); + let paths = (PathBuf::from("unused-ca"), PathBuf::from("unused-bundle")); + for empty in ["", " ", "\t"] { + assert_eq!( + stage_tls_ca_files(Some(&paths), empty, "sandbox-a") + .unwrap_err() + .kind(), + std::io::ErrorKind::InvalidInput + ); + } + for id in [ + "", + ".", + "..", + "../outside", + "..\\outside", + "C:\\outside", + "file:stream", + ] { + assert_eq!( + stage_tls_ca_files(Some(&paths), share.path().to_str().unwrap(), id) + .unwrap_err() + .kind(), + std::io::ErrorKind::InvalidInput + ); + } + assert_eq!(std::fs::read_dir(share.path()).unwrap().count(), 0); + assert_eq!(stage_tls_ca_files(None, "", "").unwrap(), None); + } + + #[test] + fn proxy_env_replaces_inherited_values_and_clears_bypass_rules() { + let mut env = vec![ + "PATH=C:\\Windows".to_owned(), + "HTTP_PROXY=http://stale.invalid:1".to_owned(), + "https_proxy=http://stale.invalid:2".to_owned(), + "NO_PROXY=example.com".to_owned(), + "no_proxy=example.org".to_owned(), + ]; + + let proxy_auth = SandboxProxyAuth { + password: "sandbox-secret".to_owned(), + }; + append_proxy_env_vars( + &mut env, + Some("127.0.0.1:18080".parse().unwrap()), + Some(&proxy_auth), + ); + + assert!(env.contains(&"PATH=C:\\Windows".to_owned())); + for key in PROXY_ENV_KEYS { + assert_eq!( + env.iter() + .filter(|entry| entry.starts_with(&format!("{key}="))) + .count(), + 1, + "{key} must be emitted exactly once" + ); + } + assert!( + env.contains(&"HTTP_PROXY=http://openshell:sandbox-secret@127.0.0.1:18080".to_owned()) + ); + assert!( + env.contains(&"HTTPS_PROXY=http://openshell:sandbox-secret@127.0.0.1:18080".to_owned()) + ); + assert!(env.contains(&"NO_PROXY=".to_owned())); + assert!(env.contains(&"no_proxy=".to_owned())); + } + + #[test] + fn sandbox_environment_uses_sandbox_scope_with_spec_precedence() { let mut sandbox = driver_sandbox("sb-env"); let spec = sandbox.spec.as_mut().unwrap(); spec.template @@ -1324,14 +2850,9 @@ mod lifecycle_tests { let environment = sandbox_environment(&sandbox); assert!(environment.contains(&"SHARED=spec".to_string())); assert!(environment.contains(&"TOKEN=value".to_string())); - for key in MINIMAL_WINDOWS_BOOTSTRAP_ENV { - if let Ok(value) = std::env::var(key) { - assert!(environment.contains(&format!("{key}={value}"))); - } - } assert!(environment.iter().all(|entry| { let key = entry.split_once('=').map_or(entry.as_str(), |(key, _)| key); - key == "SHARED" || key == "TOKEN" || MINIMAL_WINDOWS_BOOTSTRAP_ENV.contains(&key) + key == "SHARED" || key == "TOKEN" })); } @@ -1424,7 +2945,7 @@ mod lifecycle_tests { assert!(ready.is_some(), "sandbox should self-report Ready=True"); // Positive proof: the in-policy write materializes the host artifact. - let host_path = std::path::Path::new(tmp.path()).join("hello.txt"); + let host_path = tmp.path().join("hello.txt"); let mut found = false; for _ in 0..100 { if host_path.exists() { @@ -1490,8 +3011,11 @@ mod lifecycle_tests { recorded.get("network").is_none(), "coarse path must not emit an MXC network block" ); + assert_eq!(recorded["ui"]["disable"], true); + assert_eq!(recorded["ui"]["clipboard"], "none"); + assert_eq!(recorded["ui"]["injection"], false); - let host_path = std::path::Path::new(tmp.path()).join("hello.txt"); + let host_path = tmp.path().join("hello.txt"); let mut found = false; for _ in 0..100 { if host_path.exists() { @@ -1519,10 +3043,12 @@ mod lifecycle_tests { "-Command".into(), format!("Set-Content -LiteralPath {hello} -Value hi"), ]; - let mut config = MxcComputeConfig::default(); - config.backend = MxcBackend::ProcessContainer; - config.egress_proxy = true; - config.egress_proxy_addr = "127.0.0.1:18080".into(); + let config = MxcComputeConfig { + backend: MxcBackend::ProcessContainer, + egress_proxy: true, + egress_proxy_addr: "127.0.0.1:18080".into(), + ..Default::default() + }; let backend = MxcComputeBackend::new_mocked(config); let mut stream = backend.watch_sandboxes().await; @@ -1547,6 +3073,7 @@ mod lifecycle_tests { driver_sandbox_with_command("sb-egress", &share, cmd), policy.clone(), ); + backend .create_sandbox(&sandbox) .await @@ -1562,23 +3089,16 @@ mod lifecycle_tests { ); let recorded = crate::mxc::mock_recorded_config("sb-egress").expect("mock recorded config"); - assert_eq!(recorded["network"]["defaultPolicy"], "block"); + assert_eq!(recorded["version"], "0.8.0-alpha"); + assert_eq!(recorded["network"]["egress"]["default"], "deny"); + assert_eq!( + recorded["network"]["egress"]["allow"], + serde_json::json!([{"to": [{"cidr": "127.0.0.1/32"}]}]) + ); assert!(recorded["network"].get("allowedHosts").is_none()); assert!(recorded["network"].get("blockedHosts").is_none()); - // MXC 0.6.0-alpha accepts only {"proxy": {"localhost": N}}. - let proxy_port = recorded["network"]["proxy"]["localhost"] - .as_u64() - .expect("proxy localhost port"); - assert!(proxy_port > 0); - assert!(proxy_port <= u64::from(u16::MAX)); - assert!( - recorded["network"]["proxy"].get("host").is_none(), - "proxy must not contain 'host' key" - ); - assert!( - recorded["network"]["proxy"].get("port").is_none(), - "proxy must not contain 'port' key" - ); + assert!(recorded["network"].get("proxy").is_none()); + assert!(recorded.get("networkProxy").is_none()); let reg = backend.registry.lock().await; let entry = reg.get("sb-egress").expect("registry entry"); @@ -1587,7 +3107,19 @@ mod lifecycle_tests { entry_proxy_addr.ip(), std::net::IpAddr::from([127, 0, 0, 1]) ); - assert_eq!(u64::from(entry_proxy_addr.port()), proxy_port); + assert_ne!(entry_proxy_addr.port(), 0); + let child_env = recorded["process"]["env"].as_array().expect("child env"); + let proxy_env = child_env + .iter() + .filter_map(serde_json::Value::as_str) + .find_map(|entry| entry.strip_prefix("HTTP_PROXY=")) + .expect("HTTP_PROXY must direct clients to the authenticated proxy"); + let (credentials, address) = proxy_env + .strip_prefix("http://openshell:") + .and_then(|value| value.split_once('@')) + .expect("proxy URL must contain the per-sandbox credential"); + assert!(!credentials.is_empty()); + assert_eq!(address, entry_proxy_addr.to_string()); assert_eq!( entry.trimmed_policy.as_ref().unwrap().network_policies, policy.network_policies @@ -1610,12 +3142,58 @@ mod lifecycle_tests { } } Ok(_) => break, - Err(_) => continue, + Err(_) => {} } } assert!(saw_redirect, "expected EgressRedirect platform event"); } + #[tokio::test] + async fn processcontainer_live_config_carries_explicit_ui_policy() { + let backend = MxcComputeBackend::new_mocked(MxcComputeConfig::default()); + let mut policy = fs_policy(&[]); + policy.ui = Some(UiPolicy { + allow_graphical_ui: true, + clipboard: UiClipboardAccess::All as i32, + allow_input_injection: true, + }); + let sandbox = with_policy(driver_sandbox("sb-pc-ui"), policy); + backend + .create_sandbox(&sandbox) + .await + .expect("create accepted"); + let _ = wait_for(&backend, "sb-pc-ui", |_| { + crate::mxc::mock_recorded_config("sb-pc-ui").is_some() + }) + .await; + let recorded = crate::mxc::mock_recorded_config("sb-pc-ui") + .expect("mock recorded processContainer config"); + assert_eq!(recorded["ui"]["disable"], false); + assert_eq!(recorded["ui"]["clipboard"], "all"); + assert_eq!(recorded["ui"]["injection"], true); + } + + #[tokio::test] + async fn isolation_session_rejects_ui_before_lifecycle_side_effects() { + let backend = MxcComputeBackend::new_mocked(MxcComputeConfig { + backend: MxcBackend::IsolationSession, + ..Default::default() + }); + let policy = SandboxPolicy { + ui: Some(UiPolicy::default()), + ..Default::default() + }; + let sandbox = with_policy(driver_sandbox("sb-iso-ui"), policy); + let error = backend + .create_sandbox(&sandbox) + .await + .expect_err("isolation UI must be rejected synchronously"); + assert_eq!(error.code(), tonic::Code::InvalidArgument); + assert!(error.message().contains("ui")); + assert!(backend.list_sandboxes().await.is_empty()); + assert!(crate::mxc::mock_recorded_config("sb-iso-ui").is_none()); + } + #[tokio::test] async fn negative_out_of_policy_write_is_denied_with_event() { let share_tmp = tempfile::tempdir().unwrap(); @@ -1669,7 +3247,7 @@ mod lifecycle_tests { ); // The out-of-policy artifact must NOT have been written by the mock. - let out_fs = std::path::Path::new(out_tmp.path()).join("hello.txt"); + let out_fs = out_tmp.path().join("hello.txt"); assert!(!out_fs.exists(), "out-of-policy write must be denied"); // And the sandbox surfaces a terminal ExecFailed Ready=False condition. @@ -1714,6 +3292,144 @@ mod lifecycle_tests { assert_eq!(ready_condition(&stopped).unwrap().reason, "Stopped"); } + /// `delete_sandbox` must await *confirmed* `ProcessContainer` termination + /// before removing the registry entry and reporting success -- not just + /// fire the kill signal and report success regardless (which could + /// leave the process retaining ports and file locks past a successful + /// delete). Bounded well under the child's own sleep duration: if + /// delete stopped awaiting `terminated_rx`, this would still return + /// quickly (the bug was reporting success *too early*, not hanging), so + /// the meaningful assertion is that the sandbox is confirmed gone from + /// the registry immediately after -- a second delete finds nothing. + #[tokio::test] + async fn delete_terminates_and_reaps_a_running_process_container() { + let tmp = tempfile::tempdir().unwrap(); + let share = tmp.path().to_string_lossy().replace('\\', "/"); + let command = vec![ + "powershell".into(), + "-NoProfile".into(), + "-Command".into(), + format!("$null = '{share}'; Start-Sleep -Seconds 60"), + ]; + let backend = MxcComputeBackend::new_mocked(MxcComputeConfig::default()); + let policy = fs_policy(&[&share]); + let sandbox = with_policy( + driver_sandbox_with_command("sb-delete", "", command), + policy, + ); + backend + .create_sandbox(&sandbox) + .await + .expect("create accepted"); + wait_for(&backend, "sb-delete", |sandbox| { + ready_condition(sandbox).is_some_and(|condition| condition.reason == "AgentRunning") + }) + .await + .expect("long-running child should start"); + tokio::time::sleep(Duration::from_millis(250)).await; + + let deleted = tokio::time::timeout( + Duration::from_secs(5), + backend.delete_sandbox(&sandbox.id, "sb-delete"), + ) + .await + .expect("delete should not wait for the child sleep") + .expect("delete should terminate and reap the child"); + assert!(deleted, "delete should report the sandbox as removed"); + assert!( + backend.get_sandbox("sb-delete").await.is_none(), + "sandbox should be gone from the registry after delete" + ); + } + + /// `delete_sandbox` must confirm genuine termination via `terminated_rx` + /// even when `shutdown_tx` was already consumed by an earlier attempt (a + /// timed-out delete/stop, or -- as constructed directly here -- any + /// other caller that got to the field first). `terminated_rx` must + /// therefore be `.clone()`d, not `.take()`n, from the registry entry: + /// taking it would make this call's own None-shutdown_tx branch skip + /// the wait entirely and report success (and remove the registry entry) + /// before termination was ever confirmed -- exactly the MR !98 review + /// thread this regression-tests ("delete timeout loses termination + /// state"). + #[tokio::test] + async fn delete_confirms_termination_even_when_shutdown_tx_already_taken() { + let tmp = tempfile::tempdir().unwrap(); + let share = tmp.path().to_string_lossy().replace('\\', "/"); + let command = vec![ + "powershell".into(), + "-NoProfile".into(), + "-Command".into(), + format!("$null = '{share}'; Start-Sleep -Seconds 60"), + ]; + let backend = MxcComputeBackend::new_mocked(MxcComputeConfig::default()); + let policy = fs_policy(&[&share]); + let sandbox = with_policy( + driver_sandbox_with_command("sb-delete-retry", "", command), + policy, + ); + backend + .create_sandbox(&sandbox) + .await + .expect("create accepted"); + wait_for(&backend, "sb-delete-retry", |sandbox| { + ready_condition(sandbox).is_some_and(|condition| condition.reason == "AgentRunning") + }) + .await + .expect("long-running child should start"); + tokio::time::sleep(Duration::from_millis(250)).await; + + // Simulate an earlier delete/stop attempt that already consumed and + // fired shutdown_tx (e.g. one that then timed out before + // confirming termination) -- take a fresh watch subscription first + // so this test can observe the SAME completion delete_sandbox + // itself must wait for. + let terminated = { + let registry = backend.registry.lock().await; + registry + .get(&sandbox.id) + .expect("sandbox should be registered") + .terminated_rx + .clone() + .expect("ProcessContainer entry should have terminated_rx wired") + }; + { + let mut registry = backend.registry.lock().await; + let entry = registry + .get_mut(&sandbox.id) + .expect("sandbox should be registered"); + let tx = entry + .shutdown_tx + .take() + .expect("ProcessContainer entry should have shutdown_tx wired"); + let _ = tx.send(()); + } + + let deleted = tokio::time::timeout( + Duration::from_secs(5), + backend.delete_sandbox(&sandbox.id, "sb-delete-retry"), + ) + .await + .expect("delete should still confirm termination within the timeout, not hang") + .expect("delete should succeed even with shutdown_tx already taken"); + assert!(deleted, "delete should report the sandbox as removed"); + + // The regression this guards against: delete_sandbox used to + // .take() terminated_rx too, so finding shutdown_tx already None + // made it skip waiting entirely and return success immediately -- + // well before the real OS process had actually been killed and + // reaped. If that bug were back, this watch value could still be + // false right here. + assert!( + *terminated.borrow(), + "delete_sandbox must not report success before terminated_rx confirms the process died" + ); + assert!( + backend.get_sandbox("sb-delete-retry").await.is_none(), + "sandbox should be gone from the registry after delete" + ); + } + #[tokio::test] async fn unmappable_network_policy_fails_create_lifecycle() { use openshell_core::proto::{NetworkEndpoint, NetworkPolicyRule}; @@ -1745,9 +3461,11 @@ mod lifecycle_tests { #[tokio::test] async fn governed_egress_rejects_network_middleware_before_lifecycle() { - let mut config = MxcComputeConfig::default(); - config.egress_proxy = true; - config.egress_proxy_addr = "127.0.0.1:18080".into(); + let config = MxcComputeConfig { + egress_proxy: true, + egress_proxy_addr: "127.0.0.1:18080".into(), + ..Default::default() + }; let backend = MxcComputeBackend::new_mocked(config); let mut policy = fs_policy(&[]); diff --git a/crates/openshell-driver-mxc/src/lib.rs b/crates/openshell-driver-mxc/src/lib.rs index 4b9d328e9f..dce22b3b49 100644 --- a/crates/openshell-driver-mxc/src/lib.rs +++ b/crates/openshell-driver-mxc/src/lib.rs @@ -15,6 +15,8 @@ #![allow(clippy::result_large_err)] +#[cfg(target_os = "windows")] +mod control_channel; #[cfg(target_os = "windows")] mod driver; #[cfg(target_os = "windows")] @@ -32,11 +34,17 @@ mod policy_map; // Windows-only. #[cfg(target_os = "windows")] mod etw_consumer; +#[cfg(target_os = "windows")] +mod relay; #[cfg(target_os = "windows")] -pub use driver::{MxcBackend, MxcComputeBackend, MxcComputeConfig}; +pub use driver::{ + ForwardSink, MxcBackend, MxcComputeBackend, MxcComputeConfig, OpenDynamicForwardError, +}; #[cfg(target_os = "windows")] pub use grpc::ComputeDriverService; +#[cfg(target_os = "windows")] +pub use relay::RelayHandle; // Re-export the embedded mapper API so the windows-only example and integration // test can reach it without making `policy_map` a public module. #[cfg(target_os = "windows")] diff --git a/crates/openshell-driver-mxc/src/mxc.rs b/crates/openshell-driver-mxc/src/mxc.rs index 0a77565f83..eecc1f7155 100644 --- a/crates/openshell-driver-mxc/src/mxc.rs +++ b/crates/openshell-driver-mxc/src/mxc.rs @@ -15,10 +15,11 @@ use std::path::PathBuf; use std::sync::{Mutex, OnceLock}; use thiserror::Error; use tokio::process::Command; -use tracing::debug; +use tracing::{debug, info}; -/// MXC config schema version. -pub const MXC_SCHEMA_VERSION: &str = "0.6.0-alpha"; +/// MXC config schema version. The mapper and one-shot launcher share the +/// MXC 0.8 directional network schema. +pub const MXC_SCHEMA_VERSION: &str = "0.8.0-alpha"; /// Default `configurationId` for isolation session. Never use `"small"` (known OS bug). pub const DEFAULT_CONFIGURATION_ID: &str = "composable"; @@ -69,6 +70,46 @@ pub struct MxcFilesystem { pub struct MxcNetwork { pub default_policy: String, pub proxy: Option, + /// When true, includes `"allowLocalNetwork": true` in the network JSON. + /// Required for node.js to initialize inside a processcontainer — without + /// it, node.exe DLL initialization fails with `STATUS_DLL_INIT_FAILED`. + pub allow_local_network: bool, +} + +/// Directional clipboard access in the MXC top-level `ui` policy. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MxcClipboardAccess { + None, + Read, + Write, + All, +} + +impl MxcClipboardAccess { + const fn as_str(self) -> &'static str { + match self { + Self::None => "none", + Self::Read => "read", + Self::Write => "write", + Self::All => "all", + } + } +} + +/// Cross-platform MXC UI policy emitted for a process container. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MxcUi { + pub disable: bool, + pub clipboard: MxcClipboardAccess, + pub injection: bool, +} + +fn ui_json(ui: &MxcUi) -> serde_json::Value { + serde_json::json!({ + "disable": ui.disable, + "clipboard": ui.clipboard.as_str(), + "injection": ui.injection, + }) } /// `processContainer`-specific knobs (one-shot `AppContainer` backend). @@ -92,20 +133,74 @@ pub struct MxcProcess { pub timeout: u64, } +/// Redacts `process.env` and `process.commandLine` from a wxc-config JSON +/// before it's ever logged or written to a sandbox-readable path (only +/// under `self.debug`, but debug output still isn't a safe place for it). +/// Both can carry host secrets verbatim -- `env` e.g. the shipped +/// `OpenClaw` example config's `OPENCLAW_GATEWAY_TOKEN`, `commandLine` +/// whenever a secret is passed as a literal CLI argument -- and both debug +/// sinks (gateway logs, and for `run_oneshot` a file inside the sandbox's +/// own readwrite path) are places an attacker or an over-broad log +/// retention policy could read from. Everything else debug tooling might +/// need to compare (filesystem grants, network policy, ...) is left intact. +fn redact_env_for_debug(config: &serde_json::Value) -> serde_json::Value { + let mut redacted = config.clone(); + if let Some(env) = redacted.get_mut("process").and_then(|p| p.get_mut("env")) { + let count = env.as_array().map_or(0, Vec::len); + *env = serde_json::json!(format!("")); + } + if let Some(command_line) = redacted + .get_mut("process") + .and_then(|p| p.get_mut("commandLine")) + { + *command_line = serde_json::json!(""); + } + redacted +} + fn network_json(network: &MxcNetwork) -> serde_json::Value { - // MXC 0.6.0-alpha schema accepts ONLY {"proxy": {"localhost": }}. - // {"host": ..., "port": ...} and every other shape is rejected — verified - // empirically against the real wxc-exec 0.6.0-alpha binary via --dry-run. - // See also docs/reference/mxc-compute-driver-design.mdx §network.proxy. - // The MxcNetwork.proxy field remains SocketAddr so callers keep full - // precision; only the port is serialized into the localhost key. - // Released wxc-exec rejects allowedHosts and blockedHosts as unsupported, - // even when they are empty. The host proxy enforces the L7 allowlist. - let mut value = serde_json::json!({ - "defaultPolicy": network.default_policy.as_str(), - }); - if let Some(proxy) = network.proxy { - value["proxy"] = serde_json::json!({ "localhost": proxy.port() }); + // MXC 0.8.0-alpha schema uses a directional egress/ingress format. + // "block" default_policy maps to egress.default "deny"; "allow" maps to "allow". + let egress_default = if network.default_policy == "block" { + "deny" + } else { + "allow" + }; + let mut value = if network.proxy.is_some() { + // Use direct loopback egress rather than runtimeConfig.networkProxy proxy + // mode. Proxy mode routes all outbound TCP through processmodel.dll's WFP + // redirect, which in practice blocks loopback connects from the relay to + // its target process (127.0.0.1:port) even with networkLoopback capability + // in the PSEC spec. Direct allow for 127.0.0.1/32 (not the broader 127.0.0.0/8 + // range -- openshell-supervisor-relay only ever dials the literal + // 127.0.0.1, see imp.rs) lets the relay reach: + // - the target it spawns (loopback inside AppContainer) + // - the host relay listener (also 127.0.0.1 via egress allow) + // PSEC tier is still selected because requires_psec_networking() returns + // true when egress.allow is non-empty (no NetworkIsolationSetAppContainerConfig + // call needed — no elevation required). + // + // Deliberately no `ports` restriction: `openshell forward service`'s + // dynamic bridge (imp.rs's "forward" control-channel op) connects the + // relay out to a fresh, per-request ephemeral host port chosen at + // forward-call time (data.relay_addr), not a port known when this + // config is generated -- confirmed 2026-09-10 that scoping `ports` to + // just [proxy.port(), relay_target_port] breaks that dynamic forward + // (ws-echo failed with a "forbidden by access permissions" / 10013 + // relay-connect error). Any-port-on-127.0.0.1 is the correct scope + // here, not a narrower static list. + serde_json::json!({ + "egress": { + "default": "deny", + "allow": [{"to": [{"cidr": "127.0.0.1/32"}]}] + }, + "ingress": { "default": "allow", "hostLoopback": "allow" }, + }) + } else { + serde_json::json!({ "egress": { "default": egress_default } }) + }; + if network.proxy.is_none() && network.allow_local_network { + value["ingress"] = serde_json::json!({ "default": "allow", "hostLoopback": "allow" }); } value } @@ -142,6 +237,7 @@ fn oneshot_config_json( pc: &MxcProcessContainer, process: &MxcProcess, network: Option<&MxcNetwork>, + ui: Option<&MxcUi>, ) -> serde_json::Value { let mut filesystem_json = serde_json::Map::new(); if !filesystem.readwrite_paths.is_empty() { @@ -182,6 +278,15 @@ fn oneshot_config_json( if let Some(network) = network { config["network"] = network_json(network); } + // Root-level ui section required by mxc-fixes-env-vars build. Comes from + // the typed SandboxPolicy via `ui` -- EmbeddedPolicyMapper always + // populates this for process_container (with restrictive defaults -- + // disable=true, Win32k syscall lockdown -- when the policy has no + // explicit `ui:` section), so `None` here only happens in tests that + // bypass the mapper; omit the section entirely rather than guess. + if let Some(ui) = ui { + config["ui"] = ui_json(ui); + } config } @@ -335,7 +440,11 @@ impl WxcExecInvoker { cmd.arg("--debug"); } - debug!(config = %json, "wxc-exec phase"); + // `config` here never carries `process.env` today (provision/start/ + // stop/deprovision have no `process` field at all -- see run_phase's + // doc comment), but redact defensively rather than relying on that + // staying true. + debug!(config = %redact_env_for_debug(config), "wxc-exec phase"); let output = cmd.output().await?; let stdout = String::from_utf8_lossy(&output.stdout).into_owned(); @@ -406,7 +515,13 @@ impl WxcExecInvoker { cmd.arg("--debug"); } - debug!(config = %json, "wxc-exec provision"); + let redacted = redact_env_for_debug(&config); + if self.debug { + let pretty = serde_json::to_string_pretty(&redacted).unwrap_or_else(|_| json.clone()); + info!("generated wxc-config (provision):\n{pretty}"); + } else { + debug!(config = %redacted, "wxc-exec provision"); + } let output = cmd.output().await?; let stdout = String::from_utf8_lossy(&output.stdout).into_owned(); @@ -494,15 +609,26 @@ impl WxcExecInvoker { cmd.arg("--config-base64") .arg(&b64) .arg("--experimental") - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::inherit()) - .stderr(std::process::Stdio::inherit()) - .kill_on_drop(true); + // Piped (not null): mirrors the ProcessContainer one-shot spawn + // below -- with STDIO passthrough, wxc-exec forwards this handle + // down to the exec'd child, giving the driver a control channel + // into the isolation_session sandbox with no network capability + // required. Without this, pc_relay_spawner_path's control channel + // (and therefore dynamic `openshell forward service`) silently + // has nothing to attach to on this backend. + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); if self.debug { cmd.arg("--debug"); } - debug!(sandbox_id = %iso_sandbox_id, command = %process.command_line, "wxc-exec exec spawn"); + if self.debug { + let redacted = redact_env_for_debug(&config); + let pretty = serde_json::to_string_pretty(&redacted).unwrap_or_else(|_| json.clone()); + info!(sandbox_id = %iso_sandbox_id, "generated wxc-config (exec):\n{pretty}"); + } + info!(sandbox_id = %iso_sandbox_id, "wxc-exec exec spawn"); let child = cmd.spawn()?; Ok(child) } @@ -575,9 +701,16 @@ impl WxcExecInvoker { pc: MxcProcessContainer, process: MxcProcess, network: Option, + ui: Option, ) -> Result { - let config = - oneshot_config_json(container_id, &filesystem, &pc, &process, network.as_ref()); + let config = oneshot_config_json( + container_id, + &filesystem, + &pc, + &process, + network.as_ref(), + ui.as_ref(), + ); if self.mock { let grants: Vec = filesystem .readwrite_paths @@ -593,20 +726,49 @@ impl WxcExecInvoker { } let json = serde_json::to_string(&config)?; + if self.debug { + // Redacted before either sink: the readwrite path is inside the + // sandbox itself (readable by whatever untrusted code runs + // there), and gateway logs may have broader retention/access + // than the secrets in `process.env` (e.g. OPENCLAW_GATEWAY_TOKEN + // in the shipped OpenClaw example config) should get. + let redacted = redact_env_for_debug(&config); + let redacted_json = serde_json::to_string(&redacted).unwrap_or_else(|_| json.clone()); + // Dump into the first readwrite path for comparison. + if let Some(rw) = config + .get("filesystem") + .and_then(|f| f.get("readwritePaths")) + .and_then(|a| a.as_array()) + .and_then(|a| a.first()) + .and_then(|v| v.as_str()) + { + let _ = std::fs::write( + std::path::Path::new(rw).join("wxc-exec-config-debug.json"), + &redacted_json, + ); + } + let pretty = + serde_json::to_string_pretty(&redacted).unwrap_or_else(|_| redacted_json.clone()); + info!(container_id = %container_id, "generated wxc-config:\n{pretty}"); + } let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); let mut cmd = Command::new(&self.exec_path); cmd.arg("--config-base64") .arg(&b64) - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::inherit()) - .stderr(std::process::Stdio::inherit()) - .kill_on_drop(true); + // Piped (not null): with STDIO passthrough, wxc-exec forwards this + // handle down to the sandboxed child, giving the driver a write + // channel into the AppContainer with no network capability + // required at all -- see openshell-supervisor-relay's stdin/stdout + // JSON control protocol. + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); if self.debug { cmd.arg("--debug"); } - debug!(container_id = %container_id, command = %process.command_line, "wxc-exec one-shot processContainer spawn"); + info!(container_id = %container_id, "wxc-exec one-shot processContainer spawn"); let child = cmd.spawn()?; Ok(child) } @@ -745,7 +907,7 @@ mod tests { } #[test] - fn provision_config_json_includes_network_proxy_when_supplied() { + fn provision_config_json_includes_network_loopback_when_proxy_supplied() { let filesystem = MxcFilesystem { readwrite_paths: vec!["C:\\work\\demo".into()], readonly_paths: Vec::new(), @@ -754,39 +916,47 @@ mod tests { let network = MxcNetwork { default_policy: "block".into(), proxy: Some("127.0.0.1:18080".parse().unwrap()), + allow_local_network: false, }; let config = provision_config_json(DEFAULT_CONFIGURATION_ID, &filesystem, Some(&network)); - assert_eq!(config["network"]["defaultPolicy"], "block"); - assert!(config["network"].get("allowedHosts").is_none()); - assert!(config["network"].get("blockedHosts").is_none()); - // MXC 0.6.0-alpha accepts only {"proxy": {"localhost": N}}. - assert_eq!(config["network"]["proxy"]["localhost"], 18080); - assert!( - config["network"]["proxy"].get("host").is_none(), - "proxy must not contain 'host' key" - ); - assert!( - config["network"]["proxy"].get("port").is_none(), - "proxy must not contain 'port' key" + // With proxy: use direct loopback egress (127.0.0.1/32 allow) instead of + // runtimeConfig.networkProxy proxy mode, so relay can reach its spawned + // target process via loopback without processmodel.dll proxy-redirect WFP + // interference. PSEC tier still selected via requires_psec_networking(). + assert_eq!(config["network"]["egress"]["default"], "deny"); + assert_eq!( + config["network"]["egress"]["allow"][0]["to"][0]["cidr"], + "127.0.0.1/32" ); + assert_eq!(config["network"]["ingress"]["default"], "allow"); + assert_eq!(config["network"]["ingress"]["hostLoopback"], "allow"); + assert!(config["network"].get("defaultPolicy").is_none()); + assert!(config["network"].get("proxy").is_none()); + // No runtimeConfig.networkProxy — using direct loopback egress instead. + assert!(config.get("runtimeConfig").is_none()); } #[test] - fn network_json_emits_localhost_port_shape() { - // MXC 0.6.0-alpha rejects {"host":...,"port":...} and accepts only - // {"proxy": {"localhost": N}} — verified against the real binary via - // --dry-run. This test pins the exact emitted JSON shape. + fn network_json_emits_directional_format() { + // MXC 0.8.0-alpha: egress/ingress replaces the legacy + // defaultPolicy / allowedHosts / proxy.localhost shape. let network = MxcNetwork { default_policy: "block".into(), proxy: Some("127.0.0.1:18080".parse().unwrap()), + allow_local_network: false, }; let value = network_json(&network); - assert!(value.get("allowedHosts").is_none()); - assert!(value.get("blockedHosts").is_none()); - assert_eq!(value["proxy"]["localhost"], 18080); - assert!(value["proxy"].get("host").is_none()); - assert!(value["proxy"].get("port").is_none()); + // Loopback-allow mode: egress.default="deny" with 127.0.0.1/32 allow rule. + // Allows relay to reach both the spawned target (intra-container loopback) + // and the host relay listener (host loopback) without proxy-mode WFP issues. + assert_eq!(value["egress"]["default"], "deny"); + assert_eq!(value["egress"]["allow"][0]["to"][0]["cidr"], "127.0.0.1/32"); + // ingress.hostLoopback="allow" grants networkLoopback PSEC capability. + assert_eq!(value["ingress"]["default"], "allow"); + assert_eq!(value["ingress"]["hostLoopback"], "allow"); + assert!(value.get("proxy").is_none()); + assert!(value.get("defaultPolicy").is_none()); } #[test] @@ -803,9 +973,39 @@ mod tests { env: Vec::new(), timeout: 0, }; - let config = oneshot_config_json("sb-1", &filesystem, &pc, &process, None); + let config = oneshot_config_json("sb-1", &filesystem, &pc, &process, None, None); assert!(config.get("network").is_none()); + assert!(config.get("ui").is_none()); + } + + #[test] + fn oneshot_config_json_emits_typed_ui_policy() { + let filesystem = MxcFilesystem::default(); + let pc = MxcProcessContainer::default(); + let process = MxcProcess { + command_line: "cmd /c exit 0".into(), + cwd: "C:\\work\\demo".into(), + env: Vec::new(), + timeout: 0, + }; + let ui = MxcUi { + disable: false, + clipboard: MxcClipboardAccess::Write, + injection: true, + }; + let config = oneshot_config_json("sb-ui", &filesystem, &pc, &process, None, Some(&ui)); + + assert_eq!(config["ui"]["disable"], false); + assert_eq!(config["ui"]["clipboard"], "write"); + assert_eq!(config["ui"]["injection"], true); + } + + #[test] + fn isolation_provision_config_never_synthesizes_ui() { + let config = + provision_config_json(DEFAULT_CONFIGURATION_ID, &MxcFilesystem::default(), None); + assert!(config.get("ui").is_none()); } #[test] diff --git a/crates/openshell-driver-mxc/src/policy.rs b/crates/openshell-driver-mxc/src/policy.rs index 30a0ca56da..2a5c9883aa 100644 --- a/crates/openshell-driver-mxc/src/policy.rs +++ b/crates/openshell-driver-mxc/src/policy.rs @@ -19,6 +19,7 @@ use std::net::SocketAddr; +use crate::mxc::{MxcClipboardAccess, MxcUi}; use openshell_core::proto::SandboxPolicy; use thiserror::Error; @@ -38,6 +39,9 @@ pub struct MappedConfig { /// Loopback address MXC redirects sandbox egress to. `None` when governed /// egress is disabled. pub proxy_addr: Option, + /// Top-level MXC UI policy for process containers. Isolation sessions keep + /// this absent because current MXC rejects the section on presence. + pub ui: Option, } /// Context passed to the mapper alongside the policy. @@ -49,6 +53,8 @@ pub struct MapCtx { /// Pattern-C governed-egress redirect address. When set, the embedded /// mapper uses `split_policy`; otherwise it uses the coarse MXC map. pub egress: Option, + /// MXC containment backend selected by the live driver. + pub containment: String, } /// A policy rule that the active mapper cannot enforce. @@ -109,6 +115,39 @@ fn extract_paths(config: &serde_json::Value, key: &str) -> Vec { .unwrap_or_default() } +fn extract_ui(config: &serde_json::Value) -> Result, MapError> { + let Some(ui) = config.get("ui") else { + return Ok(None); + }; + let disable = ui["disable"] + .as_bool() + .ok_or_else(|| MapError::Internal("mapped MXC ui.disable is not a boolean".into()))?; + let clipboard = match ui["clipboard"].as_str() { + Some("none") => MxcClipboardAccess::None, + Some("read") => MxcClipboardAccess::Read, + Some("write") => MxcClipboardAccess::Write, + Some("all") => MxcClipboardAccess::All, + Some(value) => { + return Err(MapError::Internal(format!( + "mapped MXC ui.clipboard has unknown value '{value}'" + ))); + } + None => { + return Err(MapError::Internal( + "mapped MXC ui.clipboard is not a string".into(), + )); + } + }; + let injection = ui["injection"] + .as_bool() + .ok_or_else(|| MapError::Internal("mapped MXC ui.injection is not a boolean".into()))?; + Ok(Some(MxcUi { + disable, + clipboard, + injection, + })) +} + impl PolicyMapper for EmbeddedPolicyMapper { fn map(&self, policy: Option<&SandboxPolicy>, ctx: &MapCtx) -> Result { let policy = policy.ok_or_else(|| { @@ -122,7 +161,7 @@ impl PolicyMapper for EmbeddedPolicyMapper { // Pattern C: MXC handles filesystem + a proxy redirect, while the // host CONNECT proxy receives the network-only trimmed policy. let opts = crate::policy_map::MxcMappingOptions { - containment: "processcontainer".to_owned(), + containment: ctx.containment.clone(), container_id: ctx.sandbox_id.clone(), proxy_redirect: Some(addr), ..Default::default() @@ -144,7 +183,7 @@ impl PolicyMapper for EmbeddedPolicyMapper { // yields an `error` loss for any host allowlist, which rejects // network policy below. let opts = crate::policy_map::MxcMappingOptions { - containment: "isolation_session".to_owned(), + containment: ctx.containment.clone(), container_id: ctx.sandbox_id.clone(), ..Default::default() }; @@ -176,12 +215,14 @@ impl PolicyMapper for EmbeddedPolicyMapper { .iter() .map(|p| normalize_path(p)) .collect(); + let ui = extract_ui(&config)?; Ok(MappedConfig { readwrite_paths: readwrite, readonly_paths: readonly, trimmed_policy, proxy_addr, + ui, }) } } @@ -197,6 +238,15 @@ mod tests { MapCtx { sandbox_id: "sb-test".into(), egress: None, + containment: "isolation_session".into(), + } + } + + fn processcontainer_ctx() -> MapCtx { + MapCtx { + sandbox_id: "sb-test".into(), + egress: None, + containment: "processcontainer".into(), } } @@ -255,6 +305,78 @@ mod tests { assert!(matches!(err, MapError::Unsupported(_))); } + #[test] + fn embedded_rejects_explicit_ui_on_isolation_session() { + use openshell_core::proto::UiPolicy; + + let mapper = EmbeddedPolicyMapper; + let policy = SandboxPolicy { + ui: Some(UiPolicy::default()), + ..Default::default() + }; + let err = mapper.map(Some(&policy), &demo_ctx()).unwrap_err(); + match err { + MapError::Unsupported(items) => { + assert_eq!(items.len(), 1); + assert_eq!(items[0].rule_kind, "ui"); + } + MapError::Internal(message) => { + panic!("expected unsupported UI, got internal error: {message}") + } + } + } + + #[test] + fn embedded_carries_typed_ui_for_processcontainer() { + use openshell_core::proto::{UiClipboardAccess, UiPolicy}; + + let mapper = EmbeddedPolicyMapper; + let policy = SandboxPolicy { + ui: Some(UiPolicy { + allow_graphical_ui: true, + clipboard: UiClipboardAccess::Read as i32, + allow_input_injection: true, + }), + ..Default::default() + }; + let result = mapper + .map(Some(&policy), &processcontainer_ctx()) + .expect("processContainer UI maps"); + assert_eq!( + result.ui, + Some(MxcUi { + disable: false, + clipboard: MxcClipboardAccess::Read, + injection: true, + }) + ); + } + + #[test] + fn embedded_rejects_ui_grants_suppressed_by_disable() { + use openshell_core::proto::{UiClipboardAccess, UiPolicy}; + + for ui in [ + UiPolicy { + clipboard: UiClipboardAccess::Read as i32, + ..Default::default() + }, + UiPolicy { + allow_input_injection: true, + ..Default::default() + }, + ] { + let policy = SandboxPolicy { + ui: Some(ui), + ..Default::default() + }; + let error = EmbeddedPolicyMapper + .map(Some(&policy), &processcontainer_ctx()) + .expect_err("MXC cannot enforce UI grants while UI is disabled"); + assert!(matches!(error, MapError::Unsupported(_))); + } + } + #[test] fn embedded_split_normalizes_paths_and_returns_proxy_handoff() { use openshell_core::proto::{NetworkBinary, NetworkEndpoint, NetworkPolicyRule}; @@ -281,6 +403,7 @@ mod tests { sandbox_id: "sb-egress".into(), egress: Some(proxy_addr), + containment: "processcontainer".into(), }; let config = mapper.map(Some(&policy), &ctx).unwrap(); @@ -313,6 +436,7 @@ mod tests { let ctx = MapCtx { sandbox_id: "sb-egress-middleware".into(), egress: Some("127.0.0.1:18080".parse().unwrap()), + containment: "processcontainer".into(), }; let error = mapper.map(Some(&policy), &ctx).unwrap_err(); diff --git a/crates/openshell-driver-mxc/src/policy_map/loss.rs b/crates/openshell-driver-mxc/src/policy_map/loss.rs index cf366b0b9d..6af3b9a32b 100644 --- a/crates/openshell-driver-mxc/src/policy_map/loss.rs +++ b/crates/openshell-driver-mxc/src/policy_map/loss.rs @@ -25,7 +25,7 @@ pub struct LossItem { /// MXC capabilities that have no `OpenShell` *policy* equivalent. Surfaced in the /// loss report so reviewers understand the mapping is not symmetric. pub const OPEN_SHELL_SUPERSET_GAPS: &[&str] = &[ - "MXC UI policy has no OpenShell policy equivalent: ui.disable, ui.clipboard, and ui.injection.", + "MXC processContainer UI refinements have no portable OpenShell policy equivalent: isolation, desktopSystemControl, systemSettings, and ime.", "MXC lifecycle fields have no OpenShell policy equivalent: destroyOnExit, preservePolicy, phase, and sandboxId.", "MXC backend selection and backend-specific blocks are outside OpenShell policy YAML.", "MXC process command, cwd, env, and timeout are runtime config fields, not OpenShell policy fields.", diff --git a/crates/openshell-driver-mxc/src/policy_map/map.rs b/crates/openshell-driver-mxc/src/policy_map/map.rs index 4bfcc316a8..4af3290715 100644 --- a/crates/openshell-driver-mxc/src/policy_map/map.rs +++ b/crates/openshell-driver-mxc/src/policy_map/map.rs @@ -11,7 +11,9 @@ use std::net::SocketAddr; -use openshell_core::proto::{NetworkEndpoint, NetworkPolicyRule, SandboxPolicy}; +use openshell_core::proto::{ + NetworkEndpoint, NetworkPolicyRule, SandboxPolicy, UiClipboardAccess, UiPolicy, +}; use serde_json::{Value, json}; use super::config::{ @@ -19,12 +21,14 @@ use super::config::{ add_backend_specific_config, default_enforcement_mode, filesystem_default_deny_message, }; use super::loss::{LossItem, add_loss}; +use crate::mxc::MXC_SCHEMA_VERSION; /// Options controlling the generated MXC config. Fields not relevant to the /// coarse map (e.g. `proxy_redirect`) are reserved for the governed-egress split. #[derive(Clone, Debug)] pub struct MxcMappingOptions { - /// MXC schema version written into `version`. + /// MXC schema version written into coarse-map output. The governed-egress + /// split uses the driver's MXC 0.8 schema. pub mxc_version: String, /// MXC containment backend. pub containment: String, @@ -68,16 +72,15 @@ pub struct MxcMappingResult { pub loss: Vec, } -/// Result of the governed-egress split: the MXC config carries filesystem grants and a -/// proxy redirect; the full network policy is returned unchanged for the -/// `OpenShell` CONNECT proxy to enforce. +/// Result of the lossless split: the MXC config carries filesystem grants and +/// loopback-only network access; the full network policy is returned unchanged +/// for the `OpenShell` CONNECT proxy to enforce. #[derive(Clone, Debug)] pub struct SplitPolicyResult { - /// MXC `ContainerConfig` with filesystem grants and `network.proxy` redirect. + /// MXC `ContainerConfig` with filesystem grants and loopback-only egress. /// - /// Direct egress is blocked at the MXC layer. Unsupported host-list fields - /// are omitted; all outbound connections flow through the proxy, which - /// enforces the full `OpenShell` network policy. + /// Direct Internet egress is denied at the MXC layer. Proxy-aware clients + /// use the host proxy, which enforces the full `OpenShell` network policy. pub mxc_config: Value, /// Full `OpenShell` network policy preserved verbatim for the host CONNECT /// proxy. Only `network_policies` is populated; the proxy does not enforce @@ -98,13 +101,12 @@ pub fn map_to_mxc(policy: &SandboxPolicy, opts: &MxcMappingOptions) -> MxcMappin /// Governed-egress split: map filesystem + containment to MXC, delegate network to the /// `OpenShell` CONNECT proxy. /// -/// The returned [`SplitPolicyResult::mxc_config`] sets `network.proxy` to -/// `opts.proxy_redirect` and omits unsupported host-list fields. Direct egress -/// is blocked at the MXC layer and all outbound connections flow through the -/// proxy. [`SplitPolicyResult::proxy_policy`] carries the original -/// `network_policies` verbatim; no binary-scope, port, protocol, or wildcard -/// loss items are generated for those rules. Network middleware is rejected -/// until the host proxy can receive the gateway middleware service registry. +/// The returned [`SplitPolicyResult::mxc_config`] allows only `127.0.0.1/32` +/// egress and denies direct Internet access at the MXC layer. The driver injects +/// `HTTP_PROXY`/`HTTPS_PROXY` for proxy-aware clients. +/// [`SplitPolicyResult::proxy_policy`] carries the original +/// `network_policies` verbatim. Network middleware remains rejected until the +/// host proxy can receive the gateway middleware service registry. /// /// Returns `None` if `opts.proxy_redirect` is not set. Use [`map_to_mxc`] /// for the standalone coarse path when no proxy is in the loop. @@ -151,11 +153,11 @@ fn build_split_mxc_config( "containment", "error", &format!( - "`network.proxy` is not supported on `{}`; governed egress requires processcontainer until MXC M1 lands.", + "MXC loopback-only proxy access is not supported on `{}`; governed egress requires processcontainer.", opts.containment ), "governed egress proxy redirect", - "The generated MXC config omits network.proxy for this backend.", + "The generated MXC config cannot enable loopback-only proxy access for this backend.", ); } if !policy.network_policies.is_empty() { @@ -185,34 +187,34 @@ fn build_split_mxc_config( ); } - // Direct egress is blocked; all outbound flows through the OpenShell proxy. - // Released wxc-exec rejects allowedHosts and blockedHosts as unsupported, - // even when empty, so the proxy path omits both fields. - // - // MXC 0.6.0-alpha schema accepts ONLY {"proxy": {"localhost": }}. - // {"host": ..., "port": ...} and every other shape is rejected — verified - // empirically against the real wxc-exec 0.6.0-alpha binary via --dry-run. + // Direct Internet egress is denied. Proxy-aware clients can reach only the + // OpenShell proxy (and other host loopback listeners) through 127.0.0.1. if proxy_supported && proxy_addr.ip() != std::net::IpAddr::from([127, 0, 0, 1]) { add_loss( items, - "network.proxy", + "proxy_redirect", "error", &format!( - "MXC schema 0.6.0-alpha can only express a localhost port \ - ({{\"localhost\": N}}); non-127.0.0.1 redirect address \ - {proxy_addr} is not representable." + "MXC governed egress requires the unpackaged OpenShell host \ + proxy to use 127.0.0.1; redirect address {proxy_addr} is not supported." ), "per-sandbox egress attribution", - "The redirect cannot be emitted; use a 127.0.0.1:PORT address.", + "The proxy environment cannot be emitted safely; use a 127.0.0.1:PORT address.", ); } - let mut network = json!({ "defaultPolicy": "block" }); - if proxy_supported && proxy_addr.ip() == std::net::IpAddr::from([127, 0, 0, 1]) { - network["proxy"] = json!({ "localhost": proxy_addr.port() }); - } + let network = json!({ + "egress": { + "default": "deny", + "allow": [{"to": [{"cidr": "127.0.0.1/32"}]}], + }, + "ingress": { + "default": "allow", + "hostLoopback": "allow", + }, + }); let mut config = json!({ - "version": opts.mxc_version, + "version": MXC_SCHEMA_VERSION, "containerId": opts.container_id, "containment": opts.containment, "lifecycle": { @@ -222,12 +224,10 @@ fn build_split_mxc_config( "process": process, "filesystem": filesystem, "network": network, - "ui": { - "disable": true, - "clipboard": "none", - "injection": false, - }, }); + if let Some(ui) = map_ui(policy.ui.as_ref(), &opts.containment, items) { + config["ui"] = ui; + } // No network hosts, so backend-specific network blocks (processContainer // internetClient, etc.) are not added — correct for the proxy path. @@ -275,18 +275,110 @@ fn build_mxc_config( "process": process, "filesystem": filesystem, "network": network, - "ui": { - "disable": true, - "clipboard": "none", - "injection": false, - }, }); + if let Some(ui) = map_ui(policy.ui.as_ref(), &opts.containment, items) { + config["ui"] = ui; + } add_backend_specific_config(&mut config, &opts.containment, &allowed_hosts, items); add_static_policy_loss(policy, opts, items); config } +fn map_ui(ui: Option<&UiPolicy>, containment: &str, items: &mut Vec) -> Option { + let restrictive = || { + json!({ + "disable": true, + "clipboard": "none", + "injection": false, + }) + }; + + match containment { + "processcontainer" | "process" => { + let Some(ui) = ui else { + // Preserve the mapper's existing deny posture for policies + // authored before the optional OpenShell UI section existed. + return Some(restrictive()); + }; + let clipboard = match UiClipboardAccess::try_from(ui.clipboard) { + Ok(UiClipboardAccess::Unspecified | UiClipboardAccess::None) => "none", + Ok(UiClipboardAccess::Read) => "read", + Ok(UiClipboardAccess::Write) => "write", + Ok(UiClipboardAccess::All) => "all", + Err(_) => { + add_loss( + items, + "ui.clipboard", + "error", + &format!( + "OpenShell UI clipboard policy has unknown enum value {}.", + ui.clipboard + ), + "directional clipboard access", + "MXC receives the restrictive clipboard=none fallback; sandbox creation is rejected.", + ); + "none" + } + }; + let graphical_ui_disabled = !ui.allow_graphical_ui; + if graphical_ui_disabled && clipboard != "none" { + add_loss( + items, + "ui.clipboard", + "error", + "MXC ignores clipboard grants when ui.disable is true.", + "directional clipboard access without graphical UI", + "MXC receives clipboard=none and sandbox creation is rejected; set allow_graphical_ui=true to request clipboard access.", + ); + } + if graphical_ui_disabled && ui.allow_input_injection { + add_loss( + items, + "ui.allow_input_injection", + "error", + "MXC ignores input-injection grants when ui.disable is true.", + "input injection without graphical UI", + "MXC receives injection=false and sandbox creation is rejected; set allow_graphical_ui=true to request input injection.", + ); + } + Some(json!({ + "disable": graphical_ui_disabled, + "clipboard": if graphical_ui_disabled { "none" } else { clipboard }, + "injection": !graphical_ui_disabled && ui.allow_input_injection, + })) + } + "isolation_session" => { + if ui.is_some() { + add_loss( + items, + "ui", + "error", + "MXC isolation_session rejects every explicitly supplied top-level UI policy, including an empty or deny-only policy.", + "OpenShell UI policy", + "The UI block is omitted and sandbox creation is rejected before wxc-exec is invoked.", + ); + } + None + } + _ => { + if ui.is_some() { + add_loss( + items, + "ui", + "error", + &format!( + "OpenShell UI policy enforcement is not supported by the MXC `{containment}` mapping target." + ), + "OpenShell UI policy", + "The generated config remains at the mapper's restrictive UI defaults and the caller must reject the mapping.", + ); + } + Some(restrictive()) + } + } +} + fn map_filesystem( policy: &SandboxPolicy, opts: &MxcMappingOptions, diff --git a/crates/openshell-driver-mxc/src/policy_map/mod.rs b/crates/openshell-driver-mxc/src/policy_map/mod.rs index 156cd8ea7a..670e31a617 100644 --- a/crates/openshell-driver-mxc/src/policy_map/mod.rs +++ b/crates/openshell-driver-mxc/src/policy_map/mod.rs @@ -18,8 +18,8 @@ //! and anything MXC cannot express (ports, protocol, L7 rules, binary scope) //! is recorded in the loss report. Use this when MXC enforces network on its //! own, with no `OpenShell` proxy in the loop. -//! - [`split_policy`] — the governed-egress split for the Windows MXC compute -//! driver: MXC handles filesystem + containment + a `network.proxy` redirect, +//! - [`split_policy`] — the *lossless* split for the Windows MXC compute +//! driver: MXC handles filesystem + containment + loopback-only egress, //! while the full `OpenShell` network policy is preserved in a trimmed policy //! enforced by the host CONNECT proxy. //! diff --git a/crates/openshell-driver-mxc/src/relay.rs b/crates/openshell-driver-mxc/src/relay.rs new file mode 100644 index 0000000000..da0a000188 --- /dev/null +++ b/crates/openshell-driver-mxc/src/relay.rs @@ -0,0 +1,686 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! WebSocket relay embedded in the gateway for MXC `ProcessContainer` sandboxes. +//! +//! When `egress_proxy = true` the `AppContainer` has outbound TCP via the +//! `OpenShell` host CONNECT proxy. The driver binds a relay listener on demand +//! (`start_relay`, e.g. from `ForwardSink::open_dynamic_forward`) and tells +//! the in-sandbox spawner its address over the stdin/stdout control channel; +//! the spawner connects outward to it as a WebSocket CLIENT (Phase A). Host +//! clients connect as raw TCP (Phase B); the relay tunnels their bytes +//! through Phase A so the in-sandbox agent can pipe them directly to the +//! target service. Each relay is per-request and short-lived — bound fresh +//! for each `openshell forward service` call, torn down when that forward +//! ends. +//! +//! ```text +//! host TCP client -> relay (gateway, raw TCP accept) +//! | tunnel via Phase A WS +//! sandbox agent -> local service (openclaw:18889) +//! ``` +//! +//! The listener is bound to the host's route-selected IPv4 interface rather +//! than loopback. `AppContainer` fallback does not map its `127.0.0.1` to the +//! host, so a loopback listener is unreachable unless traffic is sent through +//! the CONNECT proxy; that proxy can also capture the bridge's separate +//! sandbox-local target connection. Binding one concrete host interface keeps +//! the target hop on sandbox loopback and lets `allowLocalNetwork` authorize +//! only the host callback. In principle another host or local process could +//! race to connect before the real Phase A/B peer does and +//! hijack or inject traffic into the forward. Both phases are authenticated +//! against a fresh, unguessable per-forward nonce (`ForwardSink:: +//! open_dynamic_forward` generates it) instead of trusting connection order: +//! +//! Phase A (sandbox spawner, WS client) must send `TEXT "AUTH:"` as its first message, before anything else is +//! accepted from that connection -- see openshell-supervisor- +//! relay's `run_relay_bridge`, which sends this immediately +//! after connecting. +//! Phase B (host client, raw TCP -- normally the gateway process itself, +//! connecting right after `open_dynamic_forward` returns) must +//! write the raw nonce bytes as the first bytes on the +//! connection, before any tunneled application data -- see +//! openshell-server's `ForwardTcp` handler. +//! +//! A connection that fails or times out on this check is closed and the +//! relay keeps waiting for the real peer, rather than treating the first +//! comer as authoritative or tearing the whole relay down (a wrong guess +//! shouldn't be a viable way to deny service to the real caller either). +//! +//! Protocol over Phase A (WS connection from sandbox to relay), after auth: +//! TEXT "`SESSION_START`" — relay opened a Phase B TCP connection +//! BINARY — bytes from Phase B TCP stream +//! TEXT "`SESSION_END`" — Phase B TCP connection closed +//! WS Close — relay shutting down (`delete_sandbox` or error) +//! +//! Phase B (host client), after the nonce prefix, is a plain byte stream — +//! no WS handshake — so the client's full byte stream (including any WS +//! upgrade request and frames) is tunneled transparently to the in-sandbox +//! service from that point on. + +use crate::control_channel::ControlChannel; +use base64::Engine; +use futures::{SinkExt, StreamExt}; +use openshell_core::net::set_tcp_nodelay_best_effort; +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::Duration; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; +use tokio::sync::oneshot; +use tokio_tungstenite::{accept_async, tungstenite::Message}; +use tracing::{info, warn}; + +/// Length in bytes of the per-forward auth nonce (see module docs). Must +/// match `NONCE_LEN` in `driver.rs` (which generates it) and +/// `openshell-supervisor-relay`'s copy (which echoes it back on Phase A) -- +/// duplicated rather than shared via a common crate, matching how the rest +/// of this wire protocol (e.g. the "`SESSION_START`"/"`SESSION_END`" literals) +/// is already duplicated across the two sides. +pub const NONCE_LEN: usize = 32; + +/// How long to wait for a freshly-accepted connection to present its auth +/// nonce before giving up on it and going back to waiting for the real peer. +/// Generous: this is a local host round-trip, but a slow/malicious +/// connector shouldn't be able to stall the relay for the legitimate peer +/// for long either. +const AUTH_TIMEOUT: Duration = Duration::from_secs(5); + +/// Select the concrete host IPv4 address used to reach the machine's default +/// route. UDP `connect` performs route selection without sending a packet, so +/// this does not depend on the probe endpoint being reachable. Binding the +/// relay to that exact interface avoids exposing it on every interface while +/// still making it reachable from an `AppContainer` whose loopback is isolated +/// from the host's loopback. +/// Fixed-time byte comparison so a wrong guess doesn't leak how many +/// leading bytes it got right via response timing. The nonce is one-shot +/// (a fresh relay per forward) so this is defense in depth rather than +/// closing a practically exploitable channel, but it's free. +fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { + if a.len() != b.len() { + return false; + } + a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0 +} + +/// Hex-encode `bytes` (lowercase, unpadded). `pub` (crate-visible in +/// practice, since `relay` isn't a `pub mod`) so `ForwardSink:: +/// open_dynamic_forward` in `driver.rs` can use the exact same encoding to +/// build the "forward" control-channel request's `nonce` field that this +/// module expects back from the sandbox on Phase A. +pub fn encode_hex(bytes: &[u8]) -> String { + use std::fmt::Write; + bytes + .iter() + .fold(String::with_capacity(bytes.len() * 2), |mut s, b| { + let _ = write!(s, "{b:02x}"); + s + }) +} + +/// Render the first `n` bytes of `data` as a printable-ASCII preview +/// (non-printable bytes shown as `.`), for hop-by-hop diagnostic logging. +/// Not a general-purpose formatter -- just enough to eyeball whether e.g. an +/// HTTP/WS handshake looks intact versus corrupted or empty. +/// +/// Not called anywhere: forwarded traffic can carry auth headers, cookies, or +/// other sensitive payload, and this relay's own logs are gateway logs, so no +/// byte preview is ever logged, at any level. Kept only so a future opt-in +/// diagnostic mode has a ready-made (still-redaction-worthy) formatter to +/// start from. +#[allow(dead_code)] +fn byte_preview(data: &[u8]) -> String { + const MAX: usize = 120; + let n = data.len().min(MAX); + let mut s: String = data[..n] + .iter() + .map(|&b| { + if b.is_ascii_graphic() || b == b' ' { + b as char + } else { + '.' + } + }) + .collect(); + if data.len() > MAX { + s.push_str("..."); + } + s +} + +// ── Public handle ───────────────────────────────────────────────────────────── + +/// Owned handle returned by [`start_relay`]. Drop or call [`stop`] to +/// shut down the relay task and release the listener port. +pub struct RelayHandle { + shutdown_tx: oneshot::Sender<()>, +} + +impl RelayHandle { + pub fn stop(self) { + let _ = self.shutdown_tx.send(()); + } +} + +// ── Entry point ─────────────────────────────────────────────────────────────── + +/// Bind a TCP listener on `bind_addr` and spawn the relay task. The caller +/// communicates the returned address (and `nonce`) to the sandbox directly +/// (over the control channel) — this doesn't touch the filesystem at all. +/// `nonce` must be freshly generated per call (see module docs) — it's what +/// lets this host-interface relay tell the real Phase A/B peers apart from +/// any other local process that might race to connect first. +pub async fn start_relay( + bind_addr: SocketAddr, + sandbox_name: String, + nonce: [u8; NONCE_LEN], +) -> std::io::Result<(RelayHandle, SocketAddr)> { + let listener = TcpListener::bind(bind_addr).await?; + let actual = listener.local_addr()?; + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + tokio::spawn(relay_task( + listener, + sandbox_name.clone(), + nonce, + shutdown_rx, + )); + info!(sandbox = %sandbox_name, relay = %actual, "MXC relay started"); + Ok((RelayHandle { shutdown_tx }, actual)) +} + +/// Start a host-loopback listener whose sandbox leg is multiplexed over the +/// inherited stdin/stdout control channel. Unlike [`start_relay`], this path +/// never asks the `AppContainer` to connect back to a host network address. +pub async fn start_control_channel_relay( + bind_addr: SocketAddr, + sandbox_name: String, + nonce: [u8; NONCE_LEN], + control_channel: Arc, + target_port: u16, +) -> std::io::Result<(RelayHandle, SocketAddr)> { + let listener = TcpListener::bind(bind_addr).await?; + let actual = listener.local_addr()?; + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + tokio::spawn(control_channel_relay_task( + listener, + sandbox_name.clone(), + nonce, + control_channel, + target_port, + shutdown_rx, + )); + info!(sandbox = %sandbox_name, relay = %actual, "MXC control-channel relay started"); + Ok((RelayHandle { shutdown_tx }, actual)) +} + +async fn control_channel_relay_task( + listener: TcpListener, + sandbox_name: String, + nonce: [u8; NONCE_LEN], + control_channel: Arc, + target_port: u16, + mut shutdown_rx: oneshot::Receiver<()>, +) { + loop { + let mut host_stream = tokio::select! { + result = listener.accept() => match result { + Ok((stream, addr)) => { + info!(sandbox = %sandbox_name, %addr, "MXC control-channel relay: host client connected"); + set_tcp_nodelay_best_effort(&stream); + stream + } + Err(error) => { + warn!(sandbox = %sandbox_name, "MXC control-channel relay listener error: {error}"); + return; + } + }, + _ = &mut shutdown_rx => return, + }; + + let mut auth_buf = [0_u8; NONCE_LEN]; + match tokio::time::timeout(AUTH_TIMEOUT, host_stream.read_exact(&mut auth_buf)).await { + Ok(Ok(_)) if constant_time_eq(&auth_buf, &nonce) => {} + Ok(Ok(_) | Err(_)) | Err(_) => continue, + } + + let session_id = encode_hex(&rand::random::<[u8; NONCE_LEN]>()); + let open = control_channel + .request( + "forward_open", + serde_json::json!({"session_id": session_id, "target_port": target_port}), + Duration::from_secs(10), + ) + .await; + if !control_response_ok(&open) { + warn!(sandbox = %sandbox_name, "MXC control-channel relay: sandbox rejected session open"); + continue; + } + + let (mut host_read, mut host_write) = host_stream.into_split(); + let mut host_buf = vec![0_u8; 8192]; + let mut host_to_sandbox_bytes = 0_u64; + let mut sandbox_to_host_bytes = 0_u64; + let mut shutting_down = false; + loop { + tokio::select! { + result = host_read.read(&mut host_buf) => match result { + Ok(0) | Err(_) => break, + Ok(n) => { + let bytes = base64::engine::general_purpose::STANDARD.encode(&host_buf[..n]); + let response = control_channel.request( + "forward_write", + serde_json::json!({"session_id": session_id, "bytes": bytes}), + Duration::from_secs(10), + ).await; + if !control_response_ok(&response) { + break; + } + host_to_sandbox_bytes += n as u64; + } + }, + response = control_channel.request( + "forward_read", + serde_json::json!({"session_id": session_id}), + Duration::from_secs(10), + ) => { + let Ok(response) = response else { break }; + if response.get("ok").and_then(serde_json::Value::as_bool) != Some(true) { + break; + } + let data = response.get("data").unwrap_or(&serde_json::Value::Null); + if data.get("eof").and_then(serde_json::Value::as_bool) == Some(true) { + break; + } + let Some(encoded) = data.get("bytes").and_then(serde_json::Value::as_str) else { + break; + }; + if !encoded.is_empty() { + let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(encoded) else { + break; + }; + if host_write.write_all(&bytes).await.is_err() { + break; + } + sandbox_to_host_bytes += bytes.len() as u64; + } + }, + _ = &mut shutdown_rx => { + shutting_down = true; + break; + }, + } + } + let _ = control_channel + .request( + "forward_close", + serde_json::json!({"session_id": session_id}), + Duration::from_secs(5), + ) + .await; + if shutting_down { + return; + } + info!( + sandbox = %sandbox_name, + host_to_sandbox_bytes, + sandbox_to_host_bytes, + "MXC control-channel relay: host client disconnected" + ); + } +} + +fn control_response_ok( + response: &Result, +) -> bool { + matches!( + response, + Ok(value) if value.get("ok").and_then(serde_json::Value::as_bool) == Some(true) + ) +} + +async fn relay_task( + listener: TcpListener, + sandbox_name: String, + nonce: [u8; NONCE_LEN], + mut shutdown_rx: oneshot::Receiver<()>, +) { + let expected_auth = format!("AUTH:{}", encode_hex(&nonce)); + + // Phase A: wait for the sandbox agent's outbound WS connection, and + // require it to prove it's the real peer (see module docs) before + // trusting anything else from it. A connection that fails or times out + // on this is closed; the relay keeps waiting rather than accepting the + // first comer or giving up entirely. + let mut sandbox_ws = loop { + tokio::select! { + result = listener.accept() => match result { + Ok((stream, addr)) => { + // Latency-sensitive request/response tunnel, including on + // a same-host interface -- small WS frames can otherwise + // stall behind delayed ACK behavior. Best-effort, before + // the WS upgrade so it applies to the whole connection. + set_tcp_nodelay_best_effort(&stream); + match tokio::time::timeout(AUTH_TIMEOUT, accept_async(stream)).await { + Ok(Ok(mut ws)) => { + match tokio::time::timeout(AUTH_TIMEOUT, ws.next()).await { + Ok(Some(Ok(Message::Text(t)))) + if constant_time_eq(t.as_bytes(), expected_auth.as_bytes()) => + { + info!(sandbox = %sandbox_name, %addr, "MXC relay: sandbox connected (authenticated)"); + break ws; + } + Ok(Some(Ok(_))) => { + warn!(sandbox = %sandbox_name, %addr, + "MXC relay: sandbox WS auth message did not match; closing and continuing to wait"); + let _ = ws.close(None).await; + } + Ok(_) => { + warn!(sandbox = %sandbox_name, %addr, + "MXC relay: sandbox WS closed/errored before authenticating; continuing to wait"); + } + Err(_) => { + warn!(sandbox = %sandbox_name, %addr, + "MXC relay: sandbox WS auth timed out; closing and continuing to wait"); + let _ = ws.close(None).await; + } + } + } + Ok(Err(e)) => warn!(sandbox = %sandbox_name, %addr, + "MXC relay: sandbox WS handshake failed: {e}"), + Err(_) => warn!(sandbox = %sandbox_name, %addr, + "MXC relay: sandbox WS handshake timed out; continuing to wait"), + } + } + Err(e) => { + warn!(sandbox = %sandbox_name, "MXC relay: listener error: {e}"); + return; + } + }, + _ = &mut shutdown_rx => { + info!(sandbox = %sandbox_name, "MXC relay: shutdown before sandbox connected"); + return; + } + } + }; + + // Phase B: accept raw TCP host clients one at a time and tunnel their + // byte stream through the Phase A WS connection. + loop { + let mut host_stream = tokio::select! { + result = listener.accept() => match result { + Ok((stream, addr)) => { + info!(sandbox = %sandbox_name, %addr, "MXC relay: host client connected"); + // See the matching comment on the Phase A accept above. + set_tcp_nodelay_best_effort(&stream); + stream + } + Err(e) => { + warn!(sandbox = %sandbox_name, "MXC relay: listener error: {e}"); + return; + } + }, + _ = &mut shutdown_rx => { + info!(sandbox = %sandbox_name, "MXC relay: shutdown"); + // Send a proper WS Close frame instead of just dropping the + // connection -- otherwise the sandbox sees an abrupt TCP + // reset ("Connection reset without closing handshake") + // instead of a clean close, even though nothing actually + // went wrong. + let _ = sandbox_ws.close(None).await; + return; + } + }; + + // Authenticate before treating this as the real host client (see + // module docs): the raw nonce bytes must arrive first, ahead of any + // tunneled application data. A mismatch or timeout closes this + // connection and goes back to waiting for the next accept -- it + // must never fall through to tunneling a stranger's traffic. + let mut auth_buf = [0u8; NONCE_LEN]; + match tokio::time::timeout(AUTH_TIMEOUT, host_stream.read_exact(&mut auth_buf)).await { + Ok(Ok(_)) if constant_time_eq(&auth_buf, &nonce) => {} + Ok(Ok(_)) => { + warn!(sandbox = %sandbox_name, + "MXC relay: host client auth bytes did not match; closing and continuing to wait"); + continue; + } + Ok(Err(e)) => { + warn!(sandbox = %sandbox_name, + "MXC relay: host client closed/errored before authenticating: {e}"); + continue; + } + Err(_) => { + warn!(sandbox = %sandbox_name, + "MXC relay: host client auth timed out; closing and continuing to wait"); + continue; + } + } + + let (mut host_read, mut host_write) = host_stream.into_split(); + + // Notify in-sandbox agent that a new session is starting. + if sandbox_ws + .send(Message::Text("SESSION_START".into())) + .await + .is_err() + { + info!(sandbox = %sandbox_name, "MXC relay: sandbox gone at session start"); + return; + } + + // Bridge until Phase B or Phase A closes. Byte counters + first-chunk + // size events exist purely for diagnosing WHERE in the hop chain + // (host <-> this relay <-> Phase A WS <-> sandbox <-> target) bytes + // stop flowing, since a silent drop anywhere looks identical from the + // outside (client just times out) without this instrumentation. No + // payload content is ever logged -- see the module-level note on + // `byte_preview`. + let mut buf = vec![0u8; 8192]; + let mut host_to_sandbox_bytes: u64 = 0; + let mut sandbox_to_host_bytes: u64 = 0; + let mut host_to_sandbox_chunks: u64 = 0; + let mut sandbox_to_host_chunks: u64 = 0; + let sandbox_gone = loop { + tokio::select! { + // Phase B → Phase A: TCP bytes wrapped as WS Binary. + result = host_read.read(&mut buf) => match result { + Ok(0) => break false, // Phase B EOF + Ok(n) => { + host_to_sandbox_chunks += 1; + host_to_sandbox_bytes += n as u64; + if host_to_sandbox_chunks == 1 { + info!(sandbox = %sandbox_name, bytes = n, "MXC relay: first host->sandbox chunk"); + } + if sandbox_ws + .send(Message::Binary(buf[..n].to_vec().into())) + .await + .is_err() + { + break true; // Phase A gone + } + } + Err(e) => { + warn!(sandbox = %sandbox_name, "MXC relay: host read error: {e}"); + break false; + } + }, + // Phase A → Phase B: WS Binary bytes written to TCP. + msg = sandbox_ws.next() => match msg { + Some(Ok(Message::Binary(b))) => { + sandbox_to_host_chunks += 1; + sandbox_to_host_bytes += b.len() as u64; + if sandbox_to_host_chunks == 1 { + info!(sandbox = %sandbox_name, bytes = b.len(), "MXC relay: first sandbox->host chunk"); + } + if host_write.write_all(&b).await.is_err() { + break false; // Phase B gone + } + } + Some(Ok(Message::Text(t))) => { + if let Some(reason) = t.strip_prefix("SESSION_FAILED:") { + // Sandbox couldn't reach the target port. Close + // Phase B now instead of leaving the host client + // hanging until its own timeout. + warn!(sandbox = %sandbox_name, reason, + "MXC relay: sandbox failed to connect to target"); + break false; + } else if t == "SESSION_END" { + // Target closed its end (EOF) -- see + // openshell-supervisor-relay's matching send on + // Ok(0). Close Phase B now instead of leaving + // the host client waiting for bytes that will + // never arrive until its own timeout. + info!(sandbox = %sandbox_name, "MXC relay: target closed its end of the session"); + break false; + } + // otherwise ignore (shouldn't arrive from sandbox) + } + Some(Ok(Message::Close(_))) | None => { + info!(sandbox = %sandbox_name, "MXC relay: sandbox WS closed"); + break true; + } + Some(Ok(_)) => {} // ping/pong handled by tungstenite + Some(Err(e)) => { + warn!(sandbox = %sandbox_name, "MXC relay: sandbox read error: {e}"); + break true; + } + }, + _ = &mut shutdown_rx => { + let _ = sandbox_ws.close(None).await; + return; + } + } + }; + + // Signal session end to the in-sandbox agent (if Phase A still alive). + if !sandbox_gone { + let _ = sandbox_ws.send(Message::Text("SESSION_END".into())).await; + } + + info!(sandbox = %sandbox_name, + host_to_sandbox_bytes, host_to_sandbox_chunks, + sandbox_to_host_bytes, sandbox_to_host_chunks, + "MXC relay: host client disconnected"); + if sandbox_gone { + info!(sandbox = %sandbox_name, "MXC relay: sandbox gone, stopping relay"); + return; + } + // Phase A still alive — wait for the next Phase B client. + } +} + +#[cfg(test)] +mod control_relay_cleanup_tests { + use super::*; + use std::process::Stdio; + use tokio::io::{AsyncBufReadExt, BufReader}; + use tokio::net::TcpStream; + + /// Exercise the real control-channel writer and relay with a child that + /// echoes request lines. The test supplies the sandbox's matching replies. + async fn check_forward_cleanup(interrupt_active: bool) { + let mut child = tokio::process::Command::new("powershell.exe") + .args([ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + "while ($null -ne ($line = [Console]::ReadLine())) { [Console]::WriteLine($line) }", + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .kill_on_drop(true) + .spawn() + .unwrap(); + let control = Arc::new(ControlChannel::new(child.stdin.take().unwrap())); + let pending = control.pending_handle(); + let mut lines = BufReader::new(child.stdout.take().unwrap()).lines(); + let (observed_tx, mut observed_rx) = tokio::sync::mpsc::unbounded_channel(); + let responder = tokio::spawn(async move { + while let Some(line) = lines.next_line().await.unwrap() { + let request: serde_json::Value = serde_json::from_str(&line).unwrap(); + let op = request["op"].as_str().unwrap().to_string(); + let response = serde_json::json!({ + "id": request["id"], "ok": true, + "data": {"bytes": "", "eof": false}, + }); + ControlChannel::try_route_response(&pending, &response.to_string()).await; + observed_tx + .send((op.clone(), request["data"]["session_id"].clone())) + .unwrap(); + if op == "forward_close" { + break; + } + tokio::time::sleep(Duration::from_millis(5)).await; + } + }); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + let nonce = [7; NONCE_LEN]; + let relay = tokio::spawn(control_channel_relay_task( + listener, + "cleanup-test".into(), + nonce, + control, + 12345, + shutdown_rx, + )); + let mut client = TcpStream::connect(address).await.unwrap(); + client.write_all(&nonce).await.unwrap(); + let opened = tokio::time::timeout(Duration::from_secs(20), observed_rx.recv()) + .await + .unwrap() + .unwrap(); + assert_eq!(opened.0, "forward_open"); + let mut shutdown_tx = Some(shutdown_tx); + if interrupt_active { + shutdown_tx.take().unwrap().send(()).unwrap(); + } else { + client.shutdown().await.unwrap(); + } + let closed_id = tokio::time::timeout(Duration::from_secs(10), async { + while let Some((op, id)) = observed_rx.recv().await { + if op == "forward_close" { + return id; + } + } + panic!("control channel ended without closing the forward"); + }) + .await + .expect("active forward must close on shutdown or EOF"); + assert_eq!(closed_id, opened.1); + if let Some(shutdown_tx) = shutdown_tx { + shutdown_tx.send(()).unwrap(); + } + tokio::time::timeout(Duration::from_secs(10), relay) + .await + .unwrap() + .unwrap(); + let mut byte = [0]; + assert_eq!( + tokio::time::timeout(Duration::from_secs(2), client.read(&mut byte)) + .await + .unwrap() + .unwrap(), + 0 + ); + responder.await.unwrap(); + child.kill().await.unwrap(); + child.wait().await.unwrap(); + } + + #[tokio::test] + async fn active_forward_is_closed_before_relay_shutdown_returns() { + check_forward_cleanup(true).await; + } + + #[tokio::test] + async fn ordinary_client_eof_still_closes_the_forward() { + check_forward_cleanup(false).await; + } +} + +// ── Relay task ──────────────────────────────────────────────────────────────── diff --git a/crates/openshell-driver-mxc/tests/openclaw_appcontainer_compat.rs b/crates/openshell-driver-mxc/tests/openclaw_appcontainer_compat.rs new file mode 100644 index 0000000000..3a3a16af59 --- /dev/null +++ b/crates/openshell-driver-mxc/tests/openclaw_appcontainer_compat.rs @@ -0,0 +1,58 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +const CAPTURE: &str = include_str!("../examples/openclaw-capture.mjs"); +const RUNNER: &str = include_str!("../examples/run-openclaw-forward-test.ps1"); + +#[cfg(windows)] +#[test] +fn runner_restores_openclaw_environment_after_success_and_failure() { + let directory = tempfile::tempdir().unwrap(); + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + let output = std::process::Command::new("powershell.exe") + .args(["-NoLogo", "-NoProfile", "-NonInteractive", "-File"]) + .arg(root.join("tests/openclaw_environment_cleanup.ps1")) + .arg("-RunnerPath") + .arg(root.join("examples/run-openclaw-forward-test.ps1")) + .arg("-TestDirectory") + .arg(directory.path()) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert!( + String::from_utf8_lossy(&output.stdout).contains("PASS: 4 environment restoration cases") + ); +} + +#[test] +fn capture_preloads_appcontainer_safe_realpath_before_openclaw() { + let patch = CAPTURE + .find("fs.promises.realpath = promisify(fs.realpath)") + .expect("capture must install the callback realpath compatibility binding"); + let import = CAPTURE + .find("await import(pathToFileURL(entry).href)") + .expect("capture must import the OpenClaw entry point"); + + assert!( + patch < import, + "realpath compatibility must be installed before OpenClaw loads" + ); + assert!(CAPTURE.contains("syncBuiltinESMExports()")); +} + +#[test] +fn runner_limits_package_group_dacl_grants_to_writable_data_directories() { + assert!(RUNNER.contains("*S-1-15-2-1:(OI)(CI)(M)")); + assert!(RUNNER.contains("*S-1-15-2-2:(OI)(CI)(M)")); + assert!( + RUNNER.contains("Grant-AppContainerWritableDirectory (Join-Path $shareDirNorm \"home\")") + ); + assert!( + RUNNER.contains("Grant-AppContainerWritableDirectory (Join-Path $shareDirNorm \"temp\")") + ); + assert!(!RUNNER.contains("Grant-AppContainerWritableDirectory $shareDirNorm")); +} diff --git a/crates/openshell-driver-mxc/tests/openclaw_environment_cleanup.ps1 b/crates/openshell-driver-mxc/tests/openclaw_environment_cleanup.ps1 new file mode 100644 index 0000000000..987ea5aed7 --- /dev/null +++ b/crates/openshell-driver-mxc/tests/openclaw_environment_cleanup.ps1 @@ -0,0 +1,68 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +param([string]$RunnerPath, [string]$TestDirectory) +$ErrorActionPreference = 'Stop' + +# Execute the actual runner's override scope, not a copy of its cleanup logic. +$tokens = $null +$parseErrors = $null +$source = [System.IO.File]::ReadAllText($RunnerPath) +$ast = [System.Management.Automation.Language.Parser]::ParseInput($source, [ref]$tokens, [ref]$parseErrors) +if ($parseErrors.Count) { throw 'Runner must parse without errors' } +$scope = $ast.Find({ param($node) + $node -is [System.Management.Automation.Language.TryStatementAst] -and + $node.Finally -and $node.Finally.Extent.Text.Contains('$savedOpenClawConfigPath') +}, $true) +if (!$scope) { throw 'OpenClaw environment restoration needs a finally scope' } +$save = $ast.Find({ param($node) + $node -is [System.Management.Automation.Language.AssignmentStatementAst] -and + $node.Left.Extent.Text -eq '$savedOpenClawConfigPath' +}, $true) +$exercise = [scriptblock]::Create($source.Substring($save.Extent.StartOffset, $scope.Extent.EndOffset - $save.Extent.StartOffset)) + +function Step { param($Message) } +function Info { param($Message) } +function Ok { param($Message) } +function Bad { param($Message) throw $Message } +$ShareDir = $TestDirectory +$resultDir = $TestDirectory +$expectedState = Join-Path $ShareDir 'home\.openclaw' +$expectedConfig = Join-Path $expectedState 'openclaw.json' +$healthArgs = @() +$proof = '[egress-proof] {"proxyConfigured":true,"allowedViaProxy":{"connected":true},"deniedViaProxy":{"connected":false},"directInternetBypass":{"connected":false},"unrelatedHostLoopback":{"connected":true}}' +[System.IO.File]::WriteAllText((Join-Path $ShareDir 'openclaw-capture.log'), $proof) +$originalConfig = $env:OPENCLAW_CONFIG_PATH +$originalState = $env:OPENCLAW_STATE_DIR +$cases = 0 +try { + foreach ($initial in @($null, 'original-value')) { + foreach ($injectFailure in @($false, $true)) { + $env:OPENCLAW_CONFIG_PATH = $initial + $env:OPENCLAW_STATE_DIR = $initial + $passed = $false + $NodeExePath = { + if ($env:OPENCLAW_CONFIG_PATH -ne $expectedConfig -or $env:OPENCLAW_STATE_DIR -ne $expectedState) { + throw 'Client did not receive isolated OpenClaw paths' + } + if ($injectFailure) { throw 'injected-client-failure' } + '{"ok":true}' + } + $caught = $false + try { . $exercise } catch { + if (!$injectFailure -or $_.Exception.Message -ne 'injected-client-failure') { throw } + $caught = $true + } + if ($caught -ne $injectFailure) { throw 'Unexpected execution outcome' } + if (!$injectFailure -and !$passed) { throw 'Successful health path did not pass' } + if ($env:OPENCLAW_CONFIG_PATH -cne $initial -or $env:OPENCLAW_STATE_DIR -cne $initial) { + throw 'Environment was not restored after the client scope' + } + $cases++ + } + } +} finally { + $env:OPENCLAW_CONFIG_PATH = $originalConfig + $env:OPENCLAW_STATE_DIR = $originalState +} +Write-Output "PASS: $cases environment restoration cases" diff --git a/crates/openshell-driver-mxc/tests/policy_mapper_examples.rs b/crates/openshell-driver-mxc/tests/policy_mapper_examples.rs index d8d6a37904..c7382afece 100644 --- a/crates/openshell-driver-mxc/tests/policy_mapper_examples.rs +++ b/crates/openshell-driver-mxc/tests/policy_mapper_examples.rs @@ -210,12 +210,15 @@ fn all_example_policies_split_with_expected_invariants() { assert!(str_list(&cfg["filesystem"]["readonlyPaths"]).is_empty()); } - assert_eq!(cfg["network"]["defaultPolicy"], "block"); - assert!(str_list(&cfg["network"]["allowedHosts"]).is_empty()); - // MXC 0.6.0-alpha accepts only {"proxy": {"localhost": N}}. - assert_eq!(cfg["network"]["proxy"]["localhost"], 18080); - assert!(cfg["network"]["proxy"].get("host").is_none()); - assert!(cfg["network"]["proxy"].get("port").is_none()); + assert_eq!(cfg["version"], "0.8.0-alpha"); + assert_eq!(cfg["network"]["egress"]["default"], "deny"); + assert_eq!( + cfg["network"]["egress"]["allow"][0]["to"][0]["cidr"], + "127.0.0.1/32" + ); + assert_eq!(cfg["network"]["ingress"]["hostLoopback"], "allow"); + assert!(cfg.get("runtimeConfig").is_none()); + assert!(cfg["network"].get("proxy").is_none()); let errors: Vec<_> = result .loss .iter() @@ -295,29 +298,24 @@ fn split_policy_routes_network_to_proxy() { let result = split_policy(&policy, &opts).expect("split_policy returns Some when addr is set"); let cfg = &result.mxc_config; - // Proxy redirect is emitted. 127.0.0.2 is not the loopback 127.0.0.1 so - // the mapper records an error loss and omits the proxy block entirely. - // (MXC 0.6.0-alpha can only encode {"localhost": N}; non-127.0.0.1 is - // not representable.) + // 127.0.0.2 is not the supported 127.0.0.1 host-proxy address, so the + // mapper records an error loss and keeps the MXC config fail-closed. assert!( - cfg["network"].get("proxy").is_none() || cfg["network"]["proxy"].is_null(), - "non-127.0.0.1 redirect must NOT produce a proxy block: {:?}", - cfg["network"].get("proxy") + cfg.get("runtimeConfig").is_none() || cfg["runtimeConfig"].is_null(), + "non-127.0.0.1 redirect must NOT produce runtimeConfig: {:?}", + cfg.get("runtimeConfig") ); let has_proxy_loss = result .loss .iter() - .any(|i| i.path == "network.proxy" && i.severity == "error"); + .any(|i| i.path == "proxy_redirect" && i.severity == "error"); assert!( has_proxy_loss, "non-127.0.0.1 redirect must produce an error loss item" ); - // Direct egress is blocked; unsupported host-list fields are omitted and - // the proxy enforces the full list. - assert_eq!(cfg["network"]["defaultPolicy"], "block"); - assert!(cfg["network"].get("allowedHosts").is_none()); - assert!(cfg["network"].get("blockedHosts").is_none()); + // Direct egress is denied; the host proxy enforces the list. + assert_eq!(cfg["network"]["egress"]["default"], "deny"); // Filesystem grants are preserved unchanged. assert_eq!( @@ -401,7 +399,7 @@ fn split_policy_rejects_proxy_redirect_on_isolation_session() { "expected one containment error: {errors:?}" ); assert_eq!(errors[0].path, "containment"); - assert!(errors[0].message.contains("MXC M1")); + assert!(errors[0].message.contains("processcontainer")); assert!(result.mxc_config["network"].get("proxy").is_none()); } @@ -425,9 +423,7 @@ fn network_only_policy_has_empty_filesystem() { // ── New tests: proxy JSON shape and non-127.0.0.1 guard ────────────────────── #[test] -fn split_with_loopback_addr_emits_localhost_port_shape() { - // MXC 0.6.0-alpha accepts ONLY {"proxy": {"localhost": N}}. - // Verified against the real wxc-exec 0.6.0-alpha binary via --dry-run. +fn split_with_loopback_addr_emits_loopback_only_08_shape() { let path = examples_root().join("sandbox-policy-quickstart/policy.yaml"); let yaml = std::fs::read_to_string(&path).expect("read quickstart"); let policy = parse_sandbox_policy(&yaml).expect("parse quickstart"); @@ -440,18 +436,15 @@ fn split_with_loopback_addr_emits_localhost_port_shape() { let result = split_policy(&policy, &opts).expect("split returns Some"); let cfg = &result.mxc_config; + assert_eq!(cfg["version"], "0.8.0-alpha"); + assert_eq!(cfg["network"]["egress"]["default"], "deny"); assert_eq!( - cfg["network"]["proxy"]["localhost"], 18080, - "proxy must use {{\"localhost\": N}} shape" - ); - assert!( - cfg["network"]["proxy"].get("host").is_none(), - "proxy must not contain 'host' key" - ); - assert!( - cfg["network"]["proxy"].get("port").is_none(), - "proxy must not contain 'port' key" + cfg["network"]["egress"]["allow"][0]["to"][0]["cidr"], + "127.0.0.1/32" ); + assert_eq!(cfg["network"]["ingress"]["hostLoopback"], "allow"); + assert!(cfg.get("runtimeConfig").is_none()); + assert!(cfg["network"].get("proxy").is_none()); // No error losses — 127.0.0.1 is representable. assert!( result.loss.iter().all(|i| i.severity != "error"), @@ -465,9 +458,7 @@ fn split_with_loopback_addr_emits_localhost_port_shape() { } #[test] -fn split_with_non_loopback_addr_emits_error_loss_and_no_proxy_block() { - // Non-127.0.0.1 redirect addresses are not representable in MXC 0.6.0-alpha. - // The mapper must record an error loss and omit the proxy block. +fn split_with_non_loopback_addr_emits_error_loss_and_no_runtime_proxy() { let path = examples_root().join("sandbox-policy-quickstart/policy.yaml"); let yaml = std::fs::read_to_string(&path).expect("read quickstart"); let policy = parse_sandbox_policy(&yaml).expect("parse quickstart"); @@ -480,28 +471,29 @@ fn split_with_non_loopback_addr_emits_error_loss_and_no_proxy_block() { let result = split_policy(&policy, &opts).expect("split returns Some"); let cfg = &result.mxc_config; - // Proxy block must be absent. + // A runtime proxy block is never emitted; proxy-aware clients receive + // environment variables from the driver after this mapping step. assert!( - cfg["network"].get("proxy").is_none() || cfg["network"]["proxy"].is_null(), - "non-127.0.0.1 redirect must not produce a proxy block: {:?}", - cfg["network"].get("proxy") + cfg.get("runtimeConfig").is_none() || cfg["runtimeConfig"].is_null(), + "non-127.0.0.1 redirect must not produce runtimeConfig: {:?}", + cfg.get("runtimeConfig") ); - // An error loss for "network.proxy" must be present. + // An error loss for the unusable redirect must be present. let proxy_loss = result .loss .iter() - .find(|i| i.path == "network.proxy" && i.severity == "error"); + .find(|i| i.path == "proxy_redirect" && i.severity == "error"); assert!( proxy_loss.is_some(), - "non-127.0.0.1 redirect must produce an error loss item on network.proxy: {:?}", + "non-127.0.0.1 redirect must produce a proxy_redirect error loss item: {:?}", result.loss ); let loss = proxy_loss.unwrap(); assert_eq!(loss.openshell_feature, "per-sandbox egress attribution"); assert!( - loss.message.contains("localhost"), - "loss message should mention 'localhost': {}", + loss.message.contains("127.0.0.1"), + "loss message should mention '127.0.0.1': {}", loss.message ); } diff --git a/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs b/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs index a4ed71117a..3d2b8647f8 100644 --- a/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs +++ b/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs @@ -24,7 +24,7 @@ use openshell_core::proto::{ FilesystemPolicy, GraphqlOperation, L7Allow, L7DenyRule, L7Rule, LandlockPolicy, MiddlewareEndpointSelector, NetworkBinary, NetworkEndpoint, NetworkMiddlewareConfig, - NetworkPolicyRule, ProcessPolicy, SandboxPolicy, + NetworkPolicyRule, ProcessPolicy, SandboxPolicy, UiClipboardAccess, UiPolicy, }; use openshell_driver_mxc::{ EmbeddedPolicyMapper, MapCtx, MapError, MxcMappingOptions, PolicyMapper, map_to_mxc, @@ -82,6 +82,12 @@ fn pc_split_opts() -> MxcMappingOptions { } } +fn pc_opts() -> MxcMappingOptions { + MxcMappingOptions { + containment: "processcontainer".to_owned(), + ..Default::default() + } +} /// Build a minimal policy with one network rule whose endpoints carry a single /// endpoint set up by the caller. fn net_policy(key: &str, ep: NetworkEndpoint) -> SandboxPolicy { @@ -384,18 +390,18 @@ fn a_split_network_verbatim_and_version_preserved() { ); } -/// split path: mxc_config["network"]["proxy"]["localhost"] == port (new schema). +/// Split path emits MXC 0.8 loopback-only governed-egress fields. #[test] -fn a_split_proxy_localhost_port() { +fn a_split_proxy_uses_loopback_only_08_fields() { let policy = SandboxPolicy::default(); let result = split_policy(&policy, &pc_split_opts()).expect("split must return Some"); + assert!(result.mxc_config.get("runtimeConfig").is_none()); + assert_eq!(result.mxc_config["version"], "0.8.0-alpha"); + assert_eq!(result.mxc_config["network"]["egress"]["default"], "deny"); assert_eq!( - result.mxc_config["network"]["proxy"]["localhost"], 18080, - "split must emit network.proxy.localhost == port" + result.mxc_config["network"]["egress"]["allow"][0]["to"][0]["cidr"], + "127.0.0.1/32" ); - // Released wxc-exec rejects host-list fields, even when empty. - assert!(result.mxc_config["network"].get("allowedHosts").is_none()); - assert!(result.mxc_config["network"].get("blockedHosts").is_none()); } // ─── QUADRANT B: OpenShell features MXC cannot express ────────────────────── @@ -930,6 +936,7 @@ fn b_seam_returns_unsupported_on_error_field() { let ctx = MapCtx { sandbox_id: "sb-test".into(), egress: None, // coarse path → isolation_session → network policy errors + containment: "isolation_session".into(), }; let err = mapper.map(Some(&policy), &ctx).unwrap_err(); assert!( @@ -1023,13 +1030,141 @@ fn c_split_empty_allowed_hosts_with_network_rules() { }, ); let result = split_policy(&policy, &pc_split_opts()).expect("split must return Some"); - assert!(result.mxc_config["network"].get("allowedHosts").is_none()); - assert!(result.mxc_config["network"].get("blockedHosts").is_none()); - // But proxy redirect is present. assert_eq!( - result.mxc_config["network"]["proxy"]["localhost"], 18080, - "split must emit network.proxy.localhost" + result.mxc_config["network"]["egress"]["default"], "deny", + "split path must deny direct egress even with network rules" ); + // The only MXC-level egress allowance is host loopback. + assert_eq!( + result.mxc_config["network"]["egress"]["allow"][0]["to"][0]["cidr"], + "127.0.0.1/32" + ); + assert!(result.mxc_config.get("runtimeConfig").is_none()); +} + +#[test] +fn a_processcontainer_maps_ui_capabilities_exactly() { + for (clipboard, expected) in [ + (UiClipboardAccess::Unspecified, "none"), + (UiClipboardAccess::None, "none"), + (UiClipboardAccess::Read, "read"), + (UiClipboardAccess::Write, "write"), + (UiClipboardAccess::All, "all"), + ] { + let policy = SandboxPolicy { + ui: Some(UiPolicy { + allow_graphical_ui: true, + clipboard: clipboard as i32, + allow_input_injection: true, + }), + ..Default::default() + }; + let result = map_to_mxc(&policy, &pc_opts()); + assert_eq!(result.config["ui"]["disable"], false); + assert_eq!(result.config["ui"]["clipboard"], expected); + assert_eq!(result.config["ui"]["injection"], true); + assert_eq!(result.config["ui"].as_object().unwrap().len(), 3); + assert!(result.loss.iter().all(|item| item.path != "ui")); + } +} + +#[test] +fn c_processcontainer_absent_or_empty_ui_is_default_deny() { + for policy in [ + SandboxPolicy::default(), + SandboxPolicy { + ui: Some(UiPolicy::default()), + ..Default::default() + }, + ] { + let result = map_to_mxc(&policy, &pc_opts()); + assert_eq!(result.config["ui"]["disable"], true); + assert_eq!(result.config["ui"]["clipboard"], "none"); + assert_eq!(result.config["ui"]["injection"], false); + } +} + +#[test] +fn b_processcontainer_rejects_clipboard_without_graphical_ui() { + let policy = SandboxPolicy { + ui: Some(UiPolicy { + clipboard: UiClipboardAccess::Read as i32, + ..Default::default() + }), + ..Default::default() + }; + let result = map_to_mxc(&policy, &pc_opts()); + assert_eq!(result.config["ui"]["disable"], true); + assert_eq!(result.config["ui"]["clipboard"], "none"); + assert_single_loss( + &result.loss, + "ui.clipboard", + "error", + "clipboard grant suppressed by ui.disable", + ); +} + +#[test] +fn b_processcontainer_rejects_input_injection_without_graphical_ui() { + let policy = SandboxPolicy { + ui: Some(UiPolicy { + allow_input_injection: true, + ..Default::default() + }), + ..Default::default() + }; + let result = map_to_mxc(&policy, &pc_opts()); + assert_eq!(result.config["ui"]["disable"], true); + assert_eq!(result.config["ui"]["injection"], false); + assert_single_loss( + &result.loss, + "ui.allow_input_injection", + "error", + "input-injection grant suppressed by ui.disable", + ); +} + +#[test] +fn b_isolation_session_omits_absent_ui_and_rejects_explicit_ui() { + let opts = MxcMappingOptions { + containment: "isolation_session".into(), + ..Default::default() + }; + let absent = map_to_mxc(&SandboxPolicy::default(), &opts); + assert!(absent.config.get("ui").is_none()); + + let explicit = map_to_mxc( + &SandboxPolicy { + ui: Some(UiPolicy::default()), + ..Default::default() + }, + &opts, + ); + assert!(explicit.config.get("ui").is_none()); + assert_single_loss( + &explicit.loss, + "ui", + "error", + "isolation_session explicit UI", + ); +} + +#[test] +fn a_split_maps_ui_to_mxc_and_omits_it_from_proxy_policy() { + let policy = SandboxPolicy { + version: 1, + ui: Some(UiPolicy { + allow_graphical_ui: true, + clipboard: UiClipboardAccess::Write as i32, + ..Default::default() + }), + ..Default::default() + }; + let result = split_policy(&policy, &pc_split_opts()).expect("split"); + assert_eq!(result.mxc_config["ui"]["disable"], false); + assert_eq!(result.mxc_config["ui"]["clipboard"], "write"); + assert_eq!(result.mxc_config["ui"]["injection"], false); + assert!(result.proxy_policy.ui.is_none()); } // ─── DRIFT GUARD ───────────────────────────────────────────────────────────── @@ -1046,11 +1181,14 @@ fn c_split_empty_allowed_hosts_with_network_rules() { /// "landlock" — loss item emitted in add_static_policy_loss /// "process" — loss items for run_as_user / run_as_group /// "network_policies" — mapped via map_network / delegated in split +/// "network_middlewares" — error loss in coarse map / delegated in split +/// "ui" — exact processContainer map / explicit unsupported loss const HANDLED_TOPLEVEL: &[&str] = &[ "version", "filesystem_policy", "landlock", "process", + "ui", "network_policies", "network_middlewares", ]; @@ -1121,6 +1259,7 @@ fn handled_fields_inventory() { run_as_user: "sandbox".into(), run_as_group: "sandbox".into(), }), + ui: Some(UiPolicy::default()), network_policies: { let mut m = std::collections::HashMap::new(); m.insert( diff --git a/crates/openshell-driver-mxc/tests/wxc_exec_real.rs b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs index 8ac2c7ade8..c7bb11d86b 100644 --- a/crates/openshell-driver-mxc/tests/wxc_exec_real.rs +++ b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs @@ -36,7 +36,7 @@ use openshell_core::proto::{ FilesystemPolicy, NetworkBinary, NetworkEndpoint, NetworkPolicyRule, SandboxPolicy, }; use openshell_driver_mxc::{MxcComputeBackend, MxcComputeConfig}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::process::Command; use std::time::Duration; @@ -68,7 +68,7 @@ fn wxc_path() -> Option { /// Create a real, user-owned Windows directory for MXC filesystem grants. /// /// MXC config values are literal paths: it does not expand `%TEMP%`. A unique -/// directory also keeps AppContainer+DACL fallback mutations scoped to test +/// directory also keeps `AppContainer`+DACL fallback mutations scoped to test /// data the current user owns. fn temp_fixture() -> (tempfile::TempDir, String) { let dir = tempfile::tempdir().expect("create MXC temp fixture"); @@ -81,10 +81,19 @@ fn temp_fixture() -> (tempfile::TempDir, String) { /// Invoke `wxc-exec --config-base64 --dry-run` synchronously. /// Returns `(exit_code, stdout, stderr)`. fn dry_run(wxc: &PathBuf, config: &serde_json::Value) -> (i32, String, String) { + dry_run_with_args(wxc, config, &[]) +} + +fn dry_run_with_args( + wxc: &PathBuf, + config: &serde_json::Value, + args: &[&str], +) -> (i32, String, String) { let json = serde_json::to_string(config).expect("config serialize"); let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); let out = Command::new(wxc) + .args(args) .arg("--config-base64") .arg(&b64) .arg("--dry-run") @@ -97,6 +106,32 @@ fn dry_run(wxc: &PathBuf, config: &serde_json::Value) -> (i32, String, String) { (code, stdout, stderr) } +fn wxc_version(wxc: &Path) -> Option<(u64, u64, u64, String)> { + // wxc-exec does not expose --version. Release builds carry the Cargo + // version in the standard Windows ProductVersion resource. + let path_literal = wxc.to_string_lossy().replace('\'', "''"); + let output = Command::new("powershell.exe") + .args(["-NoProfile", "-NonInteractive", "-Command"]) + .arg(format!( + "(Get-Item -LiteralPath '{path_literal}').VersionInfo.ProductVersion" + )) + .output() + .ok()?; + let raw = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let version = raw.split_whitespace().find_map(|token| { + let core = token + .trim_matches(|ch: char| !ch.is_ascii_digit() && ch != '.') + .split(['+', '-']) + .next()?; + let mut parts = core.split('.'); + let major = parts.next()?.parse().ok()?; + let minor = parts.next()?.parse().ok()?; + let patch = parts.next()?.parse().ok()?; + Some((major, minor, patch)) + })?; + Some((version.0, version.1, version.2, raw)) +} + // ── (a) Dry-run contract tests ──────────────────────────────────────────────── // // These PASS on any box that has the wxc-exec binary — no enforcement backend @@ -133,6 +168,103 @@ fn dryrun_accepts_minimal_processcontainer_config() { ); } +/// Every `OpenShell` clipboard direction maps to MXC's shared top-level UI +/// contract, with graphical UI and injection carried as independent booleans. +#[test] +#[ignore = "requires real wxc-exec"] +fn dryrun_accepts_processcontainer_ui_policy_matrix() { + let Some(wxc) = wxc_path() else { + eprintln!("SKIP: wxc-exec not found"); + return; + }; + + let tempdir = tempfile::tempdir().expect("tempdir"); + let temp_path = tempdir.path().to_string_lossy().into_owned(); + for clipboard in ["none", "read", "write", "all"] { + let config = serde_json::json!({ + "version": "0.7.0-alpha", + "containerId": format!("test-ui-{clipboard}"), + "containment": "processcontainer", + "process": { + "commandLine": "cmd /c exit 0", + "cwd": temp_path.clone(), + "timeout": 0, + }, + "filesystem": { + "readwritePaths": [temp_path.clone()], + }, + "ui": { + "disable": false, + "clipboard": clipboard, + "injection": true, + }, + }); + + let (code, stdout, stderr) = dry_run(&wxc, &config); + assert_eq!( + code, 0, + "processcontainer UI policy clipboard={clipboard} rejected by --dry-run\nstdout={stdout}\nstderr={stderr}" + ); + } +} + +/// MXC 0.8 and the 0.9 development schema reject the shared top-level +/// UI object on `isolation_session`, while omission remains accepted. Older +/// 0.7 builds accepted and ignored the object, so `OpenShell`'s gateway-level +/// capability check is the stable enforcement boundary across versions. +#[test] +#[ignore = "requires real wxc-exec"] +fn dryrun_current_schema_rejects_isolation_session_ui() { + let Some(wxc) = wxc_path() else { + eprintln!("SKIP: wxc-exec not found"); + return; + }; + let Some((major, minor, _patch, raw_version)) = wxc_version(&wxc) else { + eprintln!("SKIP: could not determine wxc-exec version"); + return; + }; + if (major, minor) < (0, 8) { + eprintln!("SKIP: {raw_version} predates the isolation_session UI rejection contract"); + return; + } + + let base = serde_json::json!({ + "phase": "provision", + "containment": "isolation_session", + "network": { + "defaultPolicy": "allow", + "allowLocalNetwork": true, + }, + }); + let (code, stdout, stderr) = dry_run_with_args(&wxc, &base, &["--experimental"]); + let output = format!("{stdout} {stderr}").to_ascii_lowercase(); + if code != 0 + && output.contains("backend_unavailable") + && output.contains("not available in this build") + { + eprintln!("SKIP: {raw_version} was built without isolation_session support"); + return; + } + assert_eq!( + code, 0, + "current isolation_session schema must accept omission of UI\nversion={raw_version}\nstdout={stdout}\nstderr={stderr}" + ); + + let mut with_ui = base; + with_ui["ui"] = serde_json::json!({ "disable": true }); + let (code, stdout, stderr) = dry_run_with_args(&wxc, &with_ui, &["--experimental"]); + assert_ne!( + code, 0, + "current isolation_session schema unexpectedly accepted UI\nversion={raw_version}\nstdout={stdout}\nstderr={stderr}" + ); + assert!( + format!("{stdout} {stderr}") + .to_ascii_lowercase() + .contains("ui"), + "rejection should identify UI\nversion={raw_version}\nstdout={stdout}\nstderr={stderr}" + ); +} + /// Network block without proxy (defaultPolicy block, empty host lists) accepted. #[test] #[ignore = "requires real wxc-exec"] @@ -272,7 +404,7 @@ fn dryrun_rejects_unknown_containment() { } /// The most important dry-run test: build a typed Windows policy, run -/// `split_policy` (proxy_redirect 127.0.0.1:18080, containment +/// `split_policy` (native MXC 0.8 proxy redirect 127.0.0.1:18080, containment /// "processcontainer"), and verify the resulting config with `--dry-run`. /// /// This proves that the mapper's emitted JSON is accepted by the real binary — @@ -321,6 +453,13 @@ fn dryrun_accepts_split_policy_output() { } let mxc_config = result.mxc_config; + assert_eq!(mxc_config["version"], "0.8.0-alpha"); + assert_eq!(mxc_config["network"]["egress"]["default"], "deny"); + assert_eq!( + mxc_config["network"]["egress"]["allow"][0]["to"][0]["cidr"], + "127.0.0.1/32" + ); + assert!(mxc_config.get("runtimeConfig").is_none()); let (code, stdout, stderr) = dry_run(&wxc, &mxc_config); assert_eq!( @@ -346,10 +485,7 @@ fn dryrun_accepts_split_policy_output() { fn probe_processcontainer(wxc: &PathBuf) -> Result<(), String> { // Abort early if the mock env var is set — a stale OPENSHELL_MXC_MOCK_WXC // would silently turn this "real" run back into a mock run. - if std::env::var("OPENSHELL_MXC_MOCK_WXC") - .map(|v| v == "1") - .unwrap_or(false) - { + if std::env::var("OPENSHELL_MXC_MOCK_WXC").is_ok_and(|v| v == "1") { return Err( "OPENSHELL_MXC_MOCK_WXC=1 is set — unset it before running real enforcement tests" .to_string(), @@ -395,16 +531,17 @@ fn probe_processcontainer(wxc: &PathBuf) -> Result<(), String> { || combined.contains("not enabled") { // Extract the message if possible for a more useful skip reason. - let reason = if let Ok(v) = + let reason = serde_json::from_str::(&String::from_utf8_lossy(&out.stdout)) - { - v["error"]["message"] - .as_str() - .unwrap_or("backend_error (E_NOTIMPL)") - .to_string() - } else { - "backend_error (velocity keys not enabled)".to_string() - }; + .map_or_else( + |_| "backend_error (velocity keys not enabled)".to_string(), + |v| { + v["error"]["message"] + .as_str() + .unwrap_or("backend_error (E_NOTIMPL)") + .to_string() + }, + ); return Err(reason); } @@ -421,7 +558,7 @@ fn probe_processcontainer(wxc: &PathBuf) -> Result<(), String> { } /// Probe the released binary's live `network.proxy` support separately from -/// ordinary ProcessContainer support. Some builds accept the proxy JSON during +/// ordinary `ProcessContainer` support. Some builds accept the proxy JSON during /// `--dry-run` but return `ERROR_INVALID_PARAMETER` from the live launcher. fn probe_processcontainer_proxy(wxc: &PathBuf) -> Result<(), String> { let (_tempdir, temp_path) = temp_fixture(); @@ -472,15 +609,12 @@ fn probe_processcontainer_proxy(wxc: &PathBuf) -> Result<(), String> { )) } -/// Probe the isolation_session backend. +/// Probe the `isolation_session` backend. /// /// Attempts a `provision` phase. Returns `Ok(sandbox_id)` when live, or /// `Err(reason)` when the backend is unavailable (caller prints SKIP). fn probe_isolation_session(wxc: &PathBuf) -> Result { - if std::env::var("OPENSHELL_MXC_MOCK_WXC") - .map(|v| v == "1") - .unwrap_or(false) - { + if std::env::var("OPENSHELL_MXC_MOCK_WXC").is_ok_and(|v| v == "1") { return Err( "OPENSHELL_MXC_MOCK_WXC=1 is set — unset it before running real enforcement tests" .to_string(), @@ -667,9 +801,9 @@ fn pc_oneshot_in_policy_write_succeeds() { ); } -/// Run an HTTPS request through the real driver and ProcessContainer. The +/// Run an HTTPS request through the real driver and `ProcessContainer`. The /// workload explicitly reads the injected bundle before curl uses it, proving -/// that the driver's internal TLS share is reachable from the AppContainer. +/// that the driver's internal TLS share is reachable from the `AppContainer`. #[tokio::test] #[ignore = "requires real wxc-exec and outbound HTTPS"] async fn pc_https_egress_reads_injected_ca_bundle() { @@ -820,7 +954,7 @@ async fn pc_https_egress_reads_injected_ca_bundle() { } /// Write to a path OUTSIDE the granted dir; assert exit non-zero and file absent. -/// This is the genuine OS default-deny proof — the AppContainer blocks the write +/// This is the genuine OS default-deny proof — the `AppContainer` blocks the write /// without requiring any host ACL lockdown. The mock can only fake this. #[test] #[ignore = "requires real wxc-exec"] @@ -890,7 +1024,7 @@ fn pc_oneshot_out_of_policy_write_denied() { // ── Isolation session enforcement tests ────────────────────────────────────── -/// Full isolation_session round trip: provision → start → exec → stop → +/// Full `isolation_session` round trip: provision → start → exec → stop → /// deprovision. `deprovision` runs in a drop-guard even on panic so the /// single-session backend is never left orphaned. #[test] diff --git a/crates/openshell-driver-podman/src/driver.rs b/crates/openshell-driver-podman/src/driver.rs index 2852c43328..ae564cd4ab 100644 --- a/crates/openshell-driver-podman/src/driver.rs +++ b/crates/openshell-driver-podman/src/driver.rs @@ -528,6 +528,7 @@ impl PodmanComputeDriver { }), rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, + supports_ui_policy: false, }) } diff --git a/crates/openshell-driver-vm/src/driver.rs b/crates/openshell-driver-vm/src/driver.rs index d012ffb99e..b1b3073958 100644 --- a/crates/openshell-driver-vm/src/driver.rs +++ b/crates/openshell-driver-vm/src/driver.rs @@ -800,6 +800,7 @@ impl VmDriver { .to_string_lossy() .into_owned(), rootfs_tar_max_bytes: self.config.rootfs_tar_max_bytes(), + supports_ui_policy: false, } } diff --git a/crates/openshell-gateway/Cargo.toml b/crates/openshell-gateway/Cargo.toml index 32dcaeb24c..3aa6aa24f0 100644 --- a/crates/openshell-gateway/Cargo.toml +++ b/crates/openshell-gateway/Cargo.toml @@ -73,6 +73,7 @@ telemetry = ["openshell-core/telemetry", "openshell-server/telemetry"] ## `rust:verify:defaults-without-telemetry`. defaults-without-telemetry = ["in-tree-compute-drivers"] bundled-z3 = ["openshell-server/bundled-z3"] +prebuilt-z3 = ["openshell-server/prebuilt-z3"] [lints] workspace = true diff --git a/crates/openshell-gateway/src/lib.rs b/crates/openshell-gateway/src/lib.rs index b67e93c102..30e1c83727 100644 --- a/crates/openshell-gateway/src/lib.rs +++ b/crates/openshell-gateway/src/lib.rs @@ -159,10 +159,39 @@ impl openshell_server::ComputeDriverFactory for MxcFactory { ) -> openshell_core::Result { let config: openshell_driver_mxc::MxcComputeConfig = context.driver_config()?; let backend = openshell_driver_mxc::MxcComputeBackend::new(config); + context.set_forward_sink(std::sync::Arc::new(MxcForwardSink(backend.forward_sink()))); + let provider_credentials_sink = backend.provider_credentials_sink(); let driver = openshell_driver_mxc::ComputeDriverService::new(backend); - Ok(openshell_server::ComputeDriverInstance::InProcess( - std::sync::Arc::new(driver), - )) + Ok( + openshell_server::ComputeDriverInstance::InProcessWithProviderCredentials { + driver: std::sync::Arc::new(driver), + provider_credentials_sink, + }, + ) + } +} + +/// Adapts MXC's dynamic port-forward side channel (`handle_forward_tcp`'s +/// fallback for sandboxes with no in-sandbox supervisor) to the generic +/// `ComputeDriverForwardSink` capability the server crate consumes, so +/// `openshell-server` never has to depend on `openshell-driver-mxc` directly. +#[cfg(all(target_os = "windows", feature = "compute-driver-mxc"))] +struct MxcForwardSink(openshell_driver_mxc::ForwardSink); + +#[cfg(all(target_os = "windows", feature = "compute-driver-mxc"))] +#[async_trait::async_trait] +impl openshell_server::ComputeDriverForwardSink for MxcForwardSink { + async fn open_dynamic_forward( + &self, + sandbox_id: &str, + target_port: u16, + ) -> Result<(std::net::SocketAddr, Vec, Box), String> { + let (addr, nonce, handle) = self + .0 + .open_dynamic_forward(sandbox_id, target_port) + .await + .map_err(|error| error.to_string())?; + Ok((addr, nonce.to_vec(), Box::new(handle))) } } diff --git a/crates/openshell-policy/src/lib.rs b/crates/openshell-policy/src/lib.rs index d6c66ac597..2ff6dc90ed 100644 --- a/crates/openshell-policy/src/lib.rs +++ b/crates/openshell-policy/src/lib.rs @@ -29,7 +29,7 @@ use openshell_core::mcp::{DEFAULT_MCP_PROTOCOL_VERSION, McpProtocolVersion}; use openshell_core::proto::{ FilesystemPolicy, GraphqlOperation, L7Allow, L7DenyRule, L7QueryMatcher, L7Rule, LandlockPolicy, McpOptions, NetworkBinary, NetworkEndpoint, NetworkPolicyRule, ProcessPolicy, - SandboxPolicy, + SandboxPolicy, UiClipboardAccess, UiPolicy, }; use serde::{Deserialize, Deserializer, Serialize}; @@ -63,6 +63,8 @@ struct PolicyFile { landlock: Option, #[serde(default, skip_serializing_if = "Option::is_none")] process: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + ui: Option, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] network_policies: BTreeMap, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] @@ -104,6 +106,53 @@ struct ProcessDef { run_as_group: String, } +#[derive(Debug, Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct UiDef { + #[serde(default, skip_serializing_if = "is_false")] + allow_graphical_ui: bool, + #[serde(default, skip_serializing_if = "UiClipboardAccessDef::is_none")] + clipboard: UiClipboardAccessDef, + #[serde(default, skip_serializing_if = "is_false")] + allow_input_injection: bool, +} + +#[derive(Debug, Default, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +enum UiClipboardAccessDef { + #[default] + None, + Read, + Write, + All, +} + +impl UiClipboardAccessDef { + // Signature dictated by serde's `skip_serializing_if`, which requires `&T`. + #[allow(clippy::trivially_copy_pass_by_ref)] + const fn is_none(&self) -> bool { + matches!(self, Self::None) + } + + const fn to_proto(self) -> UiClipboardAccess { + match self { + Self::None => UiClipboardAccess::None, + Self::Read => UiClipboardAccess::Read, + Self::Write => UiClipboardAccess::Write, + Self::All => UiClipboardAccess::All, + } + } + + fn from_proto(value: i32) -> Self { + match UiClipboardAccess::try_from(value).unwrap_or(UiClipboardAccess::Unspecified) { + UiClipboardAccess::Unspecified | UiClipboardAccess::None => Self::None, + UiClipboardAccess::Read => Self::Read, + UiClipboardAccess::Write => Self::Write, + UiClipboardAccess::All => Self::All, + } + } +} + #[derive(Debug, Serialize, Deserialize)] #[serde(deny_unknown_fields)] struct NetworkPolicyRuleDef { @@ -202,6 +251,12 @@ fn is_zero(v: &u16) -> bool { *v == 0 } +// Signature dictated by serde's `skip_serializing_if`, which requires `&T`. +#[allow(clippy::trivially_copy_pass_by_ref)] +fn is_false(v: &bool) -> bool { + !*v +} + // Signature dictated by serde's `skip_serializing_if`, which requires `&T`. #[allow(clippy::trivially_copy_pass_by_ref)] fn is_zero_u32(v: &u32) -> bool { @@ -928,6 +983,11 @@ fn to_proto(raw: PolicyFile) -> Result { run_as_user: p.run_as_user, run_as_group: p.run_as_group, }), + ui: raw.ui.map(|ui| UiPolicy { + allow_graphical_ui: ui.allow_graphical_ui, + clipboard: ui.clipboard.to_proto() as i32, + allow_input_injection: ui.allow_input_injection, + }), network_policies, network_middlewares, }) @@ -971,6 +1031,12 @@ fn from_proto(policy: &SandboxPolicy) -> Result { } }); + let ui = policy.ui.as_ref().map(|ui| UiDef { + allow_graphical_ui: ui.allow_graphical_ui, + clipboard: UiClipboardAccessDef::from_proto(ui.clipboard), + allow_input_injection: ui.allow_input_injection, + }); + let network_policies = policy .network_policies .iter() @@ -1086,6 +1152,7 @@ fn from_proto(policy: &SandboxPolicy) -> Result { filesystem_policy, landlock, process, + ui, network_policies, network_middlewares, }) @@ -1284,6 +1351,7 @@ pub fn restrictive_default_policy() -> SandboxPolicy { compatibility: "best_effort".into(), }), process: None, + ui: None, network_policies: HashMap::new(), network_middlewares: HashMap::default(), } @@ -1319,6 +1387,8 @@ const MAX_PATH_LENGTH: usize = 4096; pub enum PolicyViolation { /// An explicit `run_as_user` or `run_as_group` is unsafe. InvalidProcessIdentity { field: &'static str, value: String }, + /// The protobuf carries a clipboard enum value unknown to this version. + InvalidUiClipboardAccess { value: i32 }, /// A filesystem path contains `..` components. PathTraversal { path: String }, /// A filesystem path is not absolute (does not start with `/`). @@ -1420,6 +1490,12 @@ impl fmt::Display for PolicyViolation { "{field} must be 'sandbox' or a numeric UID/GID in range [{MIN_SANDBOX_UID}, {MAX_SANDBOX_UID}], got '{value}'" ) } + Self::InvalidUiClipboardAccess { value } => { + write!( + f, + "ui clipboard access has unknown enum value {value}; expected unspecified, none, read, write, or all" + ) + } Self::PathTraversal { path } => { write!(f, "path contains '..' traversal component: {path}") } @@ -1626,6 +1702,7 @@ impl fmt::Display for PolicyViolation { /// /// Checks performed: /// - Explicit `run_as_user` / `run_as_group` fields must be safe identities +/// - UI clipboard access must use a recognized enum value /// - Filesystem paths must be absolute (start with `/`) /// - Filesystem paths must not contain `..` components /// - Read-write paths must not be overly broad (just `/`) @@ -1685,6 +1762,14 @@ fn validate_sandbox_policy_with_mcp_presence( }); } + if let Some(ref ui) = policy.ui + && UiClipboardAccess::try_from(ui.clipboard).is_err() + { + violations.push(PolicyViolation::InvalidUiClipboardAccess { + value: ui.clipboard, + }); + } + // Check filesystem paths if let Some(ref fs) = policy.filesystem { let total_paths = fs.read_only.len() + fs.read_write.len(); @@ -2129,6 +2214,7 @@ fn validate_and_canonicalize_mcp_policy_schema( .map_err(|violations| PolicyValidationError { violations })?; materialize_default_mcp_versions(&mut policy); canonicalize_mcp_version_allowlists(&mut policy); + canonicalize_ui_defaults(&mut policy); validate_mcp_policy_schema(&policy, McpVersionPresence::RequireMaterialized) .map_err(|violations| PolicyValidationError { violations })?; Ok(policy) @@ -2151,6 +2237,7 @@ pub fn validate_and_canonicalize_sandbox_policy( .map_err(|violations| PolicyValidationError { violations })?; materialize_default_mcp_versions(&mut policy); canonicalize_mcp_version_allowlists(&mut policy); + canonicalize_ui_defaults(&mut policy); debug_assert!( validate_sandbox_policy(&policy).is_ok(), "validated MCP canonicalization must preserve every policy invariant" @@ -2158,6 +2245,20 @@ pub fn validate_and_canonicalize_sandbox_policy( Ok(policy) } +/// Materialize protobuf UI defaults that have a distinct canonical enum value. +/// +/// Proto3 clients commonly leave `clipboard` at `Unspecified(0)`. `OpenShell` +/// defines that value as deny, so canonical policy state stores the equivalent +/// explicit `None(1)`. This keeps protobuf, YAML, hashes, and static-field +/// comparisons stable across a serialize/parse round trip. +fn canonicalize_ui_defaults(policy: &mut SandboxPolicy) { + if let Some(ui) = policy.ui.as_mut() + && ui.clipboard == UiClipboardAccess::Unspecified as i32 + { + ui.clipboard = UiClipboardAccess::None as i32; + } +} + /// Replace absent protobuf MCP options and empty revision lists with the /// single pinned policy default while preserving every explicit MCP option. pub(crate) fn materialize_default_mcp_versions(policy: &mut SandboxPolicy) { @@ -2277,6 +2378,98 @@ network_policies: assert!(json.get("network_policies").is_some()); } + #[test] + fn ui_absence_and_explicit_empty_remain_distinct() { + let absent = parse_sandbox_policy("version: 1\n").expect("absent UI parses"); + assert!(absent.ui.is_none()); + let absent_yaml = serialize_sandbox_policy(&absent).expect("absent UI serializes"); + assert!(!absent_yaml.contains("\nui:")); + + let explicit = parse_sandbox_policy("version: 1\nui: {}\n").expect("empty UI parses"); + let ui = explicit.ui.as_ref().expect("UI presence preserved"); + assert!(!ui.allow_graphical_ui); + assert_eq!(ui.clipboard, UiClipboardAccess::None as i32); + assert!(!ui.allow_input_injection); + + let explicit_yaml = serialize_sandbox_policy(&explicit).expect("empty UI serializes"); + assert!(explicit_yaml.contains("ui: {}"), "got:\n{explicit_yaml}"); + let reparsed = parse_sandbox_policy(&explicit_yaml).expect("empty UI reparses"); + assert!(reparsed.ui.is_some()); + } + + #[test] + fn ui_policy_round_trips_all_clipboard_directions() { + for (wire, expected) in [ + ("none", UiClipboardAccess::None), + ("read", UiClipboardAccess::Read), + ("write", UiClipboardAccess::Write), + ("all", UiClipboardAccess::All), + ] { + let yaml = format!( + "version: 1\nui:\n allow_graphical_ui: true\n clipboard: {wire}\n allow_input_injection: true\n" + ); + let policy = parse_sandbox_policy(&yaml).expect("UI policy parses"); + let ui = policy.ui.as_ref().expect("UI policy present"); + assert!(ui.allow_graphical_ui); + assert_eq!(ui.clipboard, expected as i32); + assert!(ui.allow_input_injection); + + let serialized = serialize_sandbox_policy(&policy).expect("UI policy serializes"); + let reparsed = parse_sandbox_policy(&serialized).expect("UI policy reparses"); + assert_eq!(reparsed, policy); + } + } + + #[test] + fn ui_unspecified_clipboard_canonicalizes_to_none_across_yaml_round_trip() { + let raw = SandboxPolicy { + ui: Some(UiPolicy { + allow_graphical_ui: true, + clipboard: UiClipboardAccess::Unspecified as i32, + ..Default::default() + }), + ..Default::default() + }; + + let canonical = validate_and_canonicalize_sandbox_policy(raw) + .expect("unspecified clipboard must be a valid deny default"); + assert_eq!( + canonical.ui.as_ref().expect("UI remains present").clipboard, + UiClipboardAccess::None as i32 + ); + + let yaml = serialize_sandbox_policy(&canonical).expect("canonical UI serializes"); + let reparsed = parse_sandbox_policy(&yaml).expect("canonical UI reparses"); + assert_eq!(reparsed, canonical); + } + + #[test] + fn ui_policy_rejects_unknown_yaml_clipboard_value() { + let error = parse_sandbox_policy("version: 1\nui:\n clipboard: execute\n") + .expect_err("unknown clipboard value must fail"); + assert!( + error + .to_string() + .contains("failed to parse sandbox policy YAML") + ); + } + + #[test] + fn ui_policy_validation_rejects_unknown_proto_clipboard_value() { + let policy = SandboxPolicy { + ui: Some(UiPolicy { + clipboard: 99, + ..Default::default() + }), + ..Default::default() + }; + let violations = validate_sandbox_policy(&policy).expect_err("unknown enum must fail"); + assert_eq!( + violations, + vec![PolicyViolation::InvalidUiClipboardAccess { value: 99 }] + ); + } + /// Verify that `allowed_ips` survives the round-trip. #[test] fn round_trip_preserves_allowed_ips() { @@ -3834,6 +4027,7 @@ network_policies: process: None, filesystem: None, landlock: None, + ui: None, network_policies: HashMap::new(), network_middlewares: HashMap::default(), }; @@ -4292,6 +4486,7 @@ network_policies: }), filesystem: None, landlock: None, + ui: None, network_policies: HashMap::new(), network_middlewares: HashMap::default(), }; @@ -4308,6 +4503,7 @@ network_policies: }), filesystem: None, landlock: None, + ui: None, network_policies: HashMap::new(), network_middlewares: HashMap::default(), }; @@ -4380,6 +4576,7 @@ network_policies: }), filesystem: None, landlock: None, + ui: None, network_policies: HashMap::new(), network_middlewares: HashMap::default(), }; diff --git a/crates/openshell-sdk/src/client.rs b/crates/openshell-sdk/src/client.rs index c236742739..6d41a2278d 100644 --- a/crates/openshell-sdk/src/client.rs +++ b/crates/openshell-sdk/src/client.rs @@ -349,6 +349,7 @@ impl OpenShellClient { let request = proto::DeleteSandboxRequest { name: name.to_string(), workspace_scope: Some(proto::workspace_selector("default")), + ..Default::default() }; async move { grpc.delete_sandbox(request).await } }) @@ -907,6 +908,7 @@ impl WorkspaceScopedClient { let request = proto::DeleteSandboxRequest { name: name.to_string(), workspace_scope: Some(proto::workspace_selector(&self.workspace)), + ..Default::default() }; async move { grpc.delete_sandbox(request).await } }) diff --git a/crates/openshell-server/src/compute/mod.rs b/crates/openshell-server/src/compute/mod.rs index fb44a4984d..be57ca32dd 100644 --- a/crates/openshell-server/src/compute/mod.rs +++ b/crates/openshell-server/src/compute/mod.rs @@ -35,9 +35,11 @@ use openshell_core::proto::compute::v1::{ gateway_listener_requirement::Selector, watch_sandboxes_event, }; use openshell_core::proto::{ - PlatformEvent, Sandbox, SandboxCondition, SandboxPhase, SandboxSpec, SandboxStatus, - SandboxTemplate, SandboxWorkloadTemplate, ServiceEndpoint, SshSession, + PlatformEvent, Sandbox, SandboxCondition, SandboxPhase, SandboxPolicy as ProtoSandboxPolicy, + SandboxSpec, SandboxStatus, SandboxTemplate, SandboxWorkloadTemplate, ServiceEndpoint, + SshSession, }; +use openshell_core::provider_credentials::ProviderCredentialState; use openshell_core::telemetry::TelemetryComputeDriver; use openshell_core::{ObjectLabels, ObjectWorkspace}; use prost::Message; @@ -63,6 +65,60 @@ pub type DriverWatchStream = Pin> + Send>>; pub type SharedComputeDriver = Arc + Send + Sync>; +pub type SandboxProviderCredentialsSink = Arc>>; + +/// Driver-specific values that must be delivered atomically with sandbox +/// creation without expanding the public compute-driver protobuf contract. +#[derive(Clone, Default)] +pub struct SandboxCreateRuntimeInputs { + pub effective_policy: Option, + pub provider_credentials: Option, +} + +impl SandboxCreateRuntimeInputs { + #[must_use] + pub(crate) fn new( + effective_policy: ProtoSandboxPolicy, + provider_credentials: Option, + ) -> Self { + Self { + effective_policy: Some(effective_policy), + provider_credentials, + } + } +} + +struct StagedProviderCredentials { + sink: SandboxProviderCredentialsSink, + sandbox_id: String, +} + +impl StagedProviderCredentials { + fn stage( + sink: SandboxProviderCredentialsSink, + sandbox_id: &str, + credentials: ProviderCredentialState, + ) -> Self { + let mut pending = sink + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + pending.insert(sandbox_id.to_string(), credentials); + drop(pending); + Self { + sink, + sandbox_id: sandbox_id.to_string(), + } + } +} + +impl Drop for StagedProviderCredentials { + fn drop(&mut self) { + self.sink + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(&self.sandbox_id); + } +} use traced_driver::TracedDriver; @@ -267,6 +323,16 @@ struct SandboxDeleteTarget { sandbox_name: String, } +/// Optional caller-owned preconditions for an identity-safe sandbox delete. +/// +/// These values are validated again while holding the sandbox lifecycle and +/// gateway-global locks, immediately before the durable `Deleting` mutation. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct SandboxDeletePreconditions { + pub expected_sandbox_id: Option, + pub expected_resource_version: Option, +} + /// Identity and driver result for a completed delete request. #[derive(Debug, Eq, PartialEq)] pub struct DeleteSandboxResult { @@ -293,6 +359,7 @@ enum BeginDelete { } #[derive(Debug, Clone)] +#[allow(clippy::struct_excessive_bools)] pub struct ComputeDriverInfoSnapshot { /// Gateway-selected driver name used for routing and `driver_config` keys. pub name: String, @@ -312,6 +379,9 @@ pub struct ComputeDriverInfoSnapshot { pub rootfs_tar_staging_dir: String, /// Maximum rootfs tar file size in bytes. pub rootfs_tar_max_bytes: u64, + /// Whether this configured driver instance completely enforces the portable + /// UI policy contract. + pub supports_ui_policy: bool, } /// Interval between store-vs-backend reconciliation sweeps. @@ -602,6 +672,7 @@ pub struct ComputeRuntime { telemetry_compute_driver: TelemetryComputeDriver, driver_process: Option>, default_image: String, + provider_credentials_sink: Option, store: Arc, sandbox_index: SandboxIndex, sandbox_watch_bus: SandboxWatchBus, @@ -611,6 +682,9 @@ pub struct ComputeRuntime { lifecycle_gates: Arc, gateway_listener_requirements: Vec, replica_id: String, + /// Dynamic TCP forward capability contributed by the active in-process + /// driver, if it has one. See `forward_sink`. + forward_sink: Option>, /// Gateway-issued staging slots for rootfs tar archives. Shared across /// clones: `ServerState` holds `ComputeRuntime` by value, so a per-clone /// table would make a token minted on one clone invisible to another. @@ -638,6 +712,7 @@ impl ComputeRuntime { driver_name: String, driver: SharedComputeDriver, driver_process: Option>, + provider_credentials_sink: Option, store: Arc, sandbox_index: SandboxIndex, sandbox_watch_bus: SandboxWatchBus, @@ -667,6 +742,7 @@ impl ComputeRuntime { resource_capabilities: capabilities.resource_capabilities, rootfs_tar_staging_dir: capabilities.rootfs_tar_staging_dir, rootfs_tar_max_bytes: capabilities.rootfs_tar_max_bytes, + supports_ui_policy: capabilities.supports_ui_policy, }; let default_image = capabilities.default_image; let gateway_listener_requirements = match driver @@ -734,6 +810,7 @@ impl ComputeRuntime { telemetry_compute_driver: TelemetryComputeDriver::custom(), driver_process, default_image, + provider_credentials_sink, store, sandbox_index, sandbox_watch_bus, @@ -743,6 +820,7 @@ impl ComputeRuntime { lifecycle_gates: Arc::new(LifecycleGateRegistry::default()), gateway_listener_requirements, replica_id: lease::replica_id(), + forward_sink: None, rootfs_tar_staging, }) } @@ -785,6 +863,7 @@ impl ComputeRuntime { endpoint.name, driver, endpoint.driver_process, + None, store, sandbox_index, sandbox_watch_bus, @@ -794,11 +873,33 @@ impl ComputeRuntime { .await } + /// Contributes a driver's dynamic TCP forward capability, if it has one. + /// Called at most once, right after `from_driver`, by the generic + /// `build_compute_runtime` construction path. + pub(crate) fn set_forward_sink(&mut self, sink: Arc) { + self.forward_sink = Some(sink); + } + + /// A driver-owned dynamic TCP forward capability, when the active driver + /// has one. `handle_forward_tcp` uses this as a fallback path for + /// sandboxes with no live `ConnectSupervisor` session (e.g. MXC, which + /// has no in-sandbox supervisor at all). `None` for every other driver. + #[must_use] + pub fn forward_sink(&self) -> Option<&Arc> { + self.forward_sink.as_ref() + } + #[must_use] pub fn default_image(&self) -> &str { &self.default_image } + /// Whether this in-process driver accepts create-time provider state. + #[must_use] + pub(crate) fn accepts_create_time_provider_credentials(&self) -> bool { + self.provider_credentials_sink.is_some() + } + #[must_use] pub fn driver_info_snapshots(&self) -> &[ComputeDriverInfoSnapshot] { std::slice::from_ref(&self.driver_info) @@ -904,8 +1005,26 @@ impl ComputeRuntime { } pub async fn validate_sandbox_create(&self, sandbox: &Sandbox) -> Result<(), Status> { + self.validate_sandbox_create_with_runtime_inputs( + sandbox, + &SandboxCreateRuntimeInputs::default(), + ) + .await + } + + pub(crate) async fn validate_sandbox_create_with_runtime_inputs( + &self, + sandbox: &Sandbox, + runtime_inputs: &SandboxCreateRuntimeInputs, + ) -> Result<(), Status> { + self.validate_policy_capabilities(sandbox, runtime_inputs.effective_policy.as_ref())?; let mut driver_sandbox = driver_sandbox_from_public(sandbox, &self.driver_info.name) .map_err(|status| *status)?; + if let Some(effective_policy) = runtime_inputs.effective_policy.as_ref() + && let Some(spec) = driver_sandbox.spec.as_mut() + { + spec.policy = Some(effective_policy.clone()); + } // Peek, never consume: create runs the same path immediately after and // must still find the token. if let Some(token) = take_staging_token(&mut driver_sandbox) { @@ -928,12 +1047,55 @@ impl ComputeRuntime { .map(|_| ()) } + fn validate_policy_capabilities( + &self, + sandbox: &Sandbox, + effective_policy: Option<&ProtoSandboxPolicy>, + ) -> Result<(), Status> { + let has_explicit_ui = effective_policy + .or_else(|| sandbox.spec.as_ref().and_then(|spec| spec.policy.as_ref())) + .and_then(|policy| policy.ui.as_ref()) + .is_some(); + if has_explicit_ui && !self.driver_info.supports_ui_policy { + return Err(Status::invalid_argument(format!( + "compute driver '{}' does not support the complete UI policy contract; remove the explicit ui section or select a supporting driver/backend", + self.driver_info.name + ))); + } + Ok(()) + } + pub async fn create_sandbox( &self, sandbox: Sandbox, sandbox_token: Option, await_main_process_attachment: bool, ) -> Result { + self.create_sandbox_with_runtime_inputs( + sandbox, + sandbox_token, + await_main_process_attachment, + SandboxCreateRuntimeInputs::default(), + ) + .await + } + + pub(crate) async fn create_sandbox_with_runtime_inputs( + &self, + sandbox: Sandbox, + sandbox_token: Option, + await_main_process_attachment: bool, + runtime_inputs: SandboxCreateRuntimeInputs, + ) -> Result { + // Defense in depth for internal callers that bypass the public create + // handler's ValidateSandboxCreate step. This check has no side effects. + self.validate_policy_capabilities(&sandbox, runtime_inputs.effective_policy.as_ref())?; + if runtime_inputs.provider_credentials.is_some() && self.provider_credentials_sink.is_none() + { + return Err(Status::internal( + "provider credentials supplied to a compute driver without a create-time sink", + )); + } let sandbox_id = sandbox.object_id().to_string(); let mut sandbox = sandbox; @@ -948,6 +1110,11 @@ impl ComputeRuntime { let mut driver_sandbox = driver_sandbox_from_public(&sandbox, &self.driver_info.name) .map_err(|status| *status)?; + if let Some(effective_policy) = runtime_inputs.effective_policy + && let Some(spec) = driver_sandbox.spec.as_mut() + { + spec.policy = Some(effective_policy); + } if let Some(staged) = staged.as_ref() { set_rootfs_tar_path(&mut driver_sandbox, staged.path()); } @@ -997,6 +1164,18 @@ impl ComputeRuntime { if let Some(spec) = driver_sandbox.spec.as_mut() { spec.await_main_process_attachment = await_main_process_attachment; } + let _staged_provider_credentials = match ( + self.provider_credentials_sink.clone(), + runtime_inputs.provider_credentials, + ) { + (Some(sink), Some(credentials)) => Some(StagedProviderCredentials::stage( + sink, + &sandbox_id, + credentials, + )), + (None, Some(_)) => unreachable!("provider credential sink checked before persistence"), + (_, None) => None, + }; match self .driver .call( @@ -1528,6 +1707,20 @@ impl ComputeRuntime { &self, workspace: &str, name: &str, + ) -> Result { + self.delete_sandbox_with_preconditions( + workspace, + name, + SandboxDeletePreconditions::default(), + ) + .await + } + + pub(crate) async fn delete_sandbox_with_preconditions( + &self, + workspace: &str, + name: &str, + preconditions: SandboxDeletePreconditions, ) -> Result { // Resolve and acquire both request-side locks before spawning the // owned worker. Cancellation while any of these awaits is pending is @@ -1538,6 +1731,15 @@ impl ComputeRuntime { .await .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? .ok_or_else(|| Status::not_found("sandbox not found"))?; + if preconditions + .expected_sandbox_id + .as_deref() + .is_some_and(|expected| expected != candidate.object_id()) + { + return Err(Status::aborted( + "sandbox identity does not match expected_sandbox_id", + )); + } let target = SandboxDeleteTarget { sandbox_id: candidate.object_id().to_string(), sandbox_name: candidate.object_name().to_string(), @@ -1555,7 +1757,7 @@ impl ComputeRuntime { tokio::spawn( async move { runtime - .delete_sandbox_inner(target, delete_guard, global_guard) + .delete_sandbox_inner(target, preconditions, delete_guard, global_guard) .await } .instrument(request_span), @@ -1571,6 +1773,7 @@ impl ComputeRuntime { async fn delete_sandbox_inner( &self, target: SandboxDeleteTarget, + preconditions: SandboxDeletePreconditions, delete_guard: SandboxLifecycleGuard, guard: tokio::sync::OwnedMutexGuard<()>, ) -> Result { @@ -1594,6 +1797,23 @@ impl ComputeRuntime { "sandbox name changed while the delete request was waiting; retry explicitly", )); } + if preconditions + .expected_sandbox_id + .as_deref() + .is_some_and(|expected| expected != current.object_id()) + { + return Err(Status::aborted( + "sandbox identity changed before delete mutation", + )); + } + if preconditions + .expected_resource_version + .is_some_and(|expected| expected != sandbox_resource_version(¤t)) + { + return Err(Status::aborted( + "sandbox resource version changed before delete mutation", + )); + } // `Started` carries both sides of the CAS transition: the durable // `Deleting` row used to fence recovery, and the prior row used only @@ -4780,6 +5000,7 @@ impl ComputeDriver for NoopTestDriver { resource_capabilities: None, rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, + supports_ui_policy: false, }, )) } @@ -4927,10 +5148,12 @@ pub async fn new_test_runtime_with_driver( resource_capabilities: None, rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, + supports_ui_policy: false, }, telemetry_compute_driver: TelemetryComputeDriver::custom(), driver_process: None, default_image: "openshell/sandbox:test".to_string(), + provider_credentials_sink: None, store, sandbox_index: SandboxIndex::new(), sandbox_watch_bus: SandboxWatchBus::new(), @@ -4940,6 +5163,7 @@ pub async fn new_test_runtime_with_driver( lifecycle_gates: Arc::new(LifecycleGateRegistry::default()), gateway_listener_requirements: Vec::new(), replica_id: "test-replica".to_string(), + forward_sink: None, rootfs_tar_staging: Arc::new(rootfs_tar::RootfsTarStagingRegistry::disabled()), } } @@ -4953,6 +5177,7 @@ mod tests { GetSandboxResponse, StartSandboxResponse, StopSandboxRequest, StopSandboxResponse, ValidateSandboxCreateResponse, WatchSandboxesDeletedEvent, WatchSandboxesSandboxEvent, }; + use openshell_core::proto::{SandboxPolicy as PublicSandboxPolicy, UiPolicy}; use std::collections::HashMap; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex as TestMutex}; @@ -5210,6 +5435,11 @@ mod tests { listed_sandboxes: Vec, current_sandboxes: Vec, workspace_rpcs_unimplemented: bool, + validate_create_calls: AtomicUsize, + create_calls: AtomicUsize, + provider_credentials_sink: Option, + provider_env_at_create: TestMutex>>, + created_sandboxes: TestMutex>, } #[tonic::async_trait] @@ -5242,6 +5472,7 @@ mod tests { resource_capabilities: None, rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, + supports_ui_policy: false, })) } @@ -5258,6 +5489,7 @@ mod tests { &self, _request: Request, ) -> Result, Status> { + self.validate_create_calls.fetch_add(1, Ordering::Relaxed); Ok(tonic::Response::new(ValidateSandboxCreateResponse {})) } @@ -5307,8 +5539,28 @@ mod tests { async fn create_sandbox( &self, - _request: Request, + request: Request, ) -> Result, Status> { + self.create_calls.fetch_add(1, Ordering::Relaxed); + if let Some(sandbox) = request.into_inner().sandbox { + if let Some(sink) = &self.provider_credentials_sink { + let credentials = sink + .lock() + .expect("provider credential staging lock poisoned") + .get(&sandbox.id) + .cloned() + .expect("provider credentials must be staged before driver create"); + *self + .provider_env_at_create + .lock() + .expect("provider env observation lock poisoned") = + Some(credentials.child_env_with_gcp_resolved()); + } + self.created_sandboxes + .lock() + .expect("created sandbox observation lock poisoned") + .push(sandbox); + } Ok(tonic::Response::new(CreateSandboxResponse {})) } @@ -5586,6 +5838,7 @@ mod tests { resource_capabilities: None, rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, + supports_ui_policy: false, })) } @@ -5799,10 +6052,12 @@ mod tests { resource_capabilities: None, rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, + supports_ui_policy: false, }, telemetry_compute_driver: TelemetryComputeDriver::custom(), driver_process: None, default_image: "openshell/sandbox:test".to_string(), + provider_credentials_sink: None, store, sandbox_index: SandboxIndex::new(), sandbox_watch_bus: SandboxWatchBus::new(), @@ -5812,10 +6067,205 @@ mod tests { lifecycle_gates: Arc::new(LifecycleGateRegistry::default()), gateway_listener_requirements: Vec::new(), replica_id: "test-replica".to_string(), + forward_sink: None, rootfs_tar_staging: Arc::new(rootfs_tar::RootfsTarStagingRegistry::disabled()), } } + fn sandbox_with_explicit_ui(id: &str) -> Sandbox { + let mut sandbox = sandbox_record(id, "ui-policy", SandboxPhase::Provisioning); + sandbox.spec = Some(SandboxSpec { + policy: Some(PublicSandboxPolicy { + ui: Some(UiPolicy::default()), + ..Default::default() + }), + ..Default::default() + }); + sandbox + } + + #[tokio::test] + async fn explicit_ui_policy_rejects_before_unsupported_driver_validation() { + let driver = Arc::new(TestDriver::default()); + let runtime = test_runtime(driver.clone()).await; + + let error = runtime + .validate_sandbox_create(&sandbox_with_explicit_ui("sb-ui-validate")) + .await + .expect_err("an unsupported driver must reject explicit UI policy"); + + assert_eq!(error.code(), Code::InvalidArgument); + assert!(error.message().contains("complete UI policy contract")); + assert_eq!( + driver.validate_create_calls.load(Ordering::Relaxed), + 0, + "gateway capability validation must run before the driver RPC" + ); + } + + #[tokio::test] + async fn explicit_ui_policy_rejects_before_unsupported_driver_create() { + let driver = Arc::new(TestDriver::default()); + let runtime = test_runtime(driver.clone()).await; + + let error = runtime + .create_sandbox(sandbox_with_explicit_ui("sb-ui-create"), None, false) + .await + .expect_err("an internal caller must not bypass UI capability validation"); + + assert_eq!(error.code(), Code::InvalidArgument); + assert_eq!( + driver.create_calls.load(Ordering::Relaxed), + 0, + "unsupported UI policy must fail before provisioning" + ); + } + + #[tokio::test] + async fn effective_ui_policy_rejects_before_unsupported_driver_validation() { + let driver = Arc::new(TestDriver::default()); + let runtime = test_runtime(driver.clone()).await; + let sandbox = sandbox_record( + "sb-effective-ui", + "effective-ui-policy", + SandboxPhase::Provisioning, + ); + let runtime_inputs = SandboxCreateRuntimeInputs::new( + PublicSandboxPolicy { + ui: Some(UiPolicy::default()), + ..Default::default() + }, + None, + ); + + let error = runtime + .validate_sandbox_create_with_runtime_inputs(&sandbox, &runtime_inputs) + .await + .expect_err("an unsupported driver must reject the effective UI policy"); + + assert_eq!(error.code(), Code::InvalidArgument); + assert_eq!(driver.validate_create_calls.load(Ordering::Relaxed), 0); + } + + #[tokio::test] + async fn explicit_ui_policy_reaches_driver_when_capability_is_complete() { + let driver = Arc::new(TestDriver::default()); + let mut runtime = test_runtime(driver.clone()).await; + runtime.driver_info.supports_ui_policy = true; + + runtime + .validate_sandbox_create(&sandbox_with_explicit_ui("sb-ui-supported")) + .await + .expect("a driver advertising complete UI support accepts validation"); + + assert_eq!(driver.validate_create_calls.load(Ordering::Relaxed), 1); + } + + #[tokio::test] + async fn absent_ui_policy_preserves_unsupported_driver_behavior() { + let driver = Arc::new(TestDriver::default()); + let runtime = test_runtime(driver.clone()).await; + let sandbox = sandbox_record("sb-no-ui", "no-ui-policy", SandboxPhase::Provisioning); + + runtime + .validate_sandbox_create(&sandbox) + .await + .expect("an absent UI section must preserve existing behavior"); + + assert_eq!(driver.validate_create_calls.load(Ordering::Relaxed), 1); + } + + #[tokio::test] + async fn create_runtime_inputs_reach_driver_without_persisting_effective_policy_or_secrets() { + let sink: SandboxProviderCredentialsSink = Arc::new(TestMutex::new(HashMap::new())); + let driver = Arc::new(TestDriver { + provider_credentials_sink: Some(sink.clone()), + ..Default::default() + }); + let mut runtime = test_runtime(driver.clone()).await; + runtime.provider_credentials_sink = Some(sink.clone()); + + let mut sandbox = sandbox_record( + "sb-provider-inputs", + "provider-inputs", + SandboxPhase::Provisioning, + ); + sandbox.spec = Some(SandboxSpec { + policy: Some(PublicSandboxPolicy { + version: 1, + ..Default::default() + }), + ..Default::default() + }); + let provider_credentials = ProviderCredentialState::from_environment( + 17, + HashMap::from([("GITHUB_TOKEN".to_string(), "raw-test-token".to_string())]), + HashMap::new(), + HashMap::new(), + ); + let runtime_inputs = SandboxCreateRuntimeInputs::new( + PublicSandboxPolicy { + version: 2, + ..Default::default() + }, + Some(provider_credentials), + ); + + runtime + .create_sandbox_with_runtime_inputs(sandbox, None, false, runtime_inputs) + .await + .expect("create should succeed"); + + let observed_env = driver + .provider_env_at_create + .lock() + .expect("provider env observation lock poisoned") + .clone() + .expect("driver should observe staged provider credentials"); + let observed_token = &observed_env["GITHUB_TOKEN"]; + assert_ne!(observed_token, "raw-test-token"); + assert!(observed_token.starts_with(openshell_core::secrets::PLACEHOLDER_PREFIX_PUBLIC)); + assert!( + sink.lock() + .expect("provider credential staging lock poisoned") + .is_empty(), + "create-time credentials must be removed after the driver call" + ); + + let observed_policy_version = { + let created = driver + .created_sandboxes + .lock() + .expect("created sandbox observation lock poisoned"); + created[0] + .spec + .as_ref() + .and_then(|spec| spec.policy.as_ref()) + .map(|policy| policy.version) + }; + assert_eq!( + observed_policy_version, + Some(2), + "the driver must receive the effective policy" + ); + + let persisted = runtime + .store + .get_message::("sb-provider-inputs") + .await + .expect("persisted sandbox lookup should succeed") + .expect("sandbox should be persisted"); + assert_eq!( + persisted + .spec + .as_ref() + .and_then(|spec| spec.policy.as_ref()) + .map(|policy| policy.version), + Some(1), + "the public sandbox must retain its base policy" + ); + } + async fn test_runtime_with_gateway_managed_lifecycle( driver: SharedComputeDriver, driver_name: &str, @@ -8438,6 +8888,133 @@ mod tests { ); } + #[tokio::test] + async fn identity_guarded_delete_rejects_a_different_sandbox_before_mutation() { + let driver = ControlledDriver::new(); + let runtime = test_runtime(driver.clone()).await; + let sandbox = sandbox_record("sb-current", "sandbox-a", SandboxPhase::Ready); + runtime.store.put_message(&sandbox).await.unwrap(); + + let error = runtime + .delete_sandbox_with_preconditions( + "default", + "sandbox-a", + SandboxDeletePreconditions { + expected_sandbox_id: Some("sb-stale".to_string()), + expected_resource_version: None, + }, + ) + .await + .unwrap_err(); + + assert_eq!(error.code(), Code::Aborted); + assert_eq!(driver.delete_calls(), 0); + let current = runtime + .store + .get_message::("sb-current") + .await + .unwrap() + .unwrap(); + assert_eq!( + SandboxPhase::try_from(current.phase()).unwrap(), + SandboxPhase::Ready + ); + } + + #[tokio::test] + async fn identity_guarded_delete_revalidates_resource_version_under_lifecycle_lock() { + let driver = ControlledDriver::new(); + let runtime = test_runtime(driver.clone()).await; + let sandbox = sandbox_record("sb-1", "sandbox-a", SandboxPhase::Ready); + runtime.store.put_message(&sandbox).await.unwrap(); + let current = runtime + .store + .get_message::(sandbox.object_id()) + .await + .unwrap() + .unwrap(); + let expected_resource_version = sandbox_resource_version(¤t); + + let delete_gate = runtime.lifecycle_gates.gate_for(sandbox.object_id()); + let delete_guard = delete_gate.lock().await; + let delete_runtime = runtime.clone(); + let delete = tokio::spawn(async move { + delete_runtime + .delete_sandbox_with_preconditions( + "default", + "sandbox-a", + SandboxDeletePreconditions { + expected_sandbox_id: Some("sb-1".to_string()), + expected_resource_version: Some(expected_resource_version), + }, + ) + .await + }); + tokio::time::timeout(Duration::from_secs(1), async { + while Arc::strong_count(&delete_gate) < 2 { + tokio::task::yield_now().await; + } + }) + .await + .expect("guarded delete did not start waiting on the sandbox gate"); + + runtime + .store + .update_message_cas::( + sandbox.object_id(), + expected_resource_version, + |sandbox| sandbox.set_current_policy_version(9), + ) + .await + .unwrap(); + drop(delete_guard); + + let error = delete.await.unwrap().unwrap_err(); + assert_eq!(error.code(), Code::Aborted); + assert_eq!(driver.delete_calls(), 0); + let current = runtime + .store + .get_message::(sandbox.object_id()) + .await + .unwrap() + .unwrap(); + assert_eq!(current.current_policy_version(), 9); + assert_eq!( + SandboxPhase::try_from(current.phase()).unwrap(), + SandboxPhase::Ready + ); + } + + #[tokio::test] + async fn identity_guarded_delete_accepts_the_exact_current_identity() { + let driver = ControlledDriver::new(); + let runtime = test_runtime(driver.clone()).await; + let sandbox = sandbox_record("sb-1", "sandbox-a", SandboxPhase::Ready); + runtime.store.put_message(&sandbox).await.unwrap(); + let current = runtime + .store + .get_message::(sandbox.object_id()) + .await + .unwrap() + .unwrap(); + + let result = runtime + .delete_sandbox_with_preconditions( + "default", + "sandbox-a", + SandboxDeletePreconditions { + expected_sandbox_id: Some(current.object_id().to_string()), + expected_resource_version: Some(sandbox_resource_version(¤t)), + }, + ) + .await + .unwrap(); + + assert!(result.deleted); + assert_eq!(result.sandbox_id, "sb-1"); + assert_eq!(driver.delete_calls(), 1); + } + #[tokio::test] async fn request_cancellation_does_not_cancel_the_delete_worker() { let driver = ControlledDriver::new(); @@ -9946,6 +10523,7 @@ mod tests { }), workspace: "default".to_string(), }], + ..Default::default() })) .await; @@ -10117,6 +10695,7 @@ mod tests { })), workspace: "default".to_string(), }], + ..Default::default() })) .await; @@ -10912,6 +11491,7 @@ mod tests { "test-driver".to_string(), Arc::new(TestDriver::default()), None, + None, store, SandboxIndex::new(), SandboxWatchBus::new(), diff --git a/crates/openshell-server/src/credentials.rs b/crates/openshell-server/src/credentials.rs index e1ea5994cc..eadd747f86 100644 --- a/crates/openshell-server/src/credentials.rs +++ b/crates/openshell-server/src/credentials.rs @@ -107,6 +107,10 @@ pub trait CredentialDriver: std::fmt::Debug + Send + Sync { pub struct ResolvedProviderCredentials { pub values: HashMap, pub expires_at_ms: HashMap, + /// Keys returned by a credential driver whose effective expiration has + /// already passed. Values stay withheld, but create-time consumers need + /// the identities to fail closed instead of silently omitting credentials. + pub expired_keys: HashSet, } #[derive(Debug, Clone, Copy)] @@ -706,6 +710,7 @@ impl CredentialRuntime { effective_expires_at_ms, "skipping expired handle-backed credential" ); + resolved.expired_keys.insert(credential_key); continue; } if effective_expires_at_ms > 0 { diff --git a/crates/openshell-server/src/grpc/mod.rs b/crates/openshell-server/src/grpc/mod.rs index 97f07164ba..77c6c1e7e1 100644 --- a/crates/openshell-server/src/grpc/mod.rs +++ b/crates/openshell-server/src/grpc/mod.rs @@ -236,6 +236,7 @@ impl OpenShell for OpenShellService { .resource_capabilities .as_ref() .map(|resources| public_resource_capabilities(*resources)), + supports_ui_policy: driver.supports_ui_policy, }), }) .collect(); diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index 8abdfd36d0..cd71e8064f 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -1716,6 +1716,20 @@ async fn auto_approve_chunk( Ok(()) } +/// Preserve UI as the sandbox's startup contract when applying a global policy. +/// +/// UI controls are enforced by the compute runtime before the workload starts, +/// so a later global override cannot safely add, remove, or change them. Global +/// policy writes reject their own UI section; this overlay also prevents a +/// global dynamic policy from hiding the UI state that the runtime enforced. +fn preserve_sandbox_startup_ui(policy: &mut ProtoSandboxPolicy, sandbox: &Sandbox) { + policy.ui = sandbox + .spec + .as_ref() + .and_then(|spec| spec.policy.as_ref()) + .and_then(|policy| policy.ui); +} + // TODO: share effective-policy lookup with `load_sandbox_policy` / // `GetSandboxConfig`. They re-implement very similar global-settings and // profile-composition logic; consolidating them is out of scope for the @@ -1732,20 +1746,40 @@ async fn current_effective_policy_for_sandbox( .as_ref() .map(|spec| spec.providers.clone()) .unwrap_or_default(); + let provider_records = super::provider::load_provider_environment_records( + state.store.as_ref(), + workspace, + &provider_names, + ) + .await?; + current_effective_policy_for_sandbox_with_records( + state, + catalog, + sandbox, + sandbox_id, + &provider_records, + ) + .await +} + +async fn current_effective_policy_for_sandbox_with_records( + state: &ServerState, + catalog: &EffectiveProviderProfileCatalog, + sandbox: &Sandbox, + sandbox_id: &str, + provider_records: &[super::provider::ProviderEnvironmentRecord], +) -> Result { let global_settings = load_global_settings(state.store.as_ref()).await?; - if let Some(global_policy) = decode_policy_from_global_settings(&global_settings)? { - // A global policy is the complete effective policy. Dormant sandbox - // history and specs may predate the current schema, but they must not - // prevent the valid global policy from being served. - return apply_effective_policy_context( - state, + if let Some(mut global_policy) = decode_policy_from_global_settings(&global_settings)? { + // A global policy replaces dynamic policy, but startup-only UI remains + // anchored to the sandbox spec so reads cannot misrepresent enforcement. + preserve_sandbox_startup_ui(&mut global_policy, sandbox); + return apply_effective_policy_context_from_records( catalog, - workspace, - &provider_names, + provider_records, global_policy, PolicySource::Global, - ) - .await; + ); } let policy = if let Some(record) = state @@ -1764,15 +1798,12 @@ async fn current_effective_policy_for_sandbox( } }; - apply_effective_policy_context( - state, + apply_effective_policy_context_from_records( catalog, - workspace, - &provider_names, + provider_records, policy, PolicySource::Sandbox, ) - .await } async fn effective_policy_for_source( @@ -1807,17 +1838,26 @@ async fn apply_effective_policy_context( catalog: &EffectiveProviderProfileCatalog, workspace: &str, provider_names: &[String], - mut policy: ProtoSandboxPolicy, + policy: ProtoSandboxPolicy, policy_source: PolicySource, ) -> Result { - clear_provider_credentialed_markers(&mut policy); - let mut provider_context = provider_policy_context_with_catalog( + let provider_records = super::provider::load_provider_environment_records( state.store.as_ref(), - catalog, workspace, provider_names, ) .await?; + apply_effective_policy_context_from_records(catalog, &provider_records, policy, policy_source) +} + +fn apply_effective_policy_context_from_records( + catalog: &EffectiveProviderProfileCatalog, + provider_records: &[super::provider::ProviderEnvironmentRecord], + mut policy: ProtoSandboxPolicy, + policy_source: PolicySource, +) -> Result { + clear_provider_credentialed_markers(&mut policy); + let mut provider_context = provider_policy_context_from_records(catalog, provider_records); if !matches!(policy_source, PolicySource::Global) && !provider_context.layers.is_empty() { policy = compose_effective_policy(&policy, &provider_context.layers); } @@ -2523,9 +2563,10 @@ pub(super) async fn handle_get_sandbox_config( .await .map_err(|e| Status::internal(format!("fetch policy history failed: {e}")))?; - let (mut policy, version, mut policy_hash, policy_source) = if let Some(global_policy) = + let (mut policy, version, mut policy_hash, policy_source) = if let Some(mut global_policy) = global_policy { + preserve_sandbox_startup_ui(&mut global_policy, &sandbox); let version = latest .as_ref() .map(|record| u32::try_from(record.version).unwrap_or(0)) @@ -2987,16 +3028,23 @@ async fn provider_policy_context_with_catalog( workspace: &str, provider_names: &[String], ) -> Result { + let records = + super::provider::load_provider_environment_records(store, workspace, provider_names) + .await?; + Ok(provider_policy_context_from_records(catalog, &records)) +} + +fn provider_policy_context_from_records( + catalog: &EffectiveProviderProfileCatalog, + records: &[super::provider::ProviderEnvironmentRecord], +) -> ProviderPolicyContext { let mut layers = Vec::new(); let mut credentialed_scopes = Vec::new(); let mut endpointless_provider_names = HashSet::new(); - for name in provider_names { - let provider = store - .get_message_by_name::(workspace, name) - .await - .map_err(|e| Status::internal(format!("failed to fetch provider '{name}': {e}")))? - .ok_or_else(|| Status::failed_precondition(format!("provider '{name}' not found")))?; + for record in records { + let name = &record.name; + let provider = &record.provider; let provider_type = provider.r#type.trim(); let Some(profile) = super::provider::get_provider_type_profile_for_scope( @@ -3012,7 +3060,7 @@ async fn provider_policy_context_with_catalog( continue; }; - if !super::provider::provider_profile_endpoints_are_active(&profile, &provider) { + if !super::provider::provider_profile_endpoints_are_active(&profile, provider) { endpointless_provider_names.insert(name.clone()); continue; } @@ -3041,11 +3089,11 @@ async fn provider_policy_context_with_catalog( }); } - Ok(ProviderPolicyContext { + ProviderPolicyContext { layers, credentialed_scopes, endpointless_provider_names, - }) + } } fn endpoint_ports(endpoint: &NetworkEndpoint) -> Vec { @@ -3226,21 +3274,167 @@ pub(super) async fn handle_get_gateway_config( })) } +/// Resolve the effective policy and provider credential snapshot required by +/// an in-process compute driver at sandbox creation time. +/// +/// The policy, revision, endpoint bindings, and environment are all derived +/// from one immutable provider-record snapshot. Raw static credentials remain +/// in the returned resolver state; only revision-scoped placeholders are +/// exposed through its child environment. +pub(super) async fn resolve_sandbox_create_runtime_inputs( + state: &ServerState, + sandbox: &Sandbox, +) -> Result { + if !state.compute.accepts_create_time_provider_credentials() { + return Ok(crate::compute::SandboxCreateRuntimeInputs::default()); + } + + let sandbox_id = sandbox.object_id(); + let workspace = sandbox.object_workspace(); + let provider_names = sandbox + .spec + .as_ref() + .map(|spec| spec.providers.as_slice()) + .unwrap_or_default(); + let provider_profile_catalog = state + .provider_profile_sources + .snapshot_catalog(state.store.as_ref(), workspace) + .await?; + let provider_records = super::provider::load_provider_environment_records( + state.store.as_ref(), + workspace, + provider_names, + ) + .await?; + let effective_policy = current_effective_policy_for_sandbox_with_records( + state, + &provider_profile_catalog, + sandbox, + sandbox_id, + &provider_records, + ) + .await?; + let policy_credential_bindings = + policy_static_credential_endpoint_bindings(Some(&effective_policy))?; + validate_policy_credential_binding_context( + &provider_profile_catalog, + &provider_records, + &effective_policy, + &policy_credential_bindings, + )?; + let provider_env_revision = compute_provider_env_revision_from_records_and_policy_bindings( + &provider_profile_catalog, + &provider_records, + &policy_credential_bindings, + )?; + let mut provider_environment = + super::provider::resolve_provider_environment_from_records_with_policy_bindings_and_credentials( + state.store.as_ref(), + &provider_profile_catalog, + &provider_records, + &policy_credential_bindings, + &state.credentials, + Some(sandbox_id), + ) + .await?; + + // MXC uses the binding-capable host proxy. Withhold any static value that + // has no endpoint binding instead of exposing it directly to the process. + let unbound_static_keys = provider_environment + .static_credential_keys + .iter() + .filter(|key| { + !provider_environment + .static_credential_bindings + .contains_key(*key) + }) + .cloned() + .collect::>(); + for key in unbound_static_keys { + warn!( + sandbox_id, + key = %key, + "withholding unbound static provider credential from MXC sandbox" + ); + provider_environment.environment.remove(&key); + provider_environment.credential_expires_at_ms.remove(&key); + provider_environment.static_credential_keys.remove(&key); + } + validate_create_time_provider_credential_lifetimes(sandbox_id, &provider_environment)?; + + let provider_credentials = if provider_records.is_empty() { + None + } else { + let non_secret_environment_keys = provider_environment + .environment + .keys() + .filter(|key| !provider_environment.static_credential_keys.contains(*key)) + .cloned() + .collect(); + Some( + openshell_core::provider_credentials::ProviderCredentialState::from_bound_environment( + provider_env_revision, + provider_environment.environment, + provider_environment.credential_expires_at_ms, + provider_environment.dynamic_credentials, + provider_environment.static_credential_bindings, + non_secret_environment_keys, + ) + .map_err(|error| { + Status::failed_precondition(format!( + "invalid provider credential binding for sandbox '{sandbox_id}': {error}" + )) + })?, + ) + }; + + Ok(crate::compute::SandboxCreateRuntimeInputs::new( + effective_policy, + provider_credentials, + )) +} + +fn validate_create_time_provider_credential_lifetimes( + sandbox_id: &str, + provider_environment: &super::provider::ProviderEnvironment, +) -> Result<(), Status> { + let mut expiring_static_keys = provider_environment + .static_credential_keys + .iter() + .filter(|key| { + provider_environment + .credential_expires_at_ms + .get(*key) + .is_some_and(|expires_at_ms| *expires_at_ms > 0) + }) + .chain(provider_environment.expired_static_keys.iter()) + .cloned() + .collect::>() + .into_iter() + .collect::>(); + expiring_static_keys.sort(); + + if expiring_static_keys.is_empty() { + Ok(()) + } else { + Err(Status::failed_precondition(format!( + "compute driver cannot refresh expiring or already-expired provider credentials for sandbox '{sandbox_id}'; recreate the sandbox with non-expiring, current credentials (affected keys: {})", + expiring_static_keys.join(", ") + ))) + } +} + pub(super) async fn handle_get_sandbox_provider_environment( state: &Arc, request: Request, ) -> Result, Status> { let sandbox_id = request.get_ref().sandbox_id.clone(); let supports_static_credential_bindings = request.get_ref().supports_static_credential_bindings; - crate::auth::guard::enforce_sandbox_scope(&request, &sandbox_id)?; + let principal = crate::auth::guard::enforce_sandbox_scope(&request, &sandbox_id)?; drop(request); - let sandbox = state - .store - .get_message::(&sandbox_id) - .await - .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? - .ok_or_else(|| Status::not_found("sandbox not found"))?; + let sandbox = + super::sandbox::fetch_and_authorize_sandbox(state, &principal, &sandbox_id).await?; let workspace = sandbox.object_workspace().to_string(); let spec = sandbox @@ -3259,12 +3453,12 @@ pub(super) async fn handle_get_sandbox_provider_environment( &provider_names, ) .await?; - let effective_policy = current_effective_policy_for_sandbox( + let effective_policy = current_effective_policy_for_sandbox_with_records( state.as_ref(), &provider_profile_catalog, - &workspace, &sandbox, &sandbox_id, + &provider_records, ) .await?; let policy_credential_bindings = @@ -3461,6 +3655,11 @@ async fn handle_update_config_inner( clear_provider_credentialed_markers(&mut new_policy); validate_no_reserved_provider_policy_keys(&new_policy)?; new_policy = validate_and_canonicalize_policy(new_policy)?; + if new_policy.ui.is_some() { + return Err(Status::invalid_argument( + "UI policy cannot be set globally because it is applied at sandbox startup; configure ui in each sandbox policy", + )); + } validate_policy_safety(&new_policy)?; crate::middleware::validate_policy(state.middleware_registry.as_ref(), &new_policy) .await?; @@ -3865,7 +4064,10 @@ async fn handle_update_config_inner( } let should_backfill_policy = if let Some(baseline_policy) = spec.policy.as_ref() { - let comparable_baseline = baseline_policy.clone(); + let comparable_baseline = validate_and_canonicalize_stored_policy( + baseline_policy.clone(), + STORED_POLICY_SOURCE_SPEC, + )?; validate_static_fields_unchanged(&comparable_baseline, &new_policy)?; false } else { @@ -6473,17 +6675,17 @@ async fn sandbox_policy_merge_validation_data_with_catalog( ) -> Result { let global_settings = load_global_settings(state.store.as_ref()).await?; let composition_enabled = provider_policy_composition_enabled_in(&global_settings)?; - let ProviderPolicyContext { - layers, - credentialed_scopes, - endpointless_provider_names, - } = provider_policy_context_with_catalog( + let records = super::provider::load_provider_environment_records( state.store.as_ref(), - catalog, workspace, provider_names, ) .await?; + let ProviderPolicyContext { + layers, + credentialed_scopes, + endpointless_provider_names, + } = provider_policy_context_from_records(catalog, &records); let provider_layers = if composition_enabled { layers } else { @@ -6494,12 +6696,6 @@ async fn sandbox_policy_merge_validation_data_with_catalog( provider_layer_count = provider_layers.len(), "Composed provider policy and credential context for merge validation" ); - let records = super::provider::load_provider_environment_records( - state.store.as_ref(), - workspace, - provider_names, - ) - .await?; Ok(SandboxPolicyMergeValidationData { provider_layers, catalog: catalog.clone(), @@ -7110,6 +7306,7 @@ mod tests { use crate::auth::principal::{ Principal, SandboxIdentitySource, SandboxPrincipal, UserPrincipal, }; + use crate::grpc::provider::ProviderEnvironment; use crate::grpc::test_support::{authed_request, test_server_state}; use crate::persistence::test_store; use std::collections::HashMap; @@ -8006,6 +8203,66 @@ mod tests { ); } + #[tokio::test] + async fn global_policy_preserves_each_sandbox_startup_ui_contract() { + use openshell_core::proto::{UiClipboardAccess, UiPolicy}; + + let state = test_server_state().await; + let global_policy = install_test_global_policy(&state).await; + assert!(global_policy.ui.is_none()); + + let sandbox_id = "global-preserves-startup-ui"; + let startup_ui = UiPolicy { + allow_graphical_ui: true, + clipboard: UiClipboardAccess::Read as i32, + allow_input_injection: false, + }; + let mut sandbox_policy = openshell_policy::restrictive_default_policy(); + sandbox_policy.ui = Some(startup_ui); + let sandbox = test_sandbox(sandbox_id, sandbox_id, sandbox_policy, Vec::new()); + state + .store + .put_message(&sandbox) + .await + .expect("store sandbox"); + + let response = handle_get_sandbox_config( + &state, + with_sandbox( + Request::new(GetSandboxConfigRequest { + sandbox_id: sandbox_id.to_string(), + }), + sandbox_id, + ), + ) + .await + .expect("global policy read must preserve startup UI") + .into_inner(); + assert_eq!( + response + .policy + .as_ref() + .and_then(|policy| policy.ui.as_ref()), + Some(&startup_ui) + ); + + let catalog = state + .provider_profile_sources + .snapshot_catalog(state.store.as_ref(), "default") + .await + .expect("provider profile catalog"); + let effective = current_effective_policy_for_sandbox( + state.as_ref(), + &catalog, + "default", + &sandbox, + sandbox_id, + ) + .await + .expect("effective policy lookup must preserve startup UI"); + assert_eq!(effective.ui.as_ref(), Some(&startup_ui)); + } + #[tokio::test] async fn canonical_mcp_version_order_produces_identical_policy_bytes_and_hashes() { let state = test_server_state().await; @@ -8103,6 +8360,93 @@ mod tests { } } + #[tokio::test] + async fn global_policy_ingress_rejects_startup_only_ui_before_persistence() { + use openshell_core::proto::UiPolicy; + + let state = test_server_state().await; + let mut policy = openshell_policy::restrictive_default_policy(); + policy.ui = Some(UiPolicy::default()); + + let error = handle_update_config( + &state, + with_user(Request::new(UpdateConfigRequest { + global: true, + policy: Some(policy), + ..Default::default() + })), + ) + .await + .expect_err("global UI must be rejected before persistence"); + + assert_eq!(error.code(), Code::InvalidArgument); + assert!(error.message().contains("sandbox startup")); + assert!( + state + .store + .get_latest_policy(GLOBAL_POLICY_SANDBOX_ID) + .await + .expect("global policy lookup") + .is_none() + ); + let settings = load_global_settings(state.store.as_ref()) + .await + .expect("global settings lookup"); + assert!(!settings.settings.contains_key(POLICY_SETTING_KEY)); + } + + #[tokio::test] + async fn sandbox_policy_update_accepts_semantically_unchanged_ui_default() { + use openshell_core::proto::{UiClipboardAccess, UiPolicy}; + + let state = test_server_state().await; + let sandbox_id = "ui-default-roundtrip"; + let mut canonical = openshell_policy::restrictive_default_policy(); + canonical.ui = Some(UiPolicy { + allow_graphical_ui: true, + clipboard: UiClipboardAccess::None as i32, + ..Default::default() + }); + state + .store + .put_message(&test_sandbox( + sandbox_id, + sandbox_id, + canonical.clone(), + Vec::new(), + )) + .await + .expect("store sandbox"); + + let mut protobuf_default = canonical; + protobuf_default.ui.as_mut().expect("UI policy").clipboard = + UiClipboardAccess::Unspecified as i32; + handle_update_config( + &state, + with_user(Request::new(UpdateConfigRequest { + name: sandbox_id.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), + policy: Some(protobuf_default), + ..Default::default() + })), + ) + .await + .expect("unspecified and none are the same static UI policy"); + + let stored = state + .store + .get_latest_policy(sandbox_id) + .await + .expect("policy history lookup") + .expect("policy revision"); + let persisted = ProtoSandboxPolicy::decode(stored.policy_payload.as_slice()) + .expect("decode persisted policy"); + assert_eq!( + persisted.ui.expect("persisted UI").clipboard, + UiClipboardAccess::None as i32 + ); + } + #[tokio::test] async fn policy_record_identity_global_deduplicates_defaulted_mcp_history() { for (case, legacy_policy) in defaulted_mcp_policy_cases() { @@ -11333,6 +11677,63 @@ mod tests { assert_eq!(v2_env.get("GITHUB_TOKEN"), Some(&"ghp-test".to_string())); } + #[test] + fn create_time_provider_credentials_reject_expiring_static_values() { + let provider_environment = ProviderEnvironment { + credential_expires_at_ms: HashMap::from([ + ("B_TOKEN".to_string(), 20_000), + ("A_TOKEN".to_string(), 10_000), + ("NON_SECRET".to_string(), 30_000), + ]), + static_credential_keys: HashSet::from(["A_TOKEN".to_string(), "B_TOKEN".to_string()]), + ..Default::default() + }; + + let error = validate_create_time_provider_credential_lifetimes( + "sandbox-expiring", + &provider_environment, + ) + .expect_err("expiring static credentials must fail closed"); + + assert_eq!(error.code(), Code::FailedPrecondition); + assert!(error.message().contains("A_TOKEN, B_TOKEN")); + assert!(!error.message().contains("NON_SECRET")); + } + + #[test] + fn create_time_provider_credentials_allow_non_expiring_static_values() { + let provider_environment = ProviderEnvironment { + credential_expires_at_ms: HashMap::from([("STATIC_TOKEN".to_string(), 0)]), + static_credential_keys: HashSet::from(["STATIC_TOKEN".to_string()]), + ..Default::default() + }; + + validate_create_time_provider_credential_lifetimes("sandbox-static", &provider_environment) + .expect("non-expiring static credentials are supported"); + } + + #[test] + fn create_time_provider_credentials_reject_already_expired_static_values() { + // The shared resolver withholds already-expired static credentials + // entirely -- they never appear in `static_credential_keys` or + // `credential_expires_at_ms` -- so this check must consult + // `expired_static_keys` independently instead of silently allowing + // sandbox creation without the configured credential. + let provider_environment = ProviderEnvironment { + expired_static_keys: HashSet::from(["GITHUB_TOKEN".to_string()]), + ..Default::default() + }; + + let error = validate_create_time_provider_credential_lifetimes( + "sandbox-expired", + &provider_environment, + ) + .expect_err("already-expired static credentials must fail closed"); + + assert_eq!(error.code(), Code::FailedPrecondition); + assert!(error.message().contains("GITHUB_TOKEN")); + } + #[tokio::test] async fn provider_environment_withholds_static_credentials_from_legacy_supervisors() { use openshell_core::proto::GetSandboxProviderEnvironmentRequest; @@ -18534,6 +18935,35 @@ mod tests { ); } + #[test] + fn policy_hash_distinguishes_ui_absence_presence_and_values() { + use openshell_core::proto::{UiClipboardAccess, UiPolicy}; + + let absent = ProtoSandboxPolicy::default(); + let explicit_deny = ProtoSandboxPolicy { + ui: Some(UiPolicy::default()), + ..Default::default() + }; + let clipboard_read = ProtoSandboxPolicy { + ui: Some(UiPolicy { + clipboard: UiClipboardAccess::Read as i32, + ..Default::default() + }), + ..Default::default() + }; + + assert_ne!( + deterministic_policy_hash(&absent), + deterministic_policy_hash(&explicit_deny), + "an explicitly present deny-only UI block remains hash-significant" + ); + assert_ne!( + deterministic_policy_hash(&explicit_deny), + deterministic_policy_hash(&clipboard_read), + "UI capability changes must produce a new policy hash" + ); + } + #[test] fn policy_hash_is_stable_across_middleware_config_field_insertion_order() { use prost_types::{Struct, Value, value::Kind}; @@ -20931,6 +21361,22 @@ mod tests { "handle_get_sandbox_config must return NotFound, not PermissionDenied" ); + // --- handle_get_sandbox_provider_environment --- + let err = handle_get_sandbox_provider_environment( + &state, + non_member_request(GetSandboxProviderEnvironmentRequest { + sandbox_id: "sandbox-other".into(), + supports_static_credential_bindings: true, + }), + ) + .await + .unwrap_err(); + assert_eq!( + err.code(), + Code::NotFound, + "handle_get_sandbox_provider_environment must hide cross-workspace sandboxes" + ); + // --- handle_get_sandbox_logs --- let err = handle_get_sandbox_logs( &state, diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index e3cb3f6b20..e0a83890c4 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -68,6 +68,13 @@ pub(super) struct ProviderEnvironment { pub dynamic_credentials: HashMap, pub static_credential_bindings: HashMap, pub static_credential_keys: HashSet, + /// Static credential keys withheld because they were already expired at + /// resolution time. Excluded from `environment`/`static_credential_keys` + /// like any other withheld key, but tracked separately so create-time + /// callers (see `validate_create_time_provider_credential_lifetimes`) can + /// fail closed instead of silently creating a sandbox without the + /// configured credential. + pub expired_static_keys: HashSet, } /// Immutable provider records used to build one provider-environment response. @@ -1117,6 +1124,7 @@ pub(super) async fn resolve_provider_environment_from_records_with_policy_bindin let mut expires = HashMap::new(); let mut static_credential_bindings = HashMap::new(); let mut static_credential_keys = HashSet::new(); + let mut expired_static_keys = HashSet::new(); let now_ms = crate::persistence::current_time_ms(); validate_provider_environment_records_unique_at(store, catalog, records, now_ms).await?; let registry = openshell_providers::ProviderRegistry::new(); @@ -1235,6 +1243,7 @@ pub(super) async fn resolve_provider_environment_from_records_with_policy_bindin expires_at_ms, "skipping expired provider credential" ); + expired_static_keys.insert(key.clone()); continue; } if expires_at_ms > 0 { @@ -1338,6 +1347,23 @@ pub(super) async fn resolve_provider_environment_from_records_with_policy_bindin } } + // The credential runtime withholds expired handle-backed values. Keep + // the identity of any otherwise injectable key so MXC create-time + // validation can reject the incomplete credential snapshot. + for key in resolved_refs.expired_keys { + if accepted_stored_credential_keys + .as_ref() + .is_some_and(|accepted| !accepted.contains(&key)) + || is_non_injectable_provider_credential(provider, &key) + || broker_only_credential_keys.contains(&key) + || has_no_usable_endpoint + || !is_valid_env_key(&key) + { + continue; + } + expired_static_keys.insert(key); + } + // Build each provider's emitted environment independently so another // provider's earlier output cannot change how this provider classifies // or populates its own keys. Cross-provider credential/config @@ -1354,6 +1380,7 @@ pub(super) async fn resolve_provider_environment_from_records_with_policy_bindin dynamic_credentials: resolve_dynamic_credentials_from_records(catalog, records), static_credential_bindings, static_credential_keys, + expired_static_keys, }) } @@ -10572,6 +10599,49 @@ mod tests { ); } + #[tokio::test] + async fn resolve_provider_env_preserves_expired_handle_key_for_create_time_rejection() { + let store = test_store().await; + let config = openshell_core::Config::new(None).with_credential_drivers(["test-static"]); + let credentials = crate::credentials::CredentialRuntime::from_config(&config).unwrap(); + let catalog = ProviderProfileSources::with_default_sources() + .snapshot_catalog(&store, "default") + .await + .unwrap(); + let mut provider = provider_with_credential_value( + "github-expired", + "github", + "GITHUB_TOKEN", + "github-token", + ); + provider.credential_expires_at_ms.insert( + "GITHUB_TOKEN".to_string(), + crate::persistence::current_time_ms() - 1, + ); + create_provider_record_validating( + &store, + "default", + &catalog, + provider, + Some(&credentials), + ) + .await + .unwrap(); + + let result = resolve_provider_environment_with_credentials( + &store, + &catalog, + "default", + &["github-expired".to_string()], + &credentials, + ) + .await + .unwrap(); + + assert!(!result.contains_key("GITHUB_TOKEN")); + assert!(result.expired_static_keys.contains("GITHUB_TOKEN")); + } + #[tokio::test] async fn resolve_provider_env_skips_expired_credentials_and_returns_expiry_metadata() { let store = test_store().await; diff --git a/crates/openshell-server/src/grpc/sandbox.rs b/crates/openshell-server/src/grpc/sandbox.rs index 83e6358df2..cc16a141db 100644 --- a/crates/openshell-server/src/grpc/sandbox.rs +++ b/crates/openshell-server/src/grpc/sandbox.rs @@ -14,12 +14,13 @@ use crate::auth::workspace_authz::{ AuthorizedWorkspaceScope, MinWorkspaceRole, authorize_list_workspace_selector, authorize_sandbox_workspace, authorize_workspace_selector, }; +use crate::compute::SandboxDeletePreconditions; use crate::pagination::Pagination; use crate::persistence::{ ObjectLabels, ObjectListQuery, ObjectType, WriteCondition, generate_name, }; use futures::future; -use openshell_core::net::set_tcp_nodelay_best_effort; +use openshell_core::net::{connect_tcp_nodelay_best_effort, set_tcp_nodelay_best_effort}; use openshell_core::proto::datamodel::v1::ObjectMeta; use openshell_core::proto::{ AttachSandboxProviderRequest, AttachSandboxProviderResponse, CreateSandboxRequest, @@ -490,9 +491,12 @@ async fn handle_create_sandbox_inner( ) .await?; + let runtime_inputs = + super::policy::resolve_sandbox_create_runtime_inputs(state.as_ref(), &sandbox).await?; + state .compute - .validate_sandbox_create(&sandbox) + .validate_sandbox_create_with_runtime_inputs(&sandbox, &runtime_inputs) .await .map_err(|status| { warn!(error = %status, "Rejecting sandbox create request"); @@ -518,7 +522,12 @@ async fn handle_create_sandbox_inner( let mut sandbox = state .compute - .create_sandbox(sandbox, sandbox_token, await_main_process_attachment) + .create_sandbox_with_runtime_inputs( + sandbox, + sandbox_token, + await_main_process_attachment, + runtime_inputs, + ) .await?; state .supervisor_sessions @@ -1343,8 +1352,22 @@ async fn handle_delete_sandbox_inner( let workspace = super::workspace::resolve_workspace(state.store.as_ref(), &authz.workspace) .await? .name; + if req.expected_resource_version != 0 && req.expected_sandbox_id.is_empty() { + return Err(Status::invalid_argument( + "expected_resource_version requires expected_sandbox_id", + )); + } - let result = state.compute.delete_sandbox(&workspace, &name).await?; + let preconditions = SandboxDeletePreconditions { + expected_sandbox_id: (!req.expected_sandbox_id.is_empty()) + .then_some(req.expected_sandbox_id), + expected_resource_version: (req.expected_resource_version != 0) + .then_some(req.expected_resource_version), + }; + let result = state + .compute + .delete_sandbox_with_preconditions(&workspace, &name, preconditions) + .await?; if result.deleted { state.telemetry.end_sandbox_session(&result.sandbox_id); } @@ -1917,6 +1940,69 @@ pub(super) async fn handle_forward_tcp( } let connection_guard = acquire_forward_connection_guard(state, &init, &sandbox).await?; + let sandbox_id = sandbox.object_id().to_string(); + + // Drivers with no in-sandbox supervisor at all (MXC) never have a live + // ConnectSupervisor session -- `open_relay_with_target` below would just + // burn its 15s timeout and fail. When the active driver contributes a + // dynamic-forward capability, bridge through that instead (see + // `ComputeDriverForwardSink::open_dynamic_forward`). + if let Some(forward_sink) = state.compute.forward_sink() { + let target_port = match &target { + relay_open::Target::Tcp(t) => u16::try_from(t.port) + .map_err(|_| Status::invalid_argument("tcp target port out of range"))?, + relay_open::Target::Ssh(_) => { + return Err(Status::unimplemented( + "this driver has no SSH server to forward to", + )); + } + }; + + let (relay_addr, nonce, relay_handle) = forward_sink + .open_dynamic_forward(&sandbox_id, target_port) + .await + .map_err(|e| Status::unavailable(format!("driver dynamic forward failed: {e}")))?; + + // This is a latency-sensitive request/response tunnel, including on + // loopback -- small agent-protocol/WS frames can otherwise stall + // behind delayed ACK behavior, so disable Nagle on this leg too. + let mut relay_stream = connect_tcp_nodelay_best_effort(&[relay_addr]) + .await + .map_err(|e| { + Status::unavailable(format!( + "failed to connect to MXC relay at {relay_addr}: {e}" + )) + })?; + // Prove to the relay this is the real Phase B peer before any + // tunneled application data -- see openshell-driver-mxc's relay.rs + // module docs (the relay listens on loopback, so without this any + // other local process racing to connect first could otherwise + // hijack the forward). + tokio::io::AsyncWriteExt::write_all(&mut relay_stream, &nonce) + .await + .map_err(|e| { + Status::unavailable(format!("failed to authenticate to MXC relay: {e}")) + })?; + + let (tx, rx) = mpsc::channel::>(256); + let sandbox_id_bridge = sandbox_id.clone(); + tokio::spawn(async move { + let _connection_guard = connection_guard; + // Held for the bridge's lifetime; dropping it (bridge exits, + // this task ends) stops the ephemeral relay listener and closes + // Phase A, which is what tells the sandbox's dynamic bridge to + // stop too -- no separate teardown message needed. + let _relay_handle = relay_handle; + bridge_forward_tcp_stream(inbound, relay_stream, tx, &sandbox_id_bridge, "mxc-dynamic") + .await; + }); + + let stream: Pin< + Box> + Send + 'static>, + > = Box::pin(ReceiverStream::new(rx)); + return Ok(Response::new(stream)); + } + let (channel_id, relay_rx) = state .supervisor_sessions .open_relay_with_target( @@ -1928,7 +2014,6 @@ pub(super) async fn handle_forward_tcp( .await .map_err(|e| Status::unavailable(format!("supervisor relay failed: {e}")))?; - let sandbox_id = sandbox.object_id().to_string(); let (tx, rx) = mpsc::channel::>(256); tokio::spawn(async move { let _connection_guard = connection_guard; @@ -2114,13 +2199,15 @@ fn validate_tcp_target_parts(host: &str, _port: u32) -> Result { } } -async fn bridge_forward_tcp_stream( +async fn bridge_forward_tcp_stream( mut inbound: tonic::Streaming, - relay_stream: tokio::io::DuplexStream, + relay_stream: S, tx: mpsc::Sender>, sandbox_id: &str, channel_id: &str, -) { +) where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Send + 'static, +{ let (mut relay_read, mut relay_write) = tokio::io::split(relay_stream); let sandbox_id_in = sandbox_id.to_string(); @@ -3649,6 +3736,7 @@ mod tests { workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), )), + ..Default::default() }), ) .await @@ -3687,6 +3775,66 @@ mod tests { ); } + #[tokio::test] + async fn delete_handler_rejects_expected_identity_drift_before_mutation() { + let state = test_server_state().await; + let mut sandbox = test_sandbox("guarded-delete", Vec::new()); + sandbox.metadata.as_mut().unwrap().id = "sb-current".to_string(); + state.store.put_message(&sandbox).await.unwrap(); + + let error = handle_delete_sandbox_inner( + &state, + authed_request(DeleteSandboxRequest { + name: "guarded-delete".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), + expected_sandbox_id: "sb-stale".to_string(), + expected_resource_version: 0, + }), + ) + .await + .unwrap_err(); + + assert_eq!(error.code(), tonic::Code::Aborted); + assert!( + state + .store + .get_message::("sb-current") + .await + .unwrap() + .is_some() + ); + } + + #[tokio::test] + async fn delete_handler_rejects_resource_version_without_immutable_identity() { + let state = test_server_state().await; + let mut sandbox = test_sandbox("guarded-delete", Vec::new()); + sandbox.metadata.as_mut().unwrap().id = "sb-current".to_string(); + state.store.put_message(&sandbox).await.unwrap(); + + let error = handle_delete_sandbox_inner( + &state, + authed_request(DeleteSandboxRequest { + name: "guarded-delete".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), + expected_sandbox_id: String::new(), + expected_resource_version: 17, + }), + ) + .await + .unwrap_err(); + + assert_eq!(error.code(), tonic::Code::InvalidArgument); + assert!( + state + .store + .get_message::("sb-current") + .await + .unwrap() + .is_some() + ); + } + #[tokio::test] async fn attach_sandbox_provider_persists_current_provider_list() { let state = test_server_state().await; @@ -6785,6 +6933,7 @@ mod tests { non_member_request(DeleteSandboxRequest { workspace_scope: Some(openshell_core::proto::workspace_selector("no-such-ws")), name: "any".into(), + ..Default::default() }), ) .await diff --git a/crates/openshell-server/src/grpc/validation.rs b/crates/openshell-server/src/grpc/validation.rs index 85bf1371a8..2785b9b09b 100644 --- a/crates/openshell-server/src/grpc/validation.rs +++ b/crates/openshell-server/src/grpc/validation.rs @@ -10,7 +10,7 @@ use openshell_core::proto::{ CredentialHandle, ExecSandboxRequest, Provider, SandboxPolicy as ProtoSandboxPolicy, - SandboxSpec, SandboxTemplate, + SandboxSpec, SandboxTemplate, UiClipboardAccess, }; use openshell_core::rpc_error::invalid_argument; use prost::Message; @@ -1018,7 +1018,7 @@ pub(super) fn validate_no_reserved_provider_policy_keys( Ok(()) } -/// Validate that static policy fields (filesystem, landlock, process) haven't changed +/// Validate that static policy fields (filesystem, landlock, process, UI) haven't changed /// from the baseline (version 1) policy. pub(super) fn validate_static_fields_unchanged( baseline: &ProtoSandboxPolicy, @@ -1042,6 +1042,18 @@ pub(super) fn validate_static_fields_unchanged( "process policy cannot be changed on a live sandbox (applied at startup)", )); } + let mut baseline_ui = baseline.ui; + let mut new_ui = new.ui; + for ui in [&mut baseline_ui, &mut new_ui].into_iter().flatten() { + if ui.clipboard == UiClipboardAccess::Unspecified as i32 { + ui.clipboard = UiClipboardAccess::None as i32; + } + } + if baseline_ui != new_ui { + return Err(Status::invalid_argument( + "UI policy cannot be changed on a live sandbox (applied at startup)", + )); + } Ok(()) } @@ -2347,6 +2359,33 @@ mod tests { assert!(result.unwrap_err().message().contains("include_workdir")); } + #[test] + fn validate_static_fields_rejects_ui_presence_or_value_change() { + use openshell_core::proto::{UiClipboardAccess, UiPolicy}; + + let absent = ProtoSandboxPolicy::default(); + let deny = ProtoSandboxPolicy { + ui: Some(UiPolicy::default()), + ..Default::default() + }; + let allow_clipboard = ProtoSandboxPolicy { + ui: Some(UiPolicy { + clipboard: UiClipboardAccess::Read as i32, + ..Default::default() + }), + ..Default::default() + }; + + let presence_error = validate_static_fields_unchanged(&absent, &deny) + .expect_err("adding explicit UI policy must be static"); + assert!(presence_error.message().contains("UI policy")); + + let value_error = validate_static_fields_unchanged(&deny, &allow_clipboard) + .expect_err("changing UI policy must be static"); + assert!(value_error.message().contains("UI policy")); + assert!(validate_static_fields_unchanged(&deny, &deny).is_ok()); + } + // ---- Exec validation ---- #[test] diff --git a/crates/openshell-server/src/lib.rs b/crates/openshell-server/src/lib.rs index 7ea7711a97..6023532753 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -1055,17 +1055,45 @@ async fn terminate_signal() { } pub use compute::{ - AcquiredRemoteDriverEndpoint, DriverWatchStream, ManagedDriverProcess, SharedComputeDriver, + AcquiredRemoteDriverEndpoint, DriverWatchStream, ManagedDriverProcess, + SandboxProviderCredentialsSink, SharedComputeDriver, }; /// Driver instance returned by a compiled compute-driver factory. pub enum ComputeDriverInstance { /// A driver hosted in the gateway process. InProcess(SharedComputeDriver), + /// An in-process driver with a create-time provider credential side channel. + InProcessWithProviderCredentials { + driver: SharedComputeDriver, + provider_credentials_sink: SandboxProviderCredentialsSink, + }, /// A driver process launched and owned by the gateway. ManagedRemote(AcquiredRemoteDriverEndpoint), } +/// Type-erased dynamic TCP forward capability. +/// +/// Optionally contributed by a +/// compiled in-process driver that has no in-sandbox supervisor of its own to +/// relay through (e.g. MXC: no live `ConnectSupervisor` session ever exists, +/// so `ForwardTcp` must bridge through the driver's own control channel +/// instead). Most drivers never call `ComputeDriverBuildContext::set_forward_sink` +/// and this stays `None`. +#[async_trait::async_trait] +pub trait ComputeDriverForwardSink: Send + Sync { + /// Opens a fresh, on-demand relay to `target_port` inside the sandbox. + /// Returns the relay's address, an auth nonce the caller must send as the + /// first bytes on its own connection to that address, and an opaque + /// handle the caller must hold for as long as the forward should stay + /// open (drop to tear it down). + async fn open_dynamic_forward( + &self, + sandbox_id: &str, + target_port: u16, + ) -> std::result::Result<(SocketAddr, Vec, Box), String>; +} + /// Factory for a compute driver linked into a gateway binary. #[async_trait::async_trait] pub trait ComputeDriverFactory: Send + Sync { @@ -1384,6 +1412,7 @@ impl ComputeDriverConfigContext<'_> { pub struct ComputeDriverBuildContext<'a> { config: ComputeDriverConfigContext<'a>, shutdown_rx: watch::Receiver, + forward_sink: Arc>>>, } impl ComputeDriverBuildContext<'_> { @@ -1451,6 +1480,13 @@ impl ComputeDriverBuildContext<'_> { .file .and_then(|file| file.openshell.gateway.otlp.as_ref()) } + + /// Contributes this driver's dynamic TCP forward capability, if it has + /// one, so `ComputeRuntime::forward_sink` can bridge `ForwardTcp` for + /// sandboxes with no live `ConnectSupervisor` session. + pub fn set_forward_sink(&self, sink: Arc) { + *self.forward_sink.lock().unwrap() = Some(sink); + } } #[allow(clippy::too_many_arguments)] @@ -1490,6 +1526,8 @@ async fn build_compute_runtime( let runtime = match driver { ConfiguredComputeDriver::Registered(registration) => { + let forward_sink: Arc>>> = + Arc::new(Mutex::new(None)); let build_context = ComputeDriverBuildContext { config: ComputeDriverConfigContext { driver_name: ®istration.name, @@ -1499,23 +1537,57 @@ async fn build_compute_runtime( driver_startup, }, shutdown_rx, + forward_sink: forward_sink.clone(), }; let instance = registration.factory.build(build_context).await?; match instance { - ComputeDriverInstance::InProcess(driver) => ComputeRuntime::from_driver( - registration.name, + ComputeDriverInstance::InProcess(driver) => { + let mut runtime = ComputeRuntime::from_driver( + registration.name, + driver, + None, + None, + store, + sandbox_index, + sandbox_watch_bus, + tracing_log_bus, + supervisor_sessions, + ) + .await + .map_err(|error| { + Error::execution(format!("failed to create compute runtime: {error}")) + })?; + let sink = forward_sink.lock().unwrap().take(); + if let Some(sink) = sink { + runtime.set_forward_sink(sink); + } + runtime + } + ComputeDriverInstance::InProcessWithProviderCredentials { driver, - None, - store, - sandbox_index, - sandbox_watch_bus, - tracing_log_bus, - supervisor_sessions, - ) - .await - .map_err(|error| { - Error::execution(format!("failed to create compute runtime: {error}")) - })?, + provider_credentials_sink, + } => { + let mut runtime = ComputeRuntime::from_driver( + registration.name, + driver, + None, + Some(provider_credentials_sink), + store, + sandbox_index, + sandbox_watch_bus, + tracing_log_bus, + supervisor_sessions, + ) + .await + .map_err(|error| { + Error::execution(format!("failed to create compute runtime: {error}")) + })?; + let sink = forward_sink.lock().unwrap().take(); + if let Some(sink) = sink { + runtime.set_forward_sink(sink); + } + runtime + } ComputeDriverInstance::ManagedRemote(mut endpoint) => { endpoint.name = registration.name; ComputeRuntime::new_remote_driver( diff --git a/crates/openshell-server/src/storage_proto.rs b/crates/openshell-server/src/storage_proto.rs index eb5b77d893..f92a9a40e2 100644 --- a/crates/openshell-server/src/storage_proto.rs +++ b/crates/openshell-server/src/storage_proto.rs @@ -119,11 +119,11 @@ mod tests { const STORAGE_V1_SCHEMA_SHA256: &str = "79c72615d957fc0653c672f61998bf7d8d21b757bc05d07b3fff92bd70fc8f52"; const PUBLIC_RPC_SCHEMA_SHA256: &str = - "26a2882f1fc82741f4821da90fb9674c9b5d66991760398a93a90354b7349d83"; + "800ab99d50213956aacd377a44ff98fbe0af5a948f0138c96106b9a12c90bcd7"; const DURABLE_SCHEMA_SHA256: &str = - "568ec5637c504726b40a616d286457f41b5be2f4761872c749313e1ee16b5c85"; + "c439724f77f71b794a68a76cec4040dde9eb22cbdb6e4f1517028683399c87ef"; const PUBLIC_DURABLE_OVERLAP_SHA256: &str = - "f96d841e67da5c3443fa0aca15936dd14ac30d2e150ddf0439b0d195c4a0cfd9"; + "ea546b1a1ee0ddf50ee74f2541dcf68541054c70a5ce29c93d3fd29e6af98820"; // A persisted Sandbox without endpoint status retains its lifecycle fields; // the absent repeated field decodes empty and needs no database rewrite. const SANDBOX_WITHOUT_ENDPOINT_STATUS: &str = "0a1e0a0a73616e64626f782d6964120773616e64626f783a0764656661756c741a2b0a0773616e64626f782a0d0a05526561647912045472756530023807420d73757065727669736f722d6964"; @@ -490,13 +490,13 @@ mod tests { assert_eq!( (public_closure.messages.len(), public_closure.enums.len()), - (282, 13) + (283, 14) ); assert_eq!( (durable_closure.messages.len(), durable_closure.enums.len()), - (82, 9) + (83, 10) ); - assert_eq!((overlap_messages.len(), overlap_enums.len()), (72, 9)); + assert_eq!((overlap_messages.len(), overlap_enums.len()), (73, 10)); assert_eq!( public_inventory_hash, PUBLIC_RPC_SCHEMA_SHA256, @@ -575,6 +575,7 @@ mod tests { PolicyRevisionPayload::decode(legacy_bytes(V0_0_116_POLICY_PAYLOAD).as_slice()) .expect("legacy policy payload must decode"); assert!(policy_payload.policy.is_some()); + assert!(policy_payload.policy.as_ref().unwrap().ui.is_none()); assert_eq!(policy_payload.hash, "sha256"); assert_eq!(policy_payload.load_error, "none"); assert_eq!(policy_payload.loaded_at_ms, 300); diff --git a/crates/openshell-server/src/test_support.rs b/crates/openshell-server/src/test_support.rs index 3bd5430ef1..62487d6bdb 100644 --- a/crates/openshell-server/src/test_support.rs +++ b/crates/openshell-server/src/test_support.rs @@ -100,6 +100,7 @@ impl FakeComputeDriver { resource_capabilities: None, rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, + supports_ui_policy: false, }, gateway_listener_requirements: Vec::new(), gateway_listener_requirements_supported: true, diff --git a/crates/openshell-supervisor-network/src/host.rs b/crates/openshell-supervisor-network/src/host.rs index 631dc63292..9d6f5cfffa 100644 --- a/crates/openshell-supervisor-network/src/host.rs +++ b/crates/openshell-supervisor-network/src/host.rs @@ -4,15 +4,16 @@ //! Host-side proxy startup for compute drivers that do not run the Linux //! in-sandbox supervisor. //! -//! MXC uses this on Windows: MXC's `network.proxy` redirects sandbox egress to a -//! per-sandbox loopback listener in the gateway process, and this module starts -//! the existing OpenShell CONNECT proxy against the trimmed network-only +//! MXC uses this on Windows: the driver injects proxy environment variables +//! pointing to a per-sandbox loopback listener in the gateway process. This +//! module starts the existing `OpenShell` CONNECT proxy against the trimmed network-only //! `SandboxPolicy`. use std::net::SocketAddr; use std::path::PathBuf; use std::sync::Arc; +use base64::Engine as _; use miette::Result; use openshell_core::activity::ActivitySender; use openshell_core::denial::DenialEvent; @@ -33,7 +34,28 @@ use crate::opa::OpaEngine; use crate::policy_local::PolicyLocalContext; use crate::proxy::{ProxyHandle, ProxyIdentityMode}; -/// Configuration for a host-side OpenShell CONNECT proxy. +/// Ephemeral credential required from a sandbox before the host proxy will +/// evaluate or forward its request. +/// +/// The expected header value is intentionally private and this type does not +/// implement `Debug`, preventing accidental credential disclosure in logs. +#[derive(Clone)] +pub struct HostProxyClientAuth { + expected_proxy_authorization: Arc, +} + +impl HostProxyClientAuth { + #[must_use] + pub fn basic(username: &str, password: &str) -> Self { + let encoded = base64::engine::general_purpose::STANDARD + .encode(format!("{username}:{password}").as_bytes()); + Self { + expected_proxy_authorization: Arc::from(format!("Basic {encoded}")), + } + } +} + +/// Configuration for a host-side `OpenShell` CONNECT proxy. pub struct HostProxyConfig { /// Exact socket the compute driver will redirect sandbox egress to. pub bind_addr: SocketAddr, @@ -43,6 +65,9 @@ pub struct HostProxyConfig { /// socket-owning sandbox process. Policy binaries must match this path for /// L4/L7 allow rules to pass. pub binary_path: PathBuf, + /// Per-sandbox client authentication. Host-side MXC proxies must set this + /// so another sandbox cannot borrow this proxy's identity and policy. + pub client_auth: HostProxyClientAuth, pub sandbox_id: Option, pub sandbox_name: Option, pub openshell_endpoint: Option, @@ -190,11 +215,15 @@ pub async fn start_host_proxy(config: HostProxyConfig) -> Result String { + let mut request = String::from( + "GET http://policy.local/v1/policy/current HTTP/1.1\r\nHost: policy.local\r\n", + ); + for header in headers { + request.push_str(header); + request.push_str("\r\n"); + } + request.push_str("Connection: close\r\n\r\n"); + + let mut client = TcpStream::connect(addr).await.unwrap(); + client.write_all(request.as_bytes()).await.unwrap(); + let mut response = Vec::new(); + tokio::time::timeout(Duration::from_secs(2), client.read_to_end(&mut response)) + .await + .unwrap() + .unwrap(); + String::from_utf8(response).unwrap() + } + + async fn proxy_connect_request(addr: SocketAddr, headers: &[&str]) -> String { + let mut request = + String::from("CONNECT example.invalid:443 HTTP/1.1\r\nHost: example.invalid:443\r\n"); + for header in headers { + request.push_str(header); + request.push_str("\r\n"); + } + request.push_str("Connection: close\r\n\r\n"); + + let mut client = TcpStream::connect(addr).await.unwrap(); + client.write_all(request.as_bytes()).await.unwrap(); + let mut response = Vec::new(); + tokio::time::timeout(Duration::from_secs(2), client.read_to_end(&mut response)) + .await + .unwrap() + .unwrap(); + String::from_utf8(response).unwrap() + } + #[tokio::test] async fn rejects_non_loopback_bind_addr() { let result = start_host_proxy(test_config( @@ -322,24 +391,9 @@ mod tests { .contains("BEGIN CERTIFICATE") ); - let mut client = TcpStream::connect(addr).await.unwrap(); - client - .write_all( - b"GET http://policy.local/v1/policy/current HTTP/1.1\r\n\ - Host: policy.local\r\n\ - Connection: close\r\n\ - \r\n", - ) - .await - .unwrap(); - - let mut response = Vec::new(); - tokio::time::timeout(Duration::from_secs(2), client.read_to_end(&mut response)) - .await - .unwrap() - .unwrap(); - - let response = String::from_utf8(response).unwrap(); + let auth = HostProxyClientAuth::basic("openshell", "test-secret"); + let header = format!("Proxy-Authorization: {}", auth.expected_proxy_authorization); + let response = proxy_request(addr, &[&header]).await; assert!( response.starts_with("HTTP/1.1 200 OK"), "unexpected response: {response}" @@ -355,4 +409,68 @@ mod tests { "unexpected policy payload: {body}" ); } + + #[tokio::test] + async fn per_sandbox_credentials_reject_missing_wrong_cross_and_duplicate_auth() { + let binary = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(binary.path(), b"agent").unwrap(); + + let auth_a = HostProxyClientAuth::basic("openshell", "sandbox-a-secret"); + let auth_b = HostProxyClientAuth::basic("openshell", "sandbox-b-secret"); + // Node's EnvHttpProxyAgent currently emits the field name in lower + // case; HTTP field names are case-insensitive. + let header_a = format!( + "proxy-authorization: {}", + auth_a.expected_proxy_authorization + ); + let header_b = format!( + "Proxy-Authorization: {}", + auth_b.expected_proxy_authorization + ); + + let mut config_a = test_config(([127, 0, 0, 1], 0).into(), binary.path().to_path_buf()); + config_a.client_auth = auth_a; + let proxy_a = start_host_proxy(config_a).await.unwrap(); + + let mut config_b = test_config(([127, 0, 0, 1], 0).into(), binary.path().to_path_buf()); + config_b.client_auth = auth_b; + let proxy_b = start_host_proxy(config_b).await.unwrap(); + + let addr_a = proxy_a.http_addr().unwrap(); + let addr_b = proxy_b.http_addr().unwrap(); + assert!(proxy_request(addr_a, &[]).await.starts_with("HTTP/1.1 407")); + assert!( + proxy_request(addr_a, &[&header_b]) + .await + .starts_with("HTTP/1.1 407"), + "sandbox B credential must not authenticate to sandbox A proxy" + ); + assert!( + proxy_request(addr_a, &[&header_a, &header_a]) + .await + .starts_with("HTTP/1.1 407"), + "duplicate credentials must fail closed" + ); + assert!( + proxy_request(addr_a, &[&header_a]) + .await + .starts_with("HTTP/1.1 200") + ); + assert!( + proxy_request(addr_b, &[&header_b]) + .await + .starts_with("HTTP/1.1 200") + ); + assert!( + proxy_connect_request(addr_a, &[&header_b]) + .await + .starts_with("HTTP/1.1 407") + ); + assert!( + proxy_connect_request(addr_a, &[&header_a]) + .await + .starts_with("HTTP/1.1 403"), + "valid credentials must pass the auth gate and reach deny-by-default policy evaluation" + ); + } } diff --git a/crates/openshell-supervisor-network/src/identity.rs b/crates/openshell-supervisor-network/src/identity.rs index 643db06f5b..b824c58d26 100644 --- a/crates/openshell-supervisor-network/src/identity.rs +++ b/crates/openshell-supervisor-network/src/identity.rs @@ -57,7 +57,7 @@ impl FileFingerprint { fn system_time_parts(time: std::time::SystemTime) -> Option<(i64, i64)> { let duration = time.duration_since(std::time::UNIX_EPOCH).ok()?; Some(( - duration.as_secs() as i64, + duration.as_secs().cast_signed(), i64::from(duration.subsec_nanos()), )) } diff --git a/crates/openshell-supervisor-network/src/opa.rs b/crates/openshell-supervisor-network/src/opa.rs index fdc83cf9c9..31fc7f99ee 100644 --- a/crates/openshell-supervisor-network/src/opa.rs +++ b/crates/openshell-supervisor-network/src/opa.rs @@ -2723,6 +2723,7 @@ mod tests { run_as_user: "sandbox".to_string(), run_as_group: "sandbox".to_string(), }), + ui: None, network_policies, network_middlewares: std::collections::HashMap::default(), } @@ -3820,6 +3821,7 @@ process: }), network_policies, network_middlewares: std::collections::HashMap::default(), + ui: None, }; let engine = OpaEngine::from_proto_with_pid_and_binary_identity_required(&proto, 0, false) .expect("engine from relaxed proto"); @@ -4358,6 +4360,7 @@ network_policies: }), network_policies, network_middlewares: std::collections::HashMap::default(), + ui: None, }; let engine = OpaEngine::from_proto(&proto).expect("engine from proto"); @@ -4429,6 +4432,7 @@ network_policies: }), network_policies, network_middlewares: std::collections::HashMap::default(), + ui: None, }; let engine = OpaEngine::from_proto(&proto).expect("engine from proto"); @@ -4505,6 +4509,7 @@ network_policies: }), network_policies, network_middlewares: std::collections::HashMap::default(), + ui: None, }; let engine = OpaEngine::from_proto(&proto).expect("engine from proto"); @@ -5990,6 +5995,7 @@ network_policies: }), network_policies, network_middlewares: std::collections::HashMap::default(), + ui: None, }; let engine = OpaEngine::from_proto(&proto).expect("engine from proto"); @@ -6047,6 +6053,7 @@ network_policies: }), network_policies, network_middlewares: std::collections::HashMap::default(), + ui: None, }; let engine = OpaEngine::from_proto(&proto).expect("engine from proto"); @@ -6105,6 +6112,7 @@ network_policies: }), network_policies, network_middlewares: std::collections::HashMap::default(), + ui: None, }; let engine = OpaEngine::from_proto(&proto).expect("engine from proto"); @@ -6165,6 +6173,7 @@ network_policies: }), network_policies, network_middlewares: std::collections::HashMap::default(), + ui: None, }; let engine = OpaEngine::from_proto(&proto).expect("engine from proto"); @@ -6224,6 +6233,7 @@ network_policies: }), network_policies, network_middlewares: std::collections::HashMap::default(), + ui: None, }; let engine = OpaEngine::from_proto(&proto).expect("engine from proto"); @@ -7616,6 +7626,7 @@ network_policies: }), network_policies, network_middlewares: std::collections::HashMap::default(), + ui: None, }; let engine = OpaEngine::from_proto(&proto).expect("engine from proto"); let input = NetworkInput { @@ -7686,6 +7697,7 @@ network_policies: }), network_policies, network_middlewares: std::collections::HashMap::default(), + ui: None, }; let engine = OpaEngine::from_proto(&proto).expect("Failed to create engine from proto"); @@ -7916,6 +7928,7 @@ network_policies: }), network_policies, network_middlewares: std::collections::HashMap::default(), + ui: None, }; let engine = OpaEngine::from_proto(&proto).unwrap(); // Port 443 @@ -8884,6 +8897,7 @@ network_policies: process: None, network_policies, network_middlewares: std::collections::HashMap::default(), + ui: None, }; let pid = std::process::id(); // accessible root, leaf paths absent @@ -9520,6 +9534,7 @@ network_policies: }), network_policies, network_middlewares: std::collections::HashMap::default(), + ui: None, }; // Build engine with our PID (symlink resolution will work via /proc/self/root/) @@ -9595,6 +9610,7 @@ network_policies: }), network_policies, network_middlewares: std::collections::HashMap::default(), + ui: None, }; // Initial load at pid=0 — no symlink expansion diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index f57e9f3202..22bb8191c3 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -174,6 +174,7 @@ pub(crate) enum ProxyIdentityMode { Static { binary_path: PathBuf, binary_sha256: String, + required_proxy_authorization: Option>, }, } @@ -191,14 +192,35 @@ impl ProxyIdentityMode { #[cfg(any(not(target_os = "linux"), test))] pub(crate) fn static_binary(path: impl Into) -> Result { + Self::static_binary_with_client_auth(path, None) + } + + #[cfg(any(not(target_os = "linux"), test))] + pub(crate) fn static_binary_with_client_auth( + path: impl Into, + required_proxy_authorization: Option>, + ) -> Result { let binary_path = path.into(); let binary_sha256 = crate::procfs::file_sha256(&binary_path)?; Ok(Self::Static { binary_path, binary_sha256, + required_proxy_authorization, }) } + fn required_proxy_authorization(&self) -> Option<&str> { + match self { + #[cfg(target_os = "linux")] + Self::Procfs { .. } => None, + #[cfg(any(not(target_os = "linux"), test))] + Self::Static { + required_proxy_authorization, + .. + } => required_proxy_authorization.as_deref(), + } + } + fn entrypoint_pid(&self) -> u32 { match self { #[cfg(target_os = "linux")] @@ -983,11 +1005,15 @@ enum AcceptAction { }, } +// The resource-pressure counter is used only by the Unix errno classifier. +#[cfg_attr(not(unix), allow(clippy::needless_pass_by_ref_mut))] fn classify_accept_error( err: &std::io::Error, consecutive_resource_errors: &mut u32, consecutive_unknown_errors: &mut u32, ) -> AcceptAction { + #[cfg(not(unix))] + let _ = (err, &*consecutive_resource_errors); #[cfg(unix)] if matches!( err.raw_os_error(), @@ -1696,6 +1722,40 @@ async fn deny_forward_destination( .await } +fn constant_time_bytes_eq(left: &[u8], right: &[u8]) -> bool { + let max_len = left.len().max(right.len()); + let mut difference = left.len() ^ right.len(); + for index in 0..max_len { + let left_byte = left.get(index).copied().unwrap_or_default(); + let right_byte = right.get(index).copied().unwrap_or_default(); + difference |= usize::from(left_byte ^ right_byte); + } + difference == 0 +} + +fn has_valid_proxy_authorization(request: &str, expected: &str) -> bool { + let mut provided = None; + for line in request.split("\r\n").skip(1) { + if line.is_empty() { + break; + } + let Some((name, value)) = line.split_once(':') else { + return false; + }; + if name.eq_ignore_ascii_case("proxy-authorization") { + // Reject duplicates even when both values are correct. Accepting + // ambiguous credentials can produce parser differentials between + // this proxy and downstream HTTP implementations. + if provided.is_some() { + return false; + } + provided = Some(value.trim()); + } + } + + provided.is_some_and(|value| constant_time_bytes_eq(value.as_bytes(), expected.as_bytes())) +} + // Many distinct, non-related context parameters are required for a CONNECT // dispatch; bundling them into a struct would just shift the noise into call // sites. @@ -1766,6 +1826,20 @@ async fn handle_tcp_connection( respond(&mut client, b"HTTP/1.1 400 Bad Request\r\n\r\n").await?; return Ok(()); } + if let Some(expected) = identity_mode.required_proxy_authorization() + && !has_valid_proxy_authorization(request, expected) + { + warn!("Rejected host proxy request with missing or invalid per-sandbox credentials"); + respond( + &mut client, + b"HTTP/1.1 407 Proxy Authentication Required\r\n\ + Proxy-Authenticate: Basic realm=\"OpenShell\"\r\n\ + Content-Length: 0\r\n\ + Connection: close\r\n\r\n", + ) + .await?; + return Ok(()); + } let mut lines = request.split("\r\n"); let request_line = lines.next().unwrap_or(""); let mut parts = request_line.split_whitespace(); @@ -2867,6 +2941,7 @@ fn authorize_egress_intent( ProxyIdentityMode::Static { binary_path, binary_sha256, + .. } => { let input = crate::opa::NetworkInput { host: intent.destination.host.clone(), @@ -7464,6 +7539,7 @@ network_policies: ProxyIdentityMode::Static { binary_path, binary_sha256, + .. } => { assert_eq!(binary_path, tmp.path()); assert_eq!( diff --git a/crates/openshell-supervisor-relay/Cargo.toml b/crates/openshell-supervisor-relay/Cargo.toml new file mode 100644 index 0000000000..678f08acb4 --- /dev/null +++ b/crates/openshell-supervisor-relay/Cargo.toml @@ -0,0 +1,42 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "openshell-supervisor-relay" +description = "Generic process spawner + WebSocket relay bridge for OpenShell MXC ProcessContainer sandboxes" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[[bin]] +name = "openshell-supervisor-relay" +path = "src/main.rs" + +# This is an MXC/AppContainer helper: it only does anything useful on +# Windows. The implementation is technically portable (no Windows-specific +# APIs), but per the openshell-driver-mxc platform pattern, its real deps +# stay Windows-only so `cargo build --workspace` on Linux/macOS doesn't pull +# in the full relay implementation for a binary those platforms never run — +# see src/main.rs for the corresponding cfg(target_os = "windows") gating. +[target.'cfg(target_os = "windows")'.dependencies] +tokio = { workspace = true } +futures = { workspace = true } +tokio-tungstenite = { workspace = true } +serde_json = { workspace = true } +base64 = { workspace = true } +anyhow = { workspace = true } + +# Black-box integration tests (tests/control_channel_contract.rs) spawn the +# compiled binary above and drive it over real stdio/TCP/WS -- Windows-only, +# same as the binary itself (the whole test file is `#![cfg(windows)]`), so +# these stay out of the dependency graph everywhere else too. +[target.'cfg(target_os = "windows")'.dev-dependencies] +tokio = { workspace = true, features = ["test-util"] } +futures = { workspace = true } +tokio-tungstenite = { workspace = true } +serde_json = { workspace = true } + +[lints] +workspace = true diff --git a/crates/openshell-supervisor-relay/src/imp.rs b/crates/openshell-supervisor-relay/src/imp.rs new file mode 100644 index 0000000000..ad09f7dcb8 --- /dev/null +++ b/crates/openshell-supervisor-relay/src/imp.rs @@ -0,0 +1,1090 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Generic process spawner + WebSocket relay bridge for `OpenShell` MXC +//! `ProcessContainer` sandboxes. +//! +//! Unlike `mxc-ws-agent.rs` (a self-contained echo-server test harness), this +//! binary has exactly one job: launch an arbitrary command inside the +//! `AppContainer` and, on request, expose one of its TCP ports to the host +//! through a gateway relay, without requiring any changes to the launched +//! command itself. It is the `agent_command` the mxc driver spawns in place +//! of the target application directly, whenever a sandbox config asks for +//! relay bridging (see `driver.rs`'s handling of `pc_relay_spawner_path` / +//! `pc_relay_target_port`). +//! +//! There is no bridge at startup — relay bridging is entirely on-demand, +//! driven by `forward` requests over the control channel below (used for +//! `openshell forward service`). Each bridge is independent, short-lived, and +//! parameterized per-request (relay address + target port supplied in the +//! request), not declared anywhere in the gateway TOML. +//! +//! ```text +//! Host TCP client (via `openshell forward service`) +//! | +//! v +//! Gateway relay (127.0.0.1:) <-- driver binds one per forward +//! ^ request, tells us the address +//! | outbound WS (via egress_proxy) over the control channel +//! | +//! openshell-supervisor-relay <-- dials out on "forward", proxies traffic +//! | for the lifetime of that one request +//! | loopback WS (AppContainer-internal) +//! v +//! <-- launched from the "launch" control-channel +//! request (see below), no relay knowledge required +//! ``` +//! +//! Usage: `openshell-supervisor-relay.exe ` -- `` +//! is the TCP port the launched command is expected to bind (an early +//! liveness check, `wait_for_port_ready`: if the target never binds it +//! within 60s, this process exits with an error instead of sitting around +//! with a target that will never work). This binary uses no `share_dir` files +//! at all -- command/env and shutdown both travel over the control channel. +//! +//! Shutdown: driver sends a `"shutdown"` request over the control channel +//! (see below) and separately kills the wxc-exec process (`AppContainer` +//! teardown) as a backstop regardless of whether that message gets through. +//! This binary's `run_lifecycle` reacts to the request by killing the +//! target cleanly if it's still running. +//! +//! ## Control channel (this process's own stdin/stdout) +//! +//! `wxc-exec` runs this process with STDIO passthrough, which forwards its +//! own stdin/stdout down from the driver -- the driver pipes them (see +//! `mxc.rs`'s `run_oneshot`) instead of the usual `null`/`piped`-for-logging +//! split, giving the gateway a write channel straight into the `AppContainer`. +//! This needs **no `AppContainer` network capability at all**: it's inherited +//! process handles, not network traffic, so none of `egress_proxy` / +//! `network.proxy` / `privateNetworkClientServer` are involved. +//! +//! Protocol: newline-delimited JSON, mirroring MXC's own `pipe_server` tool: +//! Request: `{"id": , "op": "", "data": }` +//! Response: `{"id": , "ok": true, "data": }` +//! or `{"id": , "ok": false, "error": ""}` +//! Event (unsolicited, no id): `{"event": ""}` +//! +//! Startup handshake: before spawning anything, this process emits +//! `{"event":"ready","protocol_version":N}` on stdout (`N` = `PROTOCOL_VERSION` +//! below -- the driver rejects a mismatched/missing version immediately, +//! so an independently staged, out-of-sync binary fails fast instead of +//! hanging or misbehaving later), then blocks waiting for a `"launch"` +//! request carrying `data: {"command": [...], "env": [...]}` (one arg per +//! `command` element, first is the executable; `env` is `"KEY=VALUE"` +//! strings, replacing the inherited environment entirely when non-empty -- +//! lets runtimes that choke on an unrecognized host env, e.g. node.js +//! `STATUS_DLL_INIT_FAILED`, get a curated one instead). The driver sends +//! this once its stdout-reader observes the ready event (see driver.rs). +//! Command/env travel over this channel rather than as `agent-cmd.txt`/ +//! `agent-env.txt` files in `share_dir` -- keeps them (which can carry +//! secrets) off disk, and avoids the file ever going stale. +//! +//! Ops: `launch` (see above), `shutdown` (no data; acked, then wakes +//! `run_lifecycle` to kill the target and exit -- see Shutdown above), +//! `ping`, `echo`, and `forward` -- `forward` opens a new, independent relay +//! bridge for the target port and relay address given in the request (see +//! `handle_control_request`'s doc comment for the full shape). +//! +//! Because this channel owns our stdout exclusively, the target process's own +//! stdout/stderr are piped (not inherited) and forwarded to *our* stderr with +//! a `[target stdout]`/`[target stderr]` tag instead, so they stay visible in +//! the gateway log without colliding with control-channel responses. + +use base64::Engine; +use futures::{SinkExt, StreamExt}; +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::oneshot; +use tokio_tungstenite::tungstenite::Message; + +/// Wire protocol version reported in the startup `"ready"` event (see +/// `run_control_channel`). Must match `openshell-driver-mxc`'s +/// `REQUIRED_SUPERVISOR_RELAY_PROTOCOL_VERSION` constant -- duplicated +/// rather than shared via a common crate, matching how the rest of this +/// wire protocol is already duplicated across the two sides. Bump both +/// together whenever the control-channel protocol changes in a way an +/// out-of-sync peer can't safely ignore, so an independently staged, stale +/// binary on either side fails fast with a clear version-mismatch error +/// instead of hanging or misbehaving against a field/event it predates. +const PROTOCOL_VERSION: u64 = 2; + +struct ForwardSession { + reader: tokio::sync::Mutex, + writer: tokio::sync::Mutex, +} + +type ForwardSessions = tokio::sync::Mutex>>; + +pub async fn run() -> anyhow::Result<()> { + let port: u16 = std::env::args() + .nth(1) + .ok_or_else(|| anyhow::anyhow!("usage: openshell-supervisor-relay "))? + .parse() + .map_err(|e| anyhow::anyhow!("invalid : {e}"))?; + + // Wait for the driver's "launch" request (see module docs' startup + // handshake) before spawning anything -- command/env arrive over the + // control channel, not as files read from share_dir. Shutdown notice + // arrives the same way (a later "shutdown" request) -- no share_dir + // files are used by this process at all. + let (launch_tx, launch_rx) = oneshot::channel::<(Vec, Vec)>(); + let launch_slot = Arc::new(tokio::sync::Mutex::new(Some(launch_tx))); + let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); + let shutdown_slot = Arc::new(tokio::sync::Mutex::new(Some(shutdown_tx))); + let forward_sessions = Arc::new(ForwardSessions::new(HashMap::new())); + // Lets main() ask run_control_channel's task to announce "target_ready" + // on stdout once the target is actually up (see below) -- routed + // through that task rather than a second independent stdout handle + // here, since concurrent writers to tokio's stdout can interleave. + let (target_ready_tx, target_ready_rx) = oneshot::channel::<()>(); + tokio::spawn(run_control_channel( + launch_slot, + shutdown_slot, + forward_sessions, + target_ready_rx, + )); + + eprintln!("[openshell-supervisor-relay] waiting for launch request from driver..."); + let (command, env) = launch_rx + .await + .map_err(|_| anyhow::anyhow!("control channel closed before a launch request arrived"))?; + + let mut child = spawn_target(command, env)?; + + eprintln!("[openshell-supervisor-relay] waiting for target on 127.0.0.1:{port} ..."); + // Race the (up to ~300s worst case) port-readiness wait against a + // "shutdown" control-channel request, rather than only observing + // shutdown once run_lifecycle's select starts below. Without this, a + // shutdown arriving while the target is still coming up (or never + // binds) gets acknowledged immediately by run_control_channel -- which + // just fires this oneshot, nothing more -- but nothing actually acts on + // it until wait_for_port_ready returns on its own, leaving this process + // (and the target it spawned) alive for up to the full port-readiness + // budget after a caller was told shutdown succeeded. + let mut shutdown_rx = shutdown_rx; + tokio::select! { + result = wait_for_port_ready(&mut child, port, Duration::from_mins(1)) => { + result?; + } + _ = &mut shutdown_rx => { + eprintln!( + "[openshell-supervisor-relay] shutdown request -- stopping before target became ready" + ); + let _ = child.kill().await; + let _ = child.wait().await; + eprintln!("[openshell-supervisor-relay] done"); + // Not `return Ok(())`: run_control_channel loops on + // stdin.next_line() for this process's entire lifetime and + // only sees EOF once the driver closes its end, which it has + // no reason to do before observing this process actually exit + // -- returning normally here would leave that task (and so + // this whole process) alive indefinitely, exactly the + // "acknowledged but still alive" symptom this fix exists to + // close. std::process::exit terminates unconditionally, + // matching run_lifecycle's own shutdown branch below. + std::process::exit(0); + } + } + eprintln!("[openshell-supervisor-relay] target is up on port {port}"); + // Unsolicited event, distinct from the "launch" control-channel + // response (which only confirmed the command/env arrived, not that the + // target is actually reachable) -- driver.rs awaits this before + // publishing the sandbox Ready=True. A send failure just means the + // control-channel task already exited; nothing to do about that here. + let _ = target_ready_tx.send(()); + + // No bridge at startup -- relay bridging is entirely on-demand via the + // control channel's "forward" op (see module docs). Just run the target + // process's lifecycle from here. + run_lifecycle(child, shutdown_rx).await; + + Ok(()) +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/// Render the first `n` bytes of `data` as a printable-ASCII preview +/// (non-printable bytes shown as `.`), for hop-by-hop diagnostic logging. +/// Not a general-purpose formatter -- just enough to eyeball whether e.g. an +/// HTTP/WS handshake looks intact versus corrupted or empty. +/// +/// Not called anywhere: forwarded traffic can carry auth headers, cookies, or +/// other sensitive payload, and helper stderr is forwarded into gateway logs, +/// so no byte preview is ever logged. Kept only so a future opt-in diagnostic +/// mode has a ready-made (still-redaction-worthy) formatter to start from. +#[allow(dead_code)] +fn byte_preview(data: &[u8]) -> String { + const MAX: usize = 120; + let n = data.len().min(MAX); + let mut s: String = data[..n] + .iter() + .map(|&b| { + if b.is_ascii_graphic() || b == b' ' { + b as char + } else { + '.' + } + }) + .collect(); + if data.len() > MAX { + s.push_str("..."); + } + s +} + +// ── Target process ──────────────────────────────────────────────────────────── + +/// Spawn `command` (first element is the executable, rest are args) with +/// `env` (`"KEY=VALUE"` strings) as its entire environment when non-empty -- +/// both arrive over the control channel's `"launch"` request (see module +/// docs), not read from `share_dir` files. The child's stdout/stderr are piped +/// and forwarded (tagged) to our own stderr — not inherited directly — +/// because our stdout is reserved exclusively for the control-channel +/// protocol with the driver. The child's stdin is closed; it isn't part of +/// this channel. +fn spawn_target(command: Vec, env: Vec) -> anyhow::Result { + if command.is_empty() { + anyhow::bail!("launch command must not be empty"); + } + + let mut cmd = tokio::process::Command::new(&command[0]); + cmd.args(&command[1..]); + cmd.stdin(std::process::Stdio::null()); + cmd.stdout(std::process::Stdio::piped()); + cmd.stderr(std::process::Stdio::piped()); + + if !env.is_empty() { + let child_env: Vec<(String, String)> = env + .iter() + .filter(|l| l.contains('=')) + .filter_map(|l| { + let pos = l.find('=')?; + Some((l[..pos].to_string(), l[pos + 1..].to_string())) + }) + .collect(); + eprintln!( + "[openshell-supervisor-relay] using {} child env vars from launch request", + child_env.len() + ); + cmd.env_clear().envs(child_env); + } + + // Not the full command line: it's a control-channel payload (see the + // "launch" handshake in this module's docs) and can carry secrets in + // its arguments (e.g. a token passed via CLI flag) -- log only the + // executable and an argument count, matching how the driver side + // avoids writing agent_command/env to disk for the same reason. + eprintln!( + "[openshell-supervisor-relay] starting program {:?} with {} arg(s)", + command[0], + command.len().saturating_sub(1) + ); + let mut child = cmd.spawn()?; + + if let Some(stdout) = child.stdout.take() { + tokio::spawn(forward_tagged_lines(stdout, "target stdout")); + } + if let Some(stderr) = child.stderr.take() { + tokio::spawn(forward_tagged_lines(stderr, "target stderr")); + } + + Ok(child) +} + +/// Read lines from `reader` and re-emit them on our own stderr, tagged, so +/// the target's output stays visible in the gateway log without touching our +/// stdout (reserved for the control channel). +async fn forward_tagged_lines(reader: impl tokio::io::AsyncRead + Unpin, label: &'static str) { + use tokio::io::{AsyncBufReadExt, BufReader}; + let mut lines = BufReader::new(reader).lines(); + while let Ok(Some(line)) = lines.next_line().await { + eprintln!("[{label}] {line}"); + } +} + +// ── Control channel ─────────────────────────────────────────────────────────── +// +// See module docs for the protocol and why this is safe to run with no +// AppContainer network capability. Runs for the lifetime of the process, +// independent of target/relay state. + +/// Holds the one-shot sender the `"launch"` op fires, carrying `(command, +/// env)` to `main()`. `None` after the first successful launch (or if +/// `main()` already gave up on it) -- a second `"launch"` is rejected. +type LaunchSlot = tokio::sync::Mutex, Vec)>>>; +/// Holds the one-shot sender the `"shutdown"` op fires, waking +/// `run_lifecycle`'s select so it can kill the target and exit. `None` +/// after the first shutdown request -- a second one is a no-op ack. +type ShutdownSlot = tokio::sync::Mutex>>; + +async fn run_control_channel( + launch: Arc, + shutdown: Arc, + forward_sessions: Arc, + target_ready_rx: oneshot::Receiver<()>, +) { + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + + let stdin = tokio::io::stdin(); + let mut lines = BufReader::new(stdin).lines(); + let mut stdout = tokio::io::stdout(); + + // Announce readiness before entering the request loop: this is the + // signal driver.rs's stdout-reader waits for to know it's safe to send + // "launch" (see module docs' startup handshake). Unsolicited -- no + // correlation id, since it isn't a reply to anything the driver sent. + let ready = serde_json::json!({"event": "ready", "protocol_version": PROTOCOL_VERSION}) + .to_string() + + "\n"; + if stdout.write_all(ready.as_bytes()).await.is_err() || stdout.flush().await.is_err() { + eprintln!("[openshell-supervisor-relay] control channel: failed to announce ready"); + return; + } + eprintln!("[openshell-supervisor-relay] control channel ready (stdin/stdout)"); + + // `None` once fired (or once main()'s sender is dropped without firing, + // e.g. wait_for_port_ready failed) -- the `if` guard below then disables + // that select arm instead of it firing repeatedly on every subsequent + // poll of an already-resolved oneshot. + let mut target_ready_rx = Some(target_ready_rx); + + loop { + tokio::select! { + line_result = lines.next_line() => { + let line = match line_result { + Ok(Some(l)) => l, + Ok(None) => { + eprintln!("[openshell-supervisor-relay] control channel: stdin closed"); + break; + } + Err(e) => { + eprintln!("[openshell-supervisor-relay] control channel read error: {e}"); + break; + } + }; + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + + // Log only the operation and correlation id, never the raw request -- + // "launch" carries the target's complete environment (e.g. + // OPENCLAW_GATEWAY_TOKEN) and this process's stderr is forwarded + // verbatim into the gateway's own logs, so printing `trimmed` here + // would expose it even when MXC's own --debug flag is off. + eprintln!( + "[openshell-supervisor-relay] control request: {}", + describe_control_request(trimmed) + ); + let response = handle_control_request( + trimmed, + &launch, + &shutdown, + &forward_sessions, + ) + .await; + let mut out = response.to_string(); + out.push('\n'); + if stdout.write_all(out.as_bytes()).await.is_err() || stdout.flush().await.is_err() { + eprintln!("[openshell-supervisor-relay] control channel write failed"); + break; + } + } + // See module docs' startup handshake -- distinct from the + // "launch" response, which only confirms the command/env + // arrived. Unsolicited, like "ready" above. + result = async { target_ready_rx.as_mut().unwrap().await }, if target_ready_rx.is_some() => { + target_ready_rx = None; + if result.is_ok() { + let event = serde_json::json!({"event": "target_ready"}).to_string() + "\n"; + if stdout.write_all(event.as_bytes()).await.is_err() || stdout.flush().await.is_err() { + eprintln!("[openshell-supervisor-relay] control channel: failed to announce target_ready"); + } + } + // A dropped sender (main() bailed before the target ever + // came up, e.g. wait_for_port_ready's own error) means + // there's nothing to announce -- driver.rs's timeout on the + // corresponding event will surface that as a launch failure + // on its own. + } + } + } +} + +/// `forward` opens a new, independent relay bridge for a target port, e.g. +/// for `openshell forward service`. +/// +/// `data`: `{"relay_addr": "", "target_port": }` -- the +/// caller (the driver) has already started a fresh relay listener on the +/// gateway side for this one request and tells us its address here; we dial +/// out to it (Phase A). Replies once Phase A actually connects (or on +/// failure/timeout), so the caller knows whether the bridge is really usable +/// before it starts sending Phase B clients at the relay address it created. +/// +/// No explicit "stop" for this bridge: it runs until Phase A closes, which +/// happens when the caller drops its relay listener (the gRPC forward +/// stream ending) -- see the driver-side `ForwardSink::open_dynamic_forward`. +/// Summarizes an inbound control-channel request for logging as `op=... id=...` +/// -- deliberately never includes `data`, since `launch` (and, in principle, +/// `echo`) can carry secrets. Falls back to a fixed placeholder rather than +/// printing anything from `line` if it doesn't even parse, so a malformed +/// request can't smuggle sensitive-looking text into the log via a JSON +/// parse failure either. +fn describe_control_request(line: &str) -> String { + serde_json::from_str::(line).map_or_else( + |_| "".to_string(), + |v| { + let op = v.get("op").and_then(|x| x.as_str()).unwrap_or(""); + let id = v.get("id").cloned().unwrap_or(serde_json::Value::Null); + format!("op={op} id={id}") + }, + ) +} + +async fn handle_control_request( + line: &str, + launch: &LaunchSlot, + shutdown: &ShutdownSlot, + forward_sessions: &ForwardSessions, +) -> serde_json::Value { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let req: serde_json::Value = match serde_json::from_str(line) { + Ok(v) => v, + Err(e) => { + return serde_json::json!({"id": null, "ok": false, "error": format!("parse error: {e}")}); + } + }; + let id = req.get("id").cloned().unwrap_or(serde_json::Value::Null); + let op = req.get("op").and_then(|v| v.as_str()).unwrap_or(""); + + match op { + // Driver sends this on sandbox delete instead of writing a + // openshell-shutdown.signal file -- wakes run_lifecycle's select so + // it can kill the target and exit. Acked even on a repeat (the + // slot's already empty by then), since the driver's request has a + // short timeout and shouldn't be left hanging either way. + "shutdown" => { + let slot = shutdown.lock().await.take(); + if let Some(tx) = slot { + let _ = tx.send(()); + } + serde_json::json!({"id": id, "ok": true}) + } + // See module docs' startup handshake: sent once, right after the + // "ready" event, carrying the real command/env instead of them + // being written to share_dir as agent-cmd.txt/agent-env.txt. + "launch" => { + let data = req.get("data").cloned().unwrap_or(serde_json::Value::Null); + let command: Vec = data + .get("command") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default(); + if command.is_empty() { + return serde_json::json!({ + "id": id, "ok": false, + "error": "launch requires data.command (non-empty array of strings)" + }); + } + let env: Vec = data + .get("env") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default(); + + let slot = launch.lock().await.take(); + slot.map_or_else( + || serde_json::json!({"id": id, "ok": false, "error": "launch already requested"}), + |tx| { + let _ = tx.send((command, env)); + serde_json::json!({"id": id, "ok": true}) + }, + ) + } + "ping" => serde_json::json!({"id": id, "ok": true, "data": "pong"}), + "echo" => { + let data = req.get("data").cloned().unwrap_or(serde_json::Value::Null); + serde_json::json!({"id": id, "ok": true, "data": data}) + } + "forward_open" => { + let data = req.get("data").cloned().unwrap_or(serde_json::Value::Null); + let session_id = data + .get("session_id") + .and_then(|v| v.as_str()) + .filter(|v| v.len() == 64 && v.bytes().all(|b| b.is_ascii_hexdigit())) + .map(str::to_string); + let target_port = data + .get("target_port") + .and_then(serde_json::Value::as_u64) + .and_then(|n| u16::try_from(n).ok()); + let (Some(session_id), Some(target_port)) = (session_id, target_port) else { + return serde_json::json!({ + "id": id, "ok": false, + "error": "forward_open requires a 64-character hexadecimal session_id and target_port (u16)" + }); + }; + if forward_sessions.lock().await.contains_key(&session_id) { + return serde_json::json!({ + "id": id, "ok": false, "error": "forward session already exists" + }); + } + let stream = match connect_forward_target(|| { + tokio::net::TcpStream::connect(("127.0.0.1", target_port)) + }) + .await + { + Ok(stream) => stream, + Err(error) => { + return serde_json::json!({ + "id": id, "ok": false, + "error": format!("target connect failed: {error}") + }); + } + }; + if let Err(error) = stream.set_nodelay(true) { + eprintln!( + "[openshell-supervisor-relay] failed to set TCP_NODELAY on target connection: {error}" + ); + } + let (reader, writer) = stream.into_split(); + forward_sessions.lock().await.insert( + session_id, + Arc::new(ForwardSession { + reader: tokio::sync::Mutex::new(reader), + writer: tokio::sync::Mutex::new(writer), + }), + ); + serde_json::json!({"id": id, "ok": true}) + } + "forward_write" => { + let data = req.get("data").cloned().unwrap_or(serde_json::Value::Null); + let session_id = data.get("session_id").and_then(|v| v.as_str()); + let encoded = data.get("bytes").and_then(|v| v.as_str()); + let (Some(session_id), Some(encoded)) = (session_id, encoded) else { + return serde_json::json!({ + "id": id, "ok": false, + "error": "forward_write requires session_id and base64 bytes" + }); + }; + if encoded.len() > 16_384 { + return serde_json::json!({"id": id, "ok": false, "error": "forward_write chunk is too large"}); + } + let bytes = match base64::engine::general_purpose::STANDARD.decode(encoded) { + Ok(bytes) => bytes, + Err(error) => { + return serde_json::json!({ + "id": id, "ok": false, "error": format!("invalid forward bytes: {error}") + }); + } + }; + let session = forward_sessions.lock().await.get(session_id).cloned(); + let Some(session) = session else { + return serde_json::json!({"id": id, "ok": false, "error": "forward session not found"}); + }; + match session.writer.lock().await.write_all(&bytes).await { + Ok(()) => serde_json::json!({"id": id, "ok": true}), + Err(error) => { + serde_json::json!({"id": id, "ok": false, "error": format!("target write failed: {error}")}) + } + } + } + "forward_read" => { + let data = req.get("data").cloned().unwrap_or(serde_json::Value::Null); + let Some(session_id) = data.get("session_id").and_then(|v| v.as_str()) else { + return serde_json::json!({"id": id, "ok": false, "error": "forward_read requires session_id"}); + }; + let session = forward_sessions.lock().await.get(session_id).cloned(); + let Some(session) = session else { + return serde_json::json!({"id": id, "ok": false, "error": "forward session not found"}); + }; + let mut bytes = vec![0_u8; 8192]; + match tokio::time::timeout( + Duration::from_millis(100), + session.reader.lock().await.read(&mut bytes), + ) + .await + { + Ok(Ok(0)) => { + serde_json::json!({"id": id, "ok": true, "data": {"bytes": "", "eof": true}}) + } + Ok(Ok(n)) => { + let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes[..n]); + serde_json::json!({"id": id, "ok": true, "data": {"bytes": encoded, "eof": false}}) + } + Ok(Err(error)) => { + serde_json::json!({"id": id, "ok": false, "error": format!("target read failed: {error}")}) + } + Err(_) => { + serde_json::json!({"id": id, "ok": true, "data": {"bytes": "", "eof": false}}) + } + } + } + "forward_close" => { + let data = req.get("data").cloned().unwrap_or(serde_json::Value::Null); + let Some(session_id) = data.get("session_id").and_then(|v| v.as_str()) else { + return serde_json::json!({"id": id, "ok": false, "error": "forward_close requires session_id"}); + }; + forward_sessions.lock().await.remove(session_id); + serde_json::json!({"id": id, "ok": true}) + } + "forward" => { + let data = req.get("data").cloned().unwrap_or(serde_json::Value::Null); + let relay_addr = data + .get("relay_addr") + .and_then(|v| v.as_str()) + .map(str::to_string); + let target_port = data + .get("target_port") + .and_then(serde_json::Value::as_u64) + .and_then(|n| u16::try_from(n).ok()); + // Proves to the relay (a loopback-bound listener any other local + // process could otherwise race to connect to first) that this + // is the real Phase A peer -- see openshell-driver-mxc's + // relay.rs module docs. Sent verbatim as the WS auth message + // below, not decoded here; the driver and the relay agree on + // the hex encoding independently. + let nonce = data + .get("nonce") + .and_then(|v| v.as_str()) + .map(str::to_string); + + let (Some(relay_addr), Some(target_port), Some(nonce)) = + (relay_addr, target_port, nonce) + else { + return serde_json::json!({ + "id": id, "ok": false, + "error": "forward requires data.relay_addr (string), data.target_port (u16), and data.nonce (string)" + }); + }; + + let (ready_tx, ready_rx) = oneshot::channel::>(); + + eprintln!( + "[openshell-supervisor-relay] forward: dynamic bridge ws://{relay_addr} <-> 127.0.0.1:{target_port}" + ); + tokio::spawn(run_relay_bridge(relay_addr, target_port, nonce, ready_tx)); + + match tokio::time::timeout(Duration::from_secs(5), ready_rx).await { + Ok(Ok(Ok(()))) => { + serde_json::json!({"id": id, "ok": true, "data": {"target_port": target_port}}) + } + Ok(Ok(Err(e))) => serde_json::json!({"id": id, "ok": false, "error": e}), + Ok(Err(_)) => { + serde_json::json!({"id": id, "ok": false, "error": "relay bridge task dropped"}) + } + Err(_) => serde_json::json!({ + "id": id, "ok": false, "error": "timed out waiting for relay connection" + }), + } + } + _ => serde_json::json!({"id": id, "ok": false, "error": format!("unknown op: {op}")}), + } +} + +/// Bound session opening below the driver's 10-second control-request timeout. +/// Five one-second attempts plus four 300ms retry delays take at most 6.2s +/// of timer budget. A stuck connect is cancelled before another is attempted. +async fn connect_forward_target(mut connect: F) -> std::io::Result +where + F: FnMut() -> Fut, + Fut: Future>, +{ + let mut last_error = std::io::Error::from(std::io::ErrorKind::TimedOut); + for attempt in 1..=5 { + match tokio::time::timeout(Duration::from_secs(1), connect()).await { + Ok(Ok(stream)) => return Ok(stream), + Ok(Err(error)) => last_error = error, + Err(_) => last_error = std::io::Error::from(std::io::ErrorKind::TimedOut), + } + if attempt < 5 { + tokio::time::sleep(Duration::from_millis(300)).await; + } + } + Err(last_error) +} + +/// Number of full-budget tries `wait_for_port_ready` makes -- each try gets +/// its own complete `per_try_timeout` window, not a slice of it. Worst case +/// total wait is `max_tries * per_try_timeout` (3 * 60s = 180s today). +const PORT_READY_MAX_TRIES: u32 = 3; + +/// Poll for the target port accepting TCP connections, bailing out early +/// (rather than waiting out all tries) if the child process exits first — a +/// dead child will never open the port, so there's no reason to wait. Makes +/// up to `PORT_READY_MAX_TRIES` tries, each given the full `per_try_timeout` +/// budget, logging the start of every try so a long cold-start wait (Node.js +/// first-run JIT/module resolution, first-touch AV scan of freshly staged +/// binaries, etc.) is visible rather than silent until success or final +/// timeout. +async fn wait_for_port_ready( + child: &mut tokio::process::Child, + port: u16, + per_try_timeout: Duration, +) -> anyhow::Result<()> { + // Bound each individual connect attempt: on at least one observed + // wxc-exec build, a connect() against a not-yet-listening loopback port + // inside the AppContainer never resolved at all (no fast ECONNREFUSED, + // no error) instead of failing quickly like an ordinary closed-port + // connect. Without this, a single early attempt can hang forever and + // this function -- and the whole readiness wait -- never returns even + // after the target's port genuinely opens, since nothing ever retries. + const ATTEMPT_TIMEOUT: Duration = Duration::from_secs(2); + + let overall_start = tokio::time::Instant::now(); + for try_num in 1..=PORT_READY_MAX_TRIES { + eprintln!( + "[openshell-supervisor-relay] port readiness try {try_num}/{PORT_READY_MAX_TRIES} (up to {per_try_timeout:?}) for port {port}" + ); + let try_deadline = tokio::time::Instant::now() + per_try_timeout; + loop { + let attempt = tokio::time::timeout( + ATTEMPT_TIMEOUT, + tokio::net::TcpStream::connect(("127.0.0.1", port)), + ) + .await; + if let Ok(Ok(_)) = attempt { + eprintln!( + "[openshell-supervisor-relay] port {port} ready after {:?} (try {try_num}/{PORT_READY_MAX_TRIES})", + overall_start.elapsed() + ); + return Ok(()); + } + if let Ok(Some(status)) = child.try_wait() { + anyhow::bail!("target process exited before port {port} came up: {status}"); + } + if tokio::time::Instant::now() >= try_deadline { + eprintln!( + "[openshell-supervisor-relay] port readiness try {try_num}/{PORT_READY_MAX_TRIES} timed out after {per_try_timeout:?} (elapsed {:?} total)", + overall_start.elapsed() + ); + break; + } + tokio::time::sleep(Duration::from_millis(300)).await; + } + } + anyhow::bail!( + "timed out after {PORT_READY_MAX_TRIES} tries ({:?} total) waiting for port {port}", + overall_start.elapsed() + ); +} + +// ── Relay bridge ────────────────────────────────────────────────────────────── +// +// Implements the sandbox side of relay.rs's Phase A protocol exactly: +// +// TEXT "SESSION_START" — relay opened a new Phase B TCP connection; open a +// FRESH raw TCP connection to the target and start +// forwarding its bytes back as BINARY frames. +// BINARY — bytes from the Phase B TCP stream; write them +// as-is to the active target connection. +// TEXT "SESSION_END" — Phase B TCP connection closed; drop the target +// connection. +// WS Close — relay shutting down. +// +// This is a raw byte tunnel, not a WS-to-WS message bridge: each session gets +// its own genuine TCP connection to the target with bytes passed through +// untouched, so the host's own protocol (e.g. a real WS handshake it performs +// against the tunnel) reaches the target exactly as sent. A single persistent +// connection re-used across sessions, forwarding opaque message payloads, +// would not preserve that — the target would never see a valid handshake. + +async fn run_relay_bridge( + relay_addr: String, + port: u16, + nonce: String, + ready_tx: oneshot::Sender>, +) { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let relay_url = format!("ws://{relay_addr}"); + + // The gateway already bound this listener before sending us the + // "forward" request, so it's expected to be immediately reachable -- + // single attempt, no retry needed here. + // + // Connect the raw TCP socket ourselves rather than letting + // tokio_tungstenite::connect_async do it internally, so TCP_NODELAY can + // be set before the WS handshake: this is a latency-sensitive + // request/response tunnel, including on loopback, and small WS frames + // can otherwise stall behind delayed ACK behavior. Best-effort -- + // failure to set it doesn't fail the connection, just costs a bit of + // latency. + let tcp = match tokio::net::TcpStream::connect(&relay_addr).await { + Ok(stream) => { + if let Err(e) = stream.set_nodelay(true) { + eprintln!( + "[openshell-supervisor-relay] failed to set TCP_NODELAY on relay connection: {e}" + ); + } + stream + } + Err(e) => { + let msg = format!("relay connect failed: {e}"); + eprintln!("[openshell-supervisor-relay] {msg}"); + let _ = ready_tx.send(Err(msg)); + return; + } + }; + let relay_ws = match tokio_tungstenite::client_async(&relay_url, tcp).await { + Ok((ws, _)) => { + eprintln!("[openshell-supervisor-relay] relay connected: {relay_url}"); + ws + } + Err(e) => { + let msg = format!("relay handshake failed: {e}"); + eprintln!("[openshell-supervisor-relay] {msg}"); + let _ = ready_tx.send(Err(msg)); + return; + } + }; + let (mut relay_write, mut relay_read) = relay_ws.split(); + + // Must be the very first message: the relay won't trust anything else + // from this connection (including SESSION_START/BINARY frames) until + // this matches -- see relay.rs module docs. + if let Err(e) = relay_write + .send(Message::Text(format!("AUTH:{nonce}").into())) + .await + { + let msg = format!("relay auth send failed: {e}"); + eprintln!("[openshell-supervisor-relay] {msg}"); + let _ = ready_tx.send(Err(msg)); + return; + } + + eprintln!("[openshell-supervisor-relay] relay bridge active"); + let _ = ready_tx.send(Ok(())); + + let mut session: Option = None; + let mut read_buf = vec![0u8; 8192]; + // Byte counters, reset per session -- mirror the instrumentation in + // relay.rs. Together the two sides let a hung request be localized to a + // specific hop instead of just "the client timed out": relay.rs's + // host_to_sandbox_* should match this session's phase_a_to_target_* + // (same bytes, different name each side of the WS tunnel), and + // target_to_phase_a_* should match relay.rs's sandbox_to_host_*. A + // mismatch or a stuck-at-zero counter on one side pinpoints exactly + // where bytes stop moving. No payload content is ever logged -- see the + // module-level note on `byte_preview`. + let mut phase_a_to_target_bytes: u64 = 0; + let mut phase_a_to_target_chunks: u64 = 0; + let mut target_to_phase_a_bytes: u64 = 0; + let mut target_to_phase_a_chunks: u64 = 0; + + loop { + let session_read = + futures::future::OptionFuture::from(session.as_mut().map(|s| s.read(&mut read_buf))); + + tokio::select! { + msg = relay_read.next() => match msg { + Some(Ok(Message::Text(t))) => { + if t == "SESSION_START" { + // Retry briefly: even though wait_for_port_ready() already + // confirmed a raw TCP accept succeeds once, that doesn't + // guarantee the target's listener stays continuously + // accept-ready under a freshly-started process (observed as + // a genuine, reproducible ~500ms startup race elsewhere in + // this codebase -- see mxc-ws-agent.rs's local-connect + // retry). A session-open failure here would otherwise + // silently drop the host's connection attempt. + match connect_forward_target(|| tokio::net::TcpStream::connect(("127.0.0.1", port))).await { + Ok(s) => { + // Latency-sensitive request/response tunnel -- + // see the matching comment on the relay + // connection above. Best-effort. + if let Err(e) = s.set_nodelay(true) { + eprintln!("[openshell-supervisor-relay] failed to set TCP_NODELAY on target connection: {e}"); + } + eprintln!("[openshell-supervisor-relay] session start -- connected to target 127.0.0.1:{port}"); + session = Some(s); + phase_a_to_target_bytes = 0; + phase_a_to_target_chunks = 0; + target_to_phase_a_bytes = 0; + target_to_phase_a_chunks = 0; + } + Err(last_err) => { + eprintln!("[openshell-supervisor-relay] session start -- target connect failed: {last_err}"); + // Tell the relay so it can close the host's + // TCP connection promptly instead of leaving + // it hanging until the client's own timeout. + let _ = relay_write + .send(Message::Text(format!("SESSION_FAILED:{last_err}").into())) + .await; + } + } + } else if t == "SESSION_END" { + eprintln!("[openshell-supervisor-relay] session end phase_a_to_target_bytes={phase_a_to_target_bytes} phase_a_to_target_chunks={phase_a_to_target_chunks} target_to_phase_a_bytes={target_to_phase_a_bytes} target_to_phase_a_chunks={target_to_phase_a_chunks}"); + session = None; + } + } + Some(Ok(Message::Binary(b))) => { + if let Some(s) = session.as_mut() { + phase_a_to_target_chunks += 1; + phase_a_to_target_bytes += b.len() as u64; + if phase_a_to_target_chunks == 1 { + eprintln!("[openshell-supervisor-relay] first phase-A->target chunk: {} bytes", b.len()); + } + if s.write_all(&b).await.is_err() { + eprintln!("[openshell-supervisor-relay] target write failed"); + session = None; + } + } else { + eprintln!("[openshell-supervisor-relay] BINARY with no active session (dropped {} bytes)", b.len()); + } + } + Some(Ok(Message::Close(_))) | None => { + eprintln!("[openshell-supervisor-relay] relay closed"); + break; + } + Some(Ok(_)) => {} // ping/pong handled by tungstenite + Some(Err(e)) => { + eprintln!("[openshell-supervisor-relay] relay read error: {e}"); + break; + } + }, + Some(result) = session_read => { + match result { + Ok(0) => { + eprintln!("[openshell-supervisor-relay] target connection closed phase_a_to_target_bytes={phase_a_to_target_bytes} phase_a_to_target_chunks={phase_a_to_target_chunks} target_to_phase_a_bytes={target_to_phase_a_bytes} target_to_phase_a_chunks={target_to_phase_a_chunks}"); + session = None; + // Tell the relay so it can close the host's Phase B + // TCP connection promptly (see relay.rs's handling + // of this message) instead of leaving the host + // client waiting for more bytes until its own + // timeout -- the target won't send any more. + let _ = relay_write.send(Message::Text("SESSION_END".into())).await; + } + Ok(n) => { + target_to_phase_a_chunks += 1; + target_to_phase_a_bytes += n as u64; + if target_to_phase_a_chunks == 1 { + eprintln!("[openshell-supervisor-relay] first target->phase-A chunk: {n} bytes"); + } + if relay_write.send(Message::Binary(read_buf[..n].to_vec().into())).await.is_err() { + break; + } + } + Err(e) => { + eprintln!("[openshell-supervisor-relay] target read error: {e}"); + session = None; + } + } + } + } + } + eprintln!( + "[openshell-supervisor-relay] relay bridge stopped phase_a_to_target_bytes={phase_a_to_target_bytes} phase_a_to_target_chunks={phase_a_to_target_chunks} target_to_phase_a_bytes={target_to_phase_a_bytes} target_to_phase_a_chunks={target_to_phase_a_chunks}" + ); +} + +// ── Lifecycle ───────────────────────────────────────────────────────────────── + +/// Wait for the target process to exit or a "shutdown" control-channel +/// request to arrive (see `handle_control_request`), whichever comes first. +/// Any active dynamic relay bridges are tokio tasks in this same process, so +/// `std::process::exit` below tears them down too -- no separate stop signal +/// needed. +async fn run_lifecycle(mut child: tokio::process::Child, shutdown_rx: oneshot::Receiver<()>) { + tokio::select! { + status = child.wait() => { + let code = status.map_or(1, |s| s.code().unwrap_or(1)); + eprintln!("[openshell-supervisor-relay] target exited with code {code}"); + std::process::exit(code); + } + _ = shutdown_rx => { + eprintln!("[openshell-supervisor-relay] shutdown request -- stopping"); + let _ = child.kill().await; + let _ = child.wait().await; + eprintln!("[openshell-supervisor-relay] done"); + std::process::exit(0); + } + } +} + +#[cfg(test)] +mod forward_connect_tests { + use super::connect_forward_target; + use std::io::{Error, ErrorKind}; + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::time::Duration; + + #[tokio::test(start_paused = true)] + async fn forward_connect_returns_success_without_retry_delay() { + let start = tokio::time::Instant::now(); + let result = connect_forward_target(|| std::future::ready(Ok(42))).await; + assert_eq!(result.unwrap(), 42); + assert_eq!(start.elapsed(), Duration::ZERO); + } + + #[tokio::test(start_paused = true)] + async fn forward_connect_retries_refused_connections_and_preserves_last_error() { + let mut attempts = 0; + let start = tokio::time::Instant::now(); + let result = connect_forward_target(|| { + attempts += 1; + std::future::ready(Err::<(), _>(Error::new( + ErrorKind::ConnectionRefused, + attempts.to_string(), + ))) + }) + .await; + assert_eq!(attempts, 5); + assert_eq!(result.unwrap_err().kind(), ErrorKind::ConnectionRefused); + assert_eq!(start.elapsed(), Duration::from_millis(1200)); + } + + #[tokio::test(start_paused = true)] + async fn forward_connect_cancels_stuck_attempts_before_driver_timeout() { + struct Cancelled(Arc); + impl Drop for Cancelled { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::SeqCst); + } + } + let cancelled = Arc::new(AtomicUsize::new(0)); + let start = tokio::time::Instant::now(); + let result = connect_forward_target(|| { + let guard = Cancelled(cancelled.clone()); + async move { + let _guard = guard; + std::future::pending::>().await + } + }) + .await; + assert_eq!(result.unwrap_err().kind(), ErrorKind::TimedOut); + assert_eq!(cancelled.load(Ordering::SeqCst), 5); + assert_eq!(start.elapsed(), Duration::from_millis(6200)); + assert!(start.elapsed() < Duration::from_secs(10)); + } + + #[tokio::test(start_paused = true)] + async fn forward_connect_recovers_after_a_stuck_attempt() { + let mut attempts = 0; + let start = tokio::time::Instant::now(); + let result = connect_forward_target(|| { + attempts += 1; + let attempt = attempts; + async move { + if attempt == 1 { + std::future::pending::<()>().await; + } + Ok(42) + } + }) + .await; + assert_eq!(result.unwrap(), 42); + assert_eq!(attempts, 2); + assert_eq!(start.elapsed(), Duration::from_millis(1300)); + } +} diff --git a/crates/openshell-supervisor-relay/src/main.rs b/crates/openshell-supervisor-relay/src/main.rs new file mode 100644 index 0000000000..c1117b248b --- /dev/null +++ b/crates/openshell-supervisor-relay/src/main.rs @@ -0,0 +1,32 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Entry point for `openshell-supervisor-relay`. +//! +//! This is an MXC/AppContainer helper -- it only does anything useful on +//! Windows (see `imp.rs` for the real implementation and its module docs). +//! The implementation is technically portable (no Windows-specific APIs), +//! but per the `openshell-driver-mxc` platform pattern, it's gated behind +//! `cfg(target_os = "windows")` so a generic `cargo build --workspace` on +//! Linux/macOS doesn't compile the full relay implementation (and its +//! tokio/tungstenite dependency tree) for a binary those platforms never +//! run. Non-Windows builds get this minimal stub instead, purely so +//! workspace membership (`members = ["crates/*"]`) keeps working everywhere. + +#[cfg(target_os = "windows")] +mod imp; + +#[cfg(target_os = "windows")] +#[tokio::main] +async fn main() -> anyhow::Result<()> { + imp::run().await +} + +#[cfg(not(target_os = "windows"))] +fn main() { + eprintln!( + "openshell-supervisor-relay is a Windows-only MXC ProcessContainer/AppContainer helper; \ + it is not usable on this platform." + ); + std::process::exit(1); +} diff --git a/crates/openshell-supervisor-relay/tests/control_channel_contract.rs b/crates/openshell-supervisor-relay/tests/control_channel_contract.rs new file mode 100644 index 0000000000..1f3fb51464 --- /dev/null +++ b/crates/openshell-supervisor-relay/tests/control_channel_contract.rs @@ -0,0 +1,666 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Black-box integration coverage for `openshell-supervisor-relay`'s +//! control-channel wire contract (see `src/imp.rs`'s module docs for the +//! protocol itself). +//! +//! Each test spawns the real compiled `openshell-supervisor-relay.exe` +//! (via `CARGO_BIN_EXE_...`) as an ordinary child process -- no `wxc-exec`, +//! no `AppContainer`, no MXC involved -- and drives it over its actual +//! stdin/stdout JSON protocol, exactly as `openshell-driver-mxc`'s +//! `driver.rs` and `control_channel.rs` do in production. This exercises +//! the real launch handshake, target-ready ordering, shutdown semantics, +//! and the relay-auth/forward bridging protocol end to end, without +//! requiring a live Windows `AppContainer` host. +//! +//! What this file deliberately does NOT cover: the `ProcessContainer` +//! stop/delete lifecycle as driven by `openshell-driver-mxc`'s +//! `driver.rs` (that needs a real `wxc-exec`/`AppContainer`, or a much +//! larger mock of the whole MXC invoker -- exercised today by +//! `run-openclaw-forward-test.ps1` / `run-ws-agent-test.ps1` against real +//! hardware instead) and the relay-listener half of the auth handshake +//! (`openshell-driver-mxc/src/relay.rs`'s `relay_task`, which has its own +//! unit-testable pieces but isn't exercised here). This file only tests +//! `openshell-supervisor-relay`'s side of the contract, standing in for +//! the relay listener with a small hand-rolled WS server per test. +//! +//! Windows-only, like the binary under test: gated on the whole file via +//! `#![cfg(windows)]` so nothing here (including the dev-dependencies +//! pulled in for it) affects non-Windows builds at all. + +#![cfg(windows)] + +use base64::Engine; +use futures::{SinkExt, StreamExt}; +use serde_json::{Value, json}; +use std::collections::HashSet; +use std::process::Stdio; +use std::time::Duration; +use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader, Lines}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::process::{Child, ChildStdin, ChildStdout, Command}; +use tokio_tungstenite::WebSocketStream; +use tokio_tungstenite::tungstenite::Message; + +const TIMEOUT: Duration = Duration::from_secs(10); + +/// A running `openshell-supervisor-relay.exe`, with its stdin/stdout wired +/// up as a JSON control channel the same way the driver uses them. +struct RelayProcess { + child: Child, + stdin: ChildStdin, + lines: Lines>, + // `None` for `spawn()` (stderr is discarded there -- see its doc + // comment). `spawn_capturing_stderr()` populates this so a test can + // deterministically wait for a specific diagnostic line instead of + // guessing a sleep duration. + stderr_lines: Option>, +} + +impl RelayProcess { + /// Spawn the real binary. `target_port` is the CLI arg the binary + /// expects (its own liveness-check port for whatever `launch` later + /// starts) -- irrelevant to tests that never send `launch`. + async fn spawn(target_port: u16) -> Self { + let mut child = Command::new(env!("CARGO_BIN_EXE_openshell-supervisor-relay")) + .arg(target_port.to_string()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + // Not read by these tests -- null rather than piped-and-ignored + // so the child can never block on a full stderr pipe buffer. + .stderr(Stdio::null()) + .kill_on_drop(true) + .spawn() + .expect("spawn openshell-supervisor-relay.exe (build it first: cargo build -p openshell-supervisor-relay)"); + let stdin = child.stdin.take().expect("piped stdin"); + let stdout = child.stdout.take().expect("piped stdout"); + Self { + child, + stdin, + lines: BufReader::new(stdout).lines(), + stderr_lines: None, + } + } + + /// Like `spawn`, but pipes stderr instead of discarding it, so a test + /// can wait for a specific diagnostic line via `wait_for_stderr_line`. + /// A background task drains it continuously for the process's entire + /// lifetime (forwarding every line over an unbounded channel) -- reading + /// only until the sought-after line arrives and then stopping (as an + /// earlier version of this helper did) leaves the pipe unread from then + /// on; this process's own ongoing diagnostic output (plus anything the + /// launched target itself prints, forwarded through it) then fills the + /// OS pipe buffer and makes its *next* `eprintln!` block synchronously + /// forever -- including ones on the exact shutdown path a test wants to + /// observe. Confirmed by hand: without continuous draining, this + /// deadlocks the relay process itself, not just this test. + async fn spawn_capturing_stderr(target_port: u16) -> Self { + let mut child = Command::new(env!("CARGO_BIN_EXE_openshell-supervisor-relay")) + .arg(target_port.to_string()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .expect("spawn openshell-supervisor-relay.exe (build it first: cargo build -p openshell-supervisor-relay)"); + let stdin = child.stdin.take().expect("piped stdin"); + let stdout = child.stdout.take().expect("piped stdout"); + let stderr = child.stderr.take().expect("piped stderr"); + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + tokio::spawn(async move { + let mut lines = BufReader::new(stderr).lines(); + while let Ok(Some(line)) = lines.next_line().await { + if tx.send(line).is_err() { + break; + } + } + }); + Self { + child, + stdin, + lines: BufReader::new(stdout).lines(), + stderr_lines: Some(rx), + } + } + + /// Consume forwarded stderr lines (discarding non-matching ones) until + /// one contains `pattern`, or `timeout` elapses. `timeout` is + /// deliberately a caller argument, not the shared `TIMEOUT` constant -- + /// spawning a real OS process for this to wait on can occasionally take + /// far longer than this file's other, purely protocol-level waits + /// (observed on this host: real-time AV scanning stalling + /// `CreateProcess` well past 10s, unrelated to anything this binary + /// controls). + async fn wait_for_stderr_line(&mut self, pattern: &str, timeout: Duration) { + let rx = self + .stderr_lines + .as_mut() + .expect("wait_for_stderr_line requires spawn_capturing_stderr"); + tokio::time::timeout(timeout, async { + loop { + match rx.recv().await { + Some(line) if line.contains(pattern) => return, + Some(_) => {} + None => { + panic!("relay stderr closed before printing a line containing {pattern:?}") + } + } + } + }) + .await + .unwrap_or_else(|_| { + panic!("timed out after {timeout:?} waiting for a stderr line containing {pattern:?}") + }); + } + + async fn next_line(&mut self) -> String { + tokio::time::timeout(TIMEOUT, self.lines.next_line()) + .await + .expect("timed out waiting for a control-channel line") + .expect("stdout read error") + .expect("relay exited before producing the expected line") + } + + async fn next_json(&mut self) -> Value { + let line = self.next_line().await; + serde_json::from_str(&line) + .unwrap_or_else(|e| panic!("non-JSON control-channel line {line:?}: {e}")) + } + + async fn send(&mut self, value: Value) { + let mut line = serde_json::to_string(&value).expect("serialize request"); + line.push('\n'); + self.stdin + .write_all(line.as_bytes()) + .await + .expect("write control-channel request"); + self.stdin + .flush() + .await + .expect("flush control-channel request"); + } + + /// Consume and validate the startup handshake event -- see + /// `control_channel::try_route_ready` on the driver side, which this + /// mirrors. + async fn expect_ready(&mut self) { + let v = self.next_json().await; + assert_eq!(v["event"], "ready"); + assert_eq!(v["protocol_version"], 2); + } + + async fn launch(&mut self, id: u64, command: &[&str]) -> Value { + self.send(json!({ + "id": id, + "op": "launch", + "data": {"command": command, "env": []}, + })) + .await; + self.next_json().await + } +} + +/// Spawn an in-process TCP echo server (a stand-in for whatever real +/// `agent_command` target would be bound to a port in production) and +/// return the port it bound. Handles concurrent connections -- each +/// accepted connection gets its own task -- so it doubles as the shared +/// target for the concurrent-forwards test. +async fn spawn_echo_target() -> u16 { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + tokio::spawn(async move { + loop { + let Ok((mut sock, _)) = listener.accept().await else { + break; + }; + tokio::spawn(async move { + let mut buf = [0u8; 4096]; + loop { + match sock.read(&mut buf).await { + Ok(0) | Err(_) => break, + Ok(n) => { + if sock.write_all(&buf[..n]).await.is_err() { + break; + } + } + } + } + }); + } + }); + port +} + +/// Stand-in for `openshell-driver-mxc/src/relay.rs`'s Phase A listener: +/// accept one TCP connection, complete the WS upgrade, and require the +/// first message to be exactly `AUTH:` -- matching what +/// `run_relay_bridge` in `imp.rs` sends. Panics (failing the test) if +/// anything else arrives first, same as a real relay would just silently +/// distrust and drop the connection. +async fn accept_and_authenticate( + listener: &TcpListener, + expected_nonce: &str, +) -> WebSocketStream { + let (stream, _addr) = tokio::time::timeout(TIMEOUT, listener.accept()) + .await + .expect("timed out waiting for the relay's Phase A connection") + .expect("accept failed"); + let mut ws = tokio_tungstenite::accept_async(stream) + .await + .expect("WS upgrade failed"); + let msg = tokio::time::timeout(TIMEOUT, ws.next()) + .await + .expect("timed out waiting for the AUTH message") + .expect("relay closed before sending AUTH") + .expect("WS read error"); + let expected = format!("AUTH:{expected_nonce}"); + match msg { + Message::Text(t) if t == expected => {} + other => panic!("expected {expected:?} as the first message, got {other:?}"), + } + ws +} + +// ── Startup handshake ──────────────────────────────────────────────────── + +#[tokio::test(flavor = "multi_thread")] +async fn ready_event_reports_protocol_version() { + let mut relay = RelayProcess::spawn(0).await; + relay.expect_ready().await; +} + +// ── ping / echo (protocol sanity, no launch required) ────────────────────── + +#[tokio::test(flavor = "multi_thread")] +async fn ping_and_echo_round_trip() { + let mut relay = RelayProcess::spawn(0).await; + relay.expect_ready().await; + + relay.send(json!({"id": 1, "op": "ping"})).await; + assert_eq!( + relay.next_json().await, + json!({"id": 1, "ok": true, "data": "pong"}) + ); + + relay + .send(json!({"id": 2, "op": "echo", "data": {"x": 1, "y": "two"}})) + .await; + let resp = relay.next_json().await; + assert_eq!(resp["id"], 2); + assert_eq!(resp["ok"], true); + assert_eq!(resp["data"], json!({"x": 1, "y": "two"})); +} + +// ── launch: success and failure ───────────────────────────────────────── + +#[tokio::test(flavor = "multi_thread")] +async fn launch_fails_fast_when_command_is_empty() { + let mut relay = RelayProcess::spawn(0).await; + relay.expect_ready().await; + + let resp = relay.launch(1, &[]).await; + assert_eq!(resp["id"], 1); + assert_eq!(resp["ok"], false); + assert!( + resp["error"].as_str().unwrap().contains("non-empty array"), + "unexpected error message: {resp}" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn launch_success_then_target_ready_ordering() { + // Reserve a free port, then launch a target that binds exactly it -- + // small a-priori race (something else could steal the port between the + // bind-and-drop below and the launch), acceptable for a test. + let port = { + let l = TcpListener::bind("127.0.0.1:0").await.unwrap(); + l.local_addr().unwrap().port() + }; + let script = format!( + "$l=[System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback,{port}); \ + $l.Start(); Start-Sleep -Seconds 30" + ); + + let mut relay = RelayProcess::spawn(port).await; + relay.expect_ready().await; + + let ack = relay + .launch( + 1, + &[ + "powershell", + "-NoProfile", + "-NonInteractive", + "-Command", + &script, + ], + ) + .await; + assert_eq!(ack["id"], 1); + assert_eq!(ack["ok"], true, "launch ack: {ack}"); + + // The "launch" response only confirms the command/env arrived -- the + // unsolicited "target_ready" event (no correlation id) is the actual + // liveness confirmation once the port readiness poll succeeds, and it + // must not have been sent already (it can't have been: nothing before + // this point in the protocol lets the spawner know the port bound). + // Reading it as the very next line asserts the ordering directly. + let target_ready = relay.next_json().await; + assert_eq!(target_ready["event"], "target_ready"); + assert!( + target_ready.get("id").is_none(), + "target_ready must not carry a correlation id" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn shutdown_is_acked_and_the_process_exits() { + let port = { + let l = TcpListener::bind("127.0.0.1:0").await.unwrap(); + l.local_addr().unwrap().port() + }; + let script = format!( + "$l=[System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback,{port}); \ + $l.Start(); Start-Sleep -Seconds 30" + ); + + let mut relay = RelayProcess::spawn(port).await; + relay.expect_ready().await; + let ack = relay + .launch( + 1, + &[ + "powershell", + "-NoProfile", + "-NonInteractive", + "-Command", + &script, + ], + ) + .await; + assert_eq!(ack["ok"], true); + let target_ready = relay.next_json().await; + assert_eq!(target_ready["event"], "target_ready"); + + relay.send(json!({"id": 2, "op": "shutdown"})).await; + let ack = relay.next_json().await; + assert_eq!(ack, json!({"id": 2, "ok": true})); + + let status = tokio::time::timeout(TIMEOUT, relay.child.wait()) + .await + .expect("relay did not exit within the timeout after shutdown") + .expect("wait() failed"); + assert!(status.success(), "expected a clean exit, got {status:?}"); +} + +/// Reproduces the reported race: a "shutdown" request arriving while the +/// target is still coming up (here, one that never binds the port at all) +/// must stop this process promptly, not leave it waiting out the full +/// port-readiness budget (~300s worst case, see `wait_for_port_ready`) +/// before ever observing the shutdown that `run_control_channel` already +/// acknowledged. Without racing that wait against shutdown, the final +/// `child.wait()` below would time out instead of completing within +/// `SHUTDOWN_TIMEOUT`. +/// +/// Waits for the relay's own "waiting for target on ..." stderr line +/// before sending shutdown, rather than sending it right after the launch +/// ack -- the ack fires as soon as the request is parsed, before this +/// process's own `spawn_target()` (a plain `CreateProcess` call) has +/// necessarily completed, and *that* call is a separate, pre-existing +/// source of multi-second-to-multi-minute stalls on this host (real-time +/// AV scanning a freshly-launched process) that this fix does not -- and is +/// not meant to -- address. Anchoring on that line instead isolates the +/// assertion to the one thing this fix actually changed: how promptly a +/// shutdown arriving *during the port-readiness poll itself* is observed +/// and acted on. +/// +/// Does not close `stdin` before waiting on process exit -- on purpose, +/// matching driver.rs, which never closes its end of the control channel +/// before observing the relay exit either. An earlier version of this +/// fix returned normally from `run()` on this path instead of calling +/// `std::process::exit`; `run_control_channel` loops on +/// `stdin.next_line()` for the process's entire lifetime, so with `stdin` +/// still open (as it always is against a real driver) that task -- and so +/// the whole process -- stayed alive indefinitely even after `run()` had +/// already returned. Confirmed by hand with internal timestamps: the fix +/// fired and `run()` returned within single-digit milliseconds while the +/// process, observed externally, never exited. This test would have +/// caught that. +#[tokio::test(flavor = "multi_thread")] +async fn shutdown_during_port_wait_stops_promptly() { + // Generous: covers this host's observed CreateProcess stalls (up to + // ~60s) plus real margin, not just the fast path. + const SPAWN_TIMEOUT: Duration = Duration::from_mins(2); + // Well under the ~300s port-readiness budget this fix exists to avoid + // waiting out -- generous only relative to `TIMEOUT`, since a bare + // `std::process::exit` completes in well under a second. + const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(20); + + let port = { + let l = TcpListener::bind("127.0.0.1:0").await.unwrap(); + l.local_addr().unwrap().port() + }; + + let mut relay = RelayProcess::spawn_capturing_stderr(port).await; + relay.expect_ready().await; + // A real target that never binds `port` -- port-readiness polling never + // succeeds on its own. cmd.exe rather than powershell.exe: smaller, + // simpler, and this test's target just needs to run for a while and + // never bind `port`, not do anything powershell-specific. + let ack = relay + .launch(1, &["cmd", "/c", "timeout /t 300 /nobreak >nul"]) + .await; + assert_eq!(ack["ok"], true, "launch ack: {ack}"); + + // Confirms spawn_target() has returned and wait_for_port_ready has + // started -- only from this point on is the fix under test actually + // in play. + relay + .wait_for_stderr_line("waiting for target on", SPAWN_TIMEOUT) + .await; + + relay.send(json!({"id": 2, "op": "shutdown"})).await; + let ack = relay.next_json().await; + assert_eq!(ack, json!({"id": 2, "ok": true})); + + let status = tokio::time::timeout(SHUTDOWN_TIMEOUT, relay.child.wait()) + .await + .expect( + "relay did not exit promptly after shutdown during port-wait \ + (see imp.rs's port-wait/shutdown race)", + ) + .expect("wait() failed"); + assert!(status.success(), "expected a clean exit, got {status:?}"); +} + +// ── forward: authenticated relay association + byte bridging ─────────────── + +#[tokio::test(flavor = "multi_thread")] +async fn control_channel_forward_round_trips_bytes_without_host_callback_networking() { + let target_port = spawn_echo_target().await; + let mut relay = RelayProcess::spawn(0).await; + relay.expect_ready().await; + let session_id = "a".repeat(64); + + relay + .send(json!({ + "id": 1, "op": "forward_open", + "data": {"session_id": session_id, "target_port": target_port}, + })) + .await; + assert_eq!(relay.next_json().await, json!({"id": 1, "ok": true})); + + let payload = b"stdio-forward-round-trip"; + relay + .send(json!({ + "id": 2, "op": "forward_write", + "data": { + "session_id": session_id, + "bytes": base64::engine::general_purpose::STANDARD.encode(payload), + }, + })) + .await; + assert_eq!(relay.next_json().await, json!({"id": 2, "ok": true})); + + let echoed = loop { + relay + .send(json!({ + "id": 3, "op": "forward_read", "data": {"session_id": session_id}, + })) + .await; + let response = relay.next_json().await; + let encoded = response["data"]["bytes"].as_str().unwrap(); + if !encoded.is_empty() { + break base64::engine::general_purpose::STANDARD + .decode(encoded) + .unwrap(); + } + }; + assert_eq!(echoed, payload); + + relay + .send(json!({ + "id": 4, "op": "forward_close", "data": {"session_id": session_id}, + })) + .await; + assert_eq!(relay.next_json().await, json!({"id": 4, "ok": true})); +} + +#[tokio::test(flavor = "multi_thread")] +async fn forward_with_correct_auth_bridges_bytes_both_directions() { + let target_port = spawn_echo_target().await; + let relay_listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let relay_addr = relay_listener.local_addr().unwrap(); + + let mut relay = RelayProcess::spawn(0).await; + relay.expect_ready().await; + + let nonce = "test-nonce-abc123"; + relay + .send(json!({ + "id": 5, + "op": "forward", + "data": {"relay_addr": relay_addr.to_string(), "target_port": target_port, "nonce": nonce}, + })) + .await; + + // Both sides of the handshake only make progress if driven + // concurrently: the relay's forward ack doesn't arrive until its WS + // client connection to us completes, which needs us to actually + // accept it. + let (mut ws, ack) = tokio::join!( + accept_and_authenticate(&relay_listener, nonce), + relay.next_json() + ); + assert_eq!(ack["id"], 5); + assert_eq!(ack["ok"], true, "forward ack: {ack}"); + + ws.send(Message::Text("SESSION_START".into())) + .await + .unwrap(); + let payload = b"hello over the bridge".to_vec(); + ws.send(Message::Binary(payload.clone().into())) + .await + .unwrap(); + + let echoed = tokio::time::timeout(TIMEOUT, ws.next()) + .await + .expect("timed out waiting for the echoed bytes") + .expect("WS closed before echoing") + .expect("WS read error"); + match echoed { + Message::Binary(b) => assert_eq!(b.as_ref(), payload.as_slice()), + other => panic!("expected a Binary echo, got {other:?}"), + } + + ws.send(Message::Text("SESSION_END".into())).await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn concurrent_forwards_do_not_cross_talk() { + let target_port = spawn_echo_target().await; + let listener_a = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr_a = listener_a.local_addr().unwrap(); + let listener_b = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr_b = listener_b.local_addr().unwrap(); + + let mut relay = RelayProcess::spawn(0).await; + relay.expect_ready().await; + + relay + .send(json!({ + "id": 1, "op": "forward", + "data": {"relay_addr": addr_a.to_string(), "target_port": target_port, "nonce": "nonce-a"}, + })) + .await; + relay + .send(json!({ + "id": 2, "op": "forward", + "data": {"relay_addr": addr_b.to_string(), "target_port": target_port, "nonce": "nonce-b"}, + })) + .await; + + let (ws_a, ws_b) = tokio::join!( + accept_and_authenticate(&listener_a, "nonce-a"), + accept_and_authenticate(&listener_b, "nonce-b"), + ); + let (mut ws_a, mut ws_b) = (ws_a, ws_b); + + let ack1 = relay.next_json().await; + let ack2 = relay.next_json().await; + assert!( + ack1["ok"] == true && ack2["ok"] == true, + "acks: {ack1} / {ack2}" + ); + let ids: HashSet<_> = [ack1["id"].as_u64(), ack2["id"].as_u64()] + .into_iter() + .collect(); + assert_eq!( + ids, + HashSet::from([Some(1), Some(2)]), + "both forward requests must be acked exactly once" + ); + + ws_a.send(Message::Text("SESSION_START".into())) + .await + .unwrap(); + ws_b.send(Message::Text("SESSION_START".into())) + .await + .unwrap(); + ws_a.send(Message::Binary(b"payload-A".to_vec().into())) + .await + .unwrap(); + ws_b.send(Message::Binary(b"payload-B".to_vec().into())) + .await + .unwrap(); + + let (echo_a, echo_b) = tokio::join!( + tokio::time::timeout(TIMEOUT, ws_a.next()), + tokio::time::timeout(TIMEOUT, ws_b.next()), + ); + match echo_a + .expect("timeout on A") + .expect("closed on A") + .expect("read error on A") + { + Message::Binary(b) => assert_eq!( + b.as_ref(), + b"payload-A", + "session A must not see session B's bytes" + ), + other => panic!("unexpected message on A: {other:?}"), + } + match echo_b + .expect("timeout on B") + .expect("closed on B") + .expect("read error on B") + { + Message::Binary(b) => assert_eq!( + b.as_ref(), + b"payload-B", + "session B must not see session A's bytes" + ), + other => panic!("unexpected message on B: {other:?}"), + } +} diff --git a/crates/openshell-tui/src/lib.rs b/crates/openshell-tui/src/lib.rs index 55fbcaff87..d9569b7970 100644 --- a/crates/openshell-tui/src/lib.rs +++ b/crates/openshell-tui/src/lib.rs @@ -817,6 +817,7 @@ async fn handle_sandbox_delete(app: &mut App, tx: mpsc::UnboundedSender) let req = openshell_core::proto::DeleteSandboxRequest { name: sandbox_name, workspace_scope: Some(named_workspace_scope(app.selected_sandbox_workspace())), + ..Default::default() }; match app.client.delete_sandbox(req).await { Ok(_) => { diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index ab24fd26b6..44de908678 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -659,7 +659,7 @@ The gateway verifies supervisor JWT-SVIDs with JWT bundles fetched from the SPIFFE Workload API, so this validation path does not require gateway access to the SPIRE OIDC discovery endpoint or its TLS CA. -### MXC +### MXC audit configuration The MXC driver runs Windows workloads through `wxc-exec`. Enable ETW auditing to map Windows Sandboxing provider events into the gateway's OCSF stream. @@ -920,6 +920,45 @@ OpenShell sends no override and Podman applies its runtime-selected profile. Set `Unconfined` explicitly only when the deployment requires the supervisor's mount setup to bypass that profile. +### MXC + +The MXC driver is Windows-only and opt-in. It links into the gateway, invokes Microsoft MXC through `wxc-exec.exe`, and runs each sandbox's configured command in-driver instead of using the Linux sandbox supervisor. + +```toml +[openshell] +version = 1 + +[openshell.gateway] +bind_address = "127.0.0.1:17670" +log_level = "info" +compute_drivers = ["mxc"] + +[openshell.drivers.mxc] +wxc_exec_path = "C:\\mxc\\wxc-exec.exe" +# process_container (default) or isolation_session. +backend = "process_container" +default_configuration_id = "composable" +pc_least_privilege = false +pc_capabilities = [] +# Pattern C governed egress. MXC 0.8 denies direct Internet egress and permits +# host loopback; proxy-aware clients receive HTTP_PROXY/HTTPS_PROXY and the host +# CONNECT proxy enforces the trimmed network policy. Requires process_container. +egress_proxy = false +egress_proxy_addr = "" +debug = false +etw_audit = false +``` + +Set `egress_proxy = true` with `egress_proxy_addr = "127.0.0.1:18080"` to enable the Windows Pattern C split. The address must be a `127.0.0.1:PORT` socket. The driver allocates a unique ephemeral port per sandbox, injects that listener through proxy environment variables, and stages the public proxy CA beneath the sandbox's configured `/.openshell-proxy//`. A non-empty per-sandbox `cwd` is therefore required when governed egress is enabled; sandbox-specific subdirectories prevent concurrent sandboxes from overwriting each other's trust files. MXC denies direct Internet egress but allows `127.0.0.1/32`; this permits dynamic forwarding but also means the sandbox can reach unrelated host services bound to loopback. + +MXC rejects policies containing `network_middlewares` before launch because this host-proxy path does not receive the gateway middleware registry. + +Supply the workload command and optional working directory through `sandbox create --driver-config-json`, for example `{"mxc":{"command":["cmd","/c","echo hello"],"cwd":"C:\\work"}}`. Supply workload environment through `sandbox create --env` or `--env-from`; it is not part of gateway configuration. + +Attached provider credentials require this governed-egress path. The gateway gives MXC only revision-scoped environment placeholders and keeps real values in the host proxy's endpoint-bound resolver. Provider keys override matching sandbox environment entries case-insensitively. Sandbox creation fails when attached provider material exists but `egress_proxy` is disabled. MXC takes its static provider snapshot at creation and rejects credentials with an expiration timestamp because it has no live refresh channel. Dynamic token grants continue to mint credentials per request in the host proxy. Recreate the sandbox after attaching, detaching, rotating, or revoking a non-expiring static provider credential. + +The driver assigns a distinct loopback listener port and proxy credentials to each MXC sandbox. The host proxy rejects missing, invalid, duplicate, or cross-sandbox proxy credentials before forwarding. This authenticates the sandbox's proxy access; it does not isolate unrelated host-loopback services or distinguish processes within the same sandbox. Treat the gateway host and processes that can read the sandbox credentials as trusted. + ### MicroVM Each sandbox runs inside its own libkrun microVM managed by the standalone `openshell-driver-vm` subprocess. Use this driver when you want stronger isolation than container namespaces alone. diff --git a/docs/reference/policy-schema.mdx b/docs/reference/policy-schema.mdx index 0961a8d0b3..36bfa0778a 100644 --- a/docs/reference/policy-schema.mdx +++ b/docs/reference/policy-schema.mdx @@ -19,6 +19,7 @@ version: 1 filesystem_policy: { ... } landlock: { ... } process: { ... } +ui: { ... } network_policies: { ... } network_middlewares: { ... } ``` @@ -29,6 +30,7 @@ network_middlewares: { ... } | `filesystem_policy` | object | No | Static | Controls which directories the agent can read and write. | | `landlock` | object | No | Static | Configures Landlock LSM enforcement behavior. | | `process` | object | No | Static | Sets the user and group the agent process runs as. | +| `ui` | object | No | Static | Controls graphical UI, directional clipboard access, and synthetic input on compute drivers that advertise complete enforcement. | | `network_policies` | map | No | Dynamic | Declares which binaries can reach which network endpoints. | | `network_middlewares` | map | No | Dynamic | Attaches ordered middleware by destination host; each implementation's manifest selects its supported HTTP and WebSocket operations. | @@ -142,6 +144,44 @@ process: run_as_group: "1500" ``` +## UI + +**Category:** Static + +Declares platform-neutral UI capabilities. Within an explicit section, every +field is deny by default, so `ui: {}` grants nothing on a supporting backend. +Omit the entire section when the workload does not need a UI surface. + +| Field | Type | Required | Values | Description | +|---|---|---|---|---| +| `allow_graphical_ui` | bool | No | `true`, `false` | Allows the workload to display graphical windows. Defaults to `false`. | +| `clipboard` | string | No | `none`, `read`, `write`, `all` | Controls host clipboard direction from the sandbox's perspective. Defaults to `none`. | +| `allow_input_injection` | bool | No | `true`, `false` | Allows synthetic keyboard or pointer input. Defaults to `false`. | + +MXC `process_container` enforces this section on Windows and advertises complete +support through the compute-driver capability contract. MXC's `disable` switch +suppresses the other UI fields, so a clipboard or input-injection grant on this +backend also requires `allow_graphical_ui: true`; OpenShell rejects a grant that +MXC would ignore. OpenShell rejects any explicit UI section, including `{}`, with +MXC `isolation_session` because current MXC rejects the top-level `ui` object +there. Docker, Podman, Kubernetes, VM, and older or partial extension drivers +also advertise no support, so the gateway rejects explicit UI policy before +validation or provisioning. Omitting the section preserves each runtime's +existing behavior. + +UI is a per-sandbox startup contract and cannot be set in a gateway-global +policy. A global policy changes dynamic policy fields while each sandbox keeps +the UI controls applied from its own policy at creation. + +Example: + +```yaml showLineNumbers={false} +ui: + allow_graphical_ui: true + clipboard: read + allow_input_injection: false +``` + ## Network Policies **Category:** Dynamic diff --git a/docs/sandboxes/manage-sandboxes.mdx b/docs/sandboxes/manage-sandboxes.mdx index 730b7da794..602a0ff01b 100644 --- a/docs/sandboxes/manage-sandboxes.mdx +++ b/docs/sandboxes/manage-sandboxes.mdx @@ -256,7 +256,7 @@ Create a sandbox from a template: openshell sandbox create --template gpu-kata --provider github -- claude ``` -The `--template` flag cannot be combined with inline workload flags such as `--from`, `--cpu`, `--memory`, `--gpu`, `--env`, or `--driver-config-json`. Put those values on the template instead. Create-time policy and provider attachments remain part of the sandbox request, so each sandbox can keep its own access boundary. +The `--template` flag cannot be combined with inline workload flags such as `--from`, `--cpu`, `--memory`, `--gpu`, `--env`, `--env-from`, or `--driver-config-json`. Put those values on the template instead. Create-time policy and provider attachments remain part of the sandbox request, so each sandbox can keep its own access boundary. Inspect and manage templates: @@ -373,6 +373,16 @@ openshell sandbox create --env API_KEY=sk-test --env DEBUG=1 -- my-agent Variables set with `--env` are available to all processes in the sandbox, including interactive shells and exec commands. +Use `--env-from KEY[=ENVVAR]` when the value should come from the CLI process environment instead of appearing in the CLI process arguments. If `ENVVAR` is omitted, OpenShell reads `KEY`: + +```shell +export SESSION_TOKEN="..." +openshell sandbox create --env-from SESSION_TOKEN -- my-agent +openshell sandbox create --env-from AGENT_TOKEN=SESSION_TOKEN -- my-agent +``` + +`--env-from` changes only how the CLI receives the value. The resulting variable is still available to processes in the sandbox, just like `--env`. + When an `--env` key looks like a credential — a known provider variable, or a name whose underscore-separated segments include a credential word such as `TOKEN`, `SECRET`, `PASSWORD`, `CREDENTIAL`, `API_KEY`, `ACCESS_KEY`, or `SECRET_KEY` (for example `DB_TOKEN` or `MY_ACCESS_KEY`) — `sandbox create` prints a non-blocking warning. Matching is on whole segments, so unrelated names like `TOKENIZERS_PARALLELISM` or `PASSWORDLESS_LOGIN` do not warn. The agent inside the sandbox can read plain environment values directly, so to hide a secret from the agent, attach it through a [profile-backed provider](/providers/profiles) with `--provider` instead. Suppress the warning with `--no-credential-warnings`. Detection uses the key name only; values are never inspected or printed. You can also set per-command environment variables with `sandbox exec`: @@ -750,6 +760,20 @@ When a multi-sandbox delete fails for one entry, the CLI reports that sandbox's failure and continues with the remaining names. The command exits with an error after it attempts every requested deletion if any entry failed. +Automation that already observed a sandbox's immutable identity can prevent a +same-name replacement from being deleted: + +```shell +openshell sandbox delete my-sandbox \ + --expected-id \ + --expected-resource-version +``` + +Identity preconditions require exactly one sandbox name, and a resource version +may be supplied only with the immutable ID. The gateway returns an `ABORTED` +error without changing the sandbox or calling its compute driver when either +value no longer matches. + ## Sandbox Lifecycle Every sandbox moves through a defined set of phases: diff --git a/docs/sandboxes/policies.mdx b/docs/sandboxes/policies.mdx index da2cb16eb4..1d3685bf39 100644 --- a/docs/sandboxes/policies.mdx +++ b/docs/sandboxes/policies.mdx @@ -12,7 +12,7 @@ Use this page to apply and iterate policy changes on running sandboxes. For a fu ## Policy Structure -A policy has static sections `filesystem_policy`, `landlock`, and `process` that are locked at sandbox creation, and dynamic `network_policies` and `network_middlewares` sections that are hot-reloadable on a running sandbox. +A policy has static sections `filesystem_policy`, `landlock`, `process`, and `ui` that are locked at sandbox creation. `network_policies` and `network_middlewares` are dynamic schema sections, but live updates work only on compute drivers that support runtime policy reload. MXC rejects live policy replacement and merge updates, so recreate the sandbox there. ```yaml wordWrap showLineNumbers={false} version: 1 @@ -32,6 +32,13 @@ landlock: # run_as_user: "1500" # run_as_group: "1500" +# Static, optional: portable UI capabilities. Within this explicit section, +# omitted values deny access. Only drivers advertising complete support accept it. +# ui: +# allow_graphical_ui: true +# clipboard: read +# allow_input_injection: false + # Dynamic: hot-reloadable. Named blocks of endpoints + binaries allowed to reach them. network_policies: my_api: @@ -69,6 +76,7 @@ When a hot reload changes rules, the supervisor publishes a new policy generatio | `filesystem_policy` | Static | Controls which directories the agent can access on disk. Paths are split into `read_only` and `read_write` lists. Any path not listed in either list is inaccessible. Set `include_workdir: true` to automatically add the agent's working directory to `read_write`. [Landlock LSM](https://docs.kernel.org/security/landlock.html) enforces these restrictions at the kernel level. | | `landlock` | Static | Configures Landlock LSM enforcement behavior. Set `compatibility` to `best_effort` (skip individual inaccessible paths while applying remaining rules) or `hard_requirement` (fail if any path is inaccessible or the required kernel ABI is unavailable). Refer to the [Policy Schema Reference](/reference/policy-schema#landlock) for the full behavior table. | | `process` | Static | Optionally overrides the OS-level identity for the agent process. Explicit values must be `sandbox` or numeric UID/GID values from `1` through `4294967294`; root and the invalid identity sentinel are rejected. Docker and Podman may use named identities through per-field OCI `USER` fallback; Kubernetes uses its platform-selected numeric identity. The agent also runs with seccomp filters that block dangerous system calls. | +| `ui` | Static | Controls portable graphical UI, directional clipboard, and synthetic-input capabilities. Within an explicit section, every omitted value denies. MXC `process_container` advertises complete enforcement. MXC `isolation_session`, Docker, Podman, Kubernetes, VM, and partial extension drivers reject any explicit section before provisioning. Omission preserves existing runtime behavior. | | `network_policies` | Dynamic | Controls outbound traffic from the sandbox, including native model-provider endpoints. Each block has a name, a list of endpoints (host, port, protocol, and optional rules), and a list of binaries allowed to use those endpoints.
Every outbound connection passes through the network supervisor, which queries the [policy engine](/about/how-it-works#core-components) with the destination and calling binary. A connection is allowed only when both match an entry in the same policy block. Attached provider profiles can contribute endpoint and binary entries to the effective policy.
For endpoints with `protocol: rest`, the proxy auto-detects TLS and terminates it so each HTTP request can be checked against that endpoint's `rules` (method and path). For endpoints with `protocol: websocket`, the proxy validates the RFC 6455 upgrade and evaluates `GET` rules for the handshake plus either `WEBSOCKET_TEXT` rules for raw client text messages or GraphQL operation rules for GraphQL-over-WebSocket messages. Set `websocket_credential_rewrite: true` only when a WebSocket or REST compatibility endpoint must keep placeholder credentials in sandbox-owned text frames and resolve them at the OpenShell relay boundary.
Endpoints with `protocol: tcp` allow ordinary DNS resolution and native TCP connections without inspecting payloads. Endpoints without `protocol` retain L4 passthrough through an explicit proxy.
If no endpoint matches, the connection is denied. | | `network_middlewares` | Dynamic | Declares keyed HTTP and WebSocket middleware configs. After network and L7 policy admit a request or upgrade, OpenShell matches each config's host selectors independently and runs matching entries by their unique ascending `order` before credential injection. WebSocket-capable entries continue on complete client text messages. | @@ -601,7 +609,12 @@ openshell policy set --global --policy ./global-policy.yaml When a global policy is configured: -- The global payload is applied in full for all sandboxes. +- The global payload replaces every sandbox's complete effective policy, + including static sections such as `filesystem_policy`, `landlock`, and + `process`, not only the dynamic `network_policies` and + `network_middlewares` fields. +- Each sandbox keeps the startup-only `ui` section from its own creation policy; + a global policy containing `ui` is rejected. - Sandbox-level policy updates are rejected until the global policy is removed. To restore sandbox-level policy control, delete the global policy setting: diff --git a/proto/compute_driver.proto b/proto/compute_driver.proto index f9a19589f9..1d781bd6ea 100644 --- a/proto/compute_driver.proto +++ b/proto/compute_driver.proto @@ -100,6 +100,10 @@ message GetCapabilitiesResponse { // Maximum rootfs tar file size in bytes accepted by the driver. Zero means // the driver does not support rootfs tar sources. uint64 rootfs_tar_max_bytes = 11; + // Whether this configured driver instance completely enforces the current + // portable SandboxPolicy.ui contract. Partial support must report false so + // the gateway rejects every explicit UI section before provisioning. + bool supports_ui_policy = 12; } message AuthenticateSandboxRequest { diff --git a/proto/openshell.proto b/proto/openshell.proto index fd664a0f7b..458c87b026 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -864,6 +864,10 @@ message ComputeDriverCapabilities { // Static portable resource request forms reported by the driver. ResourceCapabilities resource_capabilities = 3; + + // Whether the configured driver instance completely enforces the portable + // SandboxPolicy.ui contract. + bool supports_ui_policy = 4; } // Static portable resource request forms reported by a compute driver. @@ -1308,6 +1312,16 @@ message DeleteSandboxRequest { string name = 1; // Explicit workspace scope. The all-workspaces selection is invalid. openshell.datamodel.v1.WorkspaceSelector workspace_scope = 3; + // Optional immutable sandbox identity precondition. When non-empty, the + // gateway rejects the request with ABORTED unless the currently resolved + // sandbox has this exact metadata ID. The check is repeated under the + // lifecycle lock immediately before any delete mutation. + string expected_sandbox_id = 4; + // Optional optimistic-concurrency precondition. Requires + // expected_sandbox_id. When non-zero, the gateway rejects the request with + // ABORTED unless the sandbox's current resource version matches this value + // immediately before any delete mutation. + uint64 expected_resource_version = 5; } // Stop sandbox request. diff --git a/proto/sandbox.proto b/proto/sandbox.proto index 894e3b2754..5f207a5d16 100644 --- a/proto/sandbox.proto +++ b/proto/sandbox.proto @@ -31,6 +31,10 @@ message SandboxPolicy { // policy-local names. At most 10 configs are accepted, and at most 10 stages // can be selected per request. map network_middlewares = 6; + // Static, platform-neutral user-interface access policy. Within an explicit + // section, omitted capabilities deny. Omitting the section preserves the + // compute platform's existing behavior. + UiPolicy ui = 7; } // Filesystem access policy. @@ -57,6 +61,32 @@ message ProcessPolicy { string run_as_group = 2; } +// Directional clipboard access for a sandboxed workload. +enum UiClipboardAccess { + // Unspecified resolves to no clipboard access. + UI_CLIPBOARD_ACCESS_UNSPECIFIED = 0; + // No clipboard reads or writes. + UI_CLIPBOARD_ACCESS_NONE = 1; + // The sandbox may read host clipboard contents. + UI_CLIPBOARD_ACCESS_READ = 2; + // The sandbox may write host clipboard contents. + UI_CLIPBOARD_ACCESS_WRITE = 3; + // The sandbox may read and write host clipboard contents. + UI_CLIPBOARD_ACCESS_ALL = 4; +} + +// Platform-neutral user-interface capabilities. Every omitted field in an +// explicit policy defaults to deny. Compute platforms without complete support +// reject the entire explicit policy before provisioning. +message UiPolicy { + // Allow the sandbox to display graphical windows. + bool allow_graphical_ui = 1; + // Directional host clipboard access. + UiClipboardAccess clipboard = 2; + // Allow the sandbox to synthesize keyboard or pointer input. + bool allow_input_injection = 3; +} + // A named network access policy rule. message NetworkPolicyRule { // Human-readable name for this policy rule. diff --git a/sdk/go/openshell/v1/fake/sandbox.go b/sdk/go/openshell/v1/fake/sandbox.go index 59c14535b8..8c66201b9a 100644 --- a/sdk/go/openshell/v1/fake/sandbox.go +++ b/sdk/go/openshell/v1/fake/sandbox.go @@ -79,6 +79,10 @@ func copySandboxPolicy(p *types.SandboxPolicy) *types.SandboxPolicy { pr := *p.Process cp.Process = &pr } + if p.UI != nil { + ui := *p.UI + cp.UI = &ui + } if p.NetworkPolicies != nil { np := make(map[string]types.NetworkPolicyRule, len(p.NetworkPolicies)) for k, rule := range p.NetworkPolicies { diff --git a/sdk/go/openshell/v1/fake/sandbox_test.go b/sdk/go/openshell/v1/fake/sandbox_test.go index 1a2673808a..8975f6c3d9 100644 --- a/sdk/go/openshell/v1/fake/sandbox_test.go +++ b/sdk/go/openshell/v1/fake/sandbox_test.go @@ -837,6 +837,11 @@ func TestFakeSandboxCreateWithPolicy(t *testing.T) { RunAsUser: "sandbox", RunAsGroup: "sandbox-group", }, + UI: &types.UIPolicy{ + AllowGraphicalUI: true, + Clipboard: types.UIClipboardAccessRead, + AllowInputInjection: true, + }, NetworkPolicies: map[string]types.NetworkPolicyRule{ "web": { Name: "web", @@ -874,6 +879,10 @@ func TestFakeSandboxCreateWithPolicy(t *testing.T) { require.NotNil(t, p.Process) assert.Equal(t, "sandbox", p.Process.RunAsUser) assert.Equal(t, "sandbox-group", p.Process.RunAsGroup) + require.NotNil(t, p.UI) + assert.True(t, p.UI.AllowGraphicalUI) + assert.Equal(t, types.UIClipboardAccessRead, p.UI.Clipboard) + assert.True(t, p.UI.AllowInputInjection) require.Len(t, p.NetworkPolicies, 1) webRule, ok := p.NetworkPolicies["web"] @@ -886,19 +895,23 @@ func TestFakeSandboxCreateWithPolicy(t *testing.T) { // Deep-copy isolation: mutate input spec, verify stored copy unchanged spec.Policy.Version = 99 spec.Policy.Filesystem.ReadOnly[0] = "mutated" + spec.Policy.UI.Clipboard = types.UIClipboardAccessAll spec.Policy.NetworkPolicies["web"] = types.NetworkPolicyRule{Name: "mutated"} got2, err := sc.Get(ctx, "default", "policy-sb") require.NoError(t, err) assert.Equal(t, uint32(3), got2.Spec.Policy.Version) assert.Equal(t, "/etc", got2.Spec.Policy.Filesystem.ReadOnly[0]) + assert.Equal(t, types.UIClipboardAccessRead, got2.Spec.Policy.UI.Clipboard) assert.Equal(t, "web", got2.Spec.Policy.NetworkPolicies["web"].Name) // Deep-copy isolation: mutate returned object, verify store unchanged got.Spec.Policy.Filesystem.ReadWrite[0] = "mutated" + got.Spec.Policy.UI.AllowInputInjection = false got3, err := sc.Get(ctx, "default", "policy-sb") require.NoError(t, err) assert.Equal(t, "/tmp", got3.Spec.Policy.Filesystem.ReadWrite[0]) + assert.True(t, got3.Spec.Policy.UI.AllowInputInjection) } func TestFakeSandboxCreateWithNilPolicy(t *testing.T) { diff --git a/sdk/go/openshell/v1/internal/converter/coverage_test.go b/sdk/go/openshell/v1/internal/converter/coverage_test.go index 5103d4b1fc..2a7757bddd 100644 --- a/sdk/go/openshell/v1/internal/converter/coverage_test.go +++ b/sdk/go/openshell/v1/internal/converter/coverage_test.go @@ -162,6 +162,7 @@ func TestConverterCoversAllProtoFields_SandboxPolicy(t *testing.T) { "network_policies": true, "process": true, "landlock": true, + "ui": true, "network_middlewares": true, } diff --git a/sdk/go/openshell/v1/internal/converter/health.go b/sdk/go/openshell/v1/internal/converter/health.go index 63ab8c3298..f2f873ae8e 100644 --- a/sdk/go/openshell/v1/internal/converter/health.go +++ b/sdk/go/openshell/v1/internal/converter/health.go @@ -48,6 +48,7 @@ func ComputeDriverInfoFromProto(d *pb.ComputeDriverInfo) types.ComputeDriverInfo if caps := d.GetCapabilities(); caps != nil { result.DriverName = caps.GetDriverName() result.DriverVersion = caps.GetDriverVersion() + result.SupportsUIPolicy = caps.GetSupportsUiPolicy() } return result } diff --git a/sdk/go/openshell/v1/internal/converter/health_test.go b/sdk/go/openshell/v1/internal/converter/health_test.go index d0360a9b66..0a9cfb6a3f 100644 --- a/sdk/go/openshell/v1/internal/converter/health_test.go +++ b/sdk/go/openshell/v1/internal/converter/health_test.go @@ -20,8 +20,9 @@ func TestGatewayInfoFromProto(t *testing.T) { { Name: "k8s", Capabilities: &pb.ComputeDriverCapabilities{ - DriverName: "kubernetes", - DriverVersion: "2.1.0", + DriverName: "kubernetes", + DriverVersion: "2.1.0", + SupportsUiPolicy: true, }, }, { @@ -43,8 +44,10 @@ func TestGatewayInfoFromProto(t *testing.T) { assert.Equal(t, "k8s", info.ComputeDrivers[0].Name) assert.Equal(t, "kubernetes", info.ComputeDrivers[0].DriverName) assert.Equal(t, "2.1.0", info.ComputeDrivers[0].DriverVersion) + assert.True(t, info.ComputeDrivers[0].SupportsUIPolicy) assert.Equal(t, "docker", info.ComputeDrivers[1].Name) assert.Equal(t, "docker-engine", info.ComputeDrivers[1].DriverName) + assert.False(t, info.ComputeDrivers[1].SupportsUIPolicy) } func TestGatewayInfoFromProto_NoDrivers(t *testing.T) { @@ -107,6 +110,7 @@ func TestComputeDriverInfoFromProto_NilCapabilities(t *testing.T) { assert.Equal(t, "bare-metal", info.Name) assert.Empty(t, info.DriverName) assert.Empty(t, info.DriverVersion) + assert.False(t, info.SupportsUIPolicy) } func TestCurrentUserFromProto(t *testing.T) { diff --git a/sdk/go/openshell/v1/internal/converter/policy.go b/sdk/go/openshell/v1/internal/converter/policy.go index 8d68ca2f57..73cb9fc980 100644 --- a/sdk/go/openshell/v1/internal/converter/policy.go +++ b/sdk/go/openshell/v1/internal/converter/policy.go @@ -117,6 +117,7 @@ func SandboxPolicyFromProto(p *sbv1.SandboxPolicy) *types.SandboxPolicy { Filesystem: filesystemPolicyFromProto(p.GetFilesystem()), Landlock: landlockPolicyFromProto(p.GetLandlock()), Process: processPolicyFromProto(p.GetProcess()), + UI: uiPolicyFromProto(p.GetUi()), } if np := p.GetNetworkPolicies(); np != nil { result.NetworkPolicies = make(map[string]types.NetworkPolicyRule, len(np)) @@ -148,6 +149,7 @@ func SandboxPolicyToProto(p *types.SandboxPolicy) *sbv1.SandboxPolicy { Filesystem: filesystemPolicyToProto(p.Filesystem), Landlock: landlockPolicyToProto(p.Landlock), Process: processPolicyToProto(p.Process), + Ui: uiPolicyToProto(p.UI), } if p.NetworkPolicies != nil { result.NetworkPolicies = make(map[string]*sbv1.NetworkPolicyRule, len(p.NetworkPolicies)) @@ -184,6 +186,58 @@ func SandboxPolicyToProtoChecked(p *types.SandboxPolicy) (*sbv1.SandboxPolicy, e return result, nil } +func uiPolicyFromProto(p *sbv1.UiPolicy) *types.UIPolicy { + if p == nil { + return nil + } + return &types.UIPolicy{ + AllowGraphicalUI: p.GetAllowGraphicalUi(), + Clipboard: uiClipboardAccessFromProto(p.GetClipboard()), + AllowInputInjection: p.GetAllowInputInjection(), + } +} + +func uiPolicyToProto(p *types.UIPolicy) *sbv1.UiPolicy { + if p == nil { + return nil + } + return &sbv1.UiPolicy{ + AllowGraphicalUi: p.AllowGraphicalUI, + Clipboard: uiClipboardAccessToProto(p.Clipboard), + AllowInputInjection: p.AllowInputInjection, + } +} + +func uiClipboardAccessFromProto(v sbv1.UiClipboardAccess) types.UIClipboardAccess { + switch v { + case sbv1.UiClipboardAccess_UI_CLIPBOARD_ACCESS_NONE: + return types.UIClipboardAccessNone + case sbv1.UiClipboardAccess_UI_CLIPBOARD_ACCESS_READ: + return types.UIClipboardAccessRead + case sbv1.UiClipboardAccess_UI_CLIPBOARD_ACCESS_WRITE: + return types.UIClipboardAccessWrite + case sbv1.UiClipboardAccess_UI_CLIPBOARD_ACCESS_ALL: + return types.UIClipboardAccessAll + default: + return types.UIClipboardAccessUnspecified + } +} + +func uiClipboardAccessToProto(v types.UIClipboardAccess) sbv1.UiClipboardAccess { + switch v { + case types.UIClipboardAccessNone: + return sbv1.UiClipboardAccess_UI_CLIPBOARD_ACCESS_NONE + case types.UIClipboardAccessRead: + return sbv1.UiClipboardAccess_UI_CLIPBOARD_ACCESS_READ + case types.UIClipboardAccessWrite: + return sbv1.UiClipboardAccess_UI_CLIPBOARD_ACCESS_WRITE + case types.UIClipboardAccessAll: + return sbv1.UiClipboardAccess_UI_CLIPBOARD_ACCESS_ALL + default: + return sbv1.UiClipboardAccess_UI_CLIPBOARD_ACCESS_UNSPECIFIED + } +} + func middlewareConfigFromProto(m *sbv1.NetworkMiddlewareConfig) types.NetworkMiddlewareConfig { result := types.NetworkMiddlewareConfig{ Name: m.GetName(), diff --git a/sdk/go/openshell/v1/internal/converter/policy_test.go b/sdk/go/openshell/v1/internal/converter/policy_test.go index 6935b3def0..c3606548a9 100644 --- a/sdk/go/openshell/v1/internal/converter/policy_test.go +++ b/sdk/go/openshell/v1/internal/converter/policy_test.go @@ -200,6 +200,11 @@ func TestSandboxPolicyRoundTrip(t *testing.T) { RunAsUser: "sandbox-user", RunAsGroup: "sandbox-group", }, + UI: &v1.UIPolicy{ + AllowGraphicalUI: true, + Clipboard: v1.UIClipboardAccessRead, + AllowInputInjection: true, + }, NetworkPolicies: map[string]v1.NetworkPolicyRule{ "web-api": { Name: "web-api", @@ -239,6 +244,10 @@ func TestSandboxPolicyRoundTrip(t *testing.T) { assert.Equal(t, original.Process.RunAsUser, roundTrip.Process.RunAsUser) assert.Equal(t, original.Process.RunAsGroup, roundTrip.Process.RunAsGroup) + // UI + require.NotNil(t, roundTrip.UI) + assert.Equal(t, original.UI, roundTrip.UI) + // NetworkPolicies require.Len(t, roundTrip.NetworkPolicies, 2) webAPI, ok := roundTrip.NetworkPolicies["web-api"] @@ -315,6 +324,39 @@ func TestSandboxPolicyPartialSubPolicies(t *testing.T) { assert.Nil(t, roundTrip.NetworkPolicies) }) + t.Run("only UI", func(t *testing.T) { + for _, clipboard := range []v1.UIClipboardAccess{ + v1.UIClipboardAccessUnspecified, + v1.UIClipboardAccessNone, + v1.UIClipboardAccessRead, + v1.UIClipboardAccessWrite, + v1.UIClipboardAccessAll, + } { + original := &v1.SandboxPolicy{ + UI: &v1.UIPolicy{ + AllowGraphicalUI: true, + Clipboard: clipboard, + AllowInputInjection: true, + }, + } + roundTrip := SandboxPolicyFromProto(SandboxPolicyToProto(original)) + require.NotNil(t, roundTrip) + assert.Equal(t, original.UI, roundTrip.UI) + } + }) + + t.Run("explicit empty UI remains present", func(t *testing.T) { + original := &v1.SandboxPolicy{UI: &v1.UIPolicy{}} + protoPolicy := SandboxPolicyToProto(original) + require.NotNil(t, protoPolicy) + require.NotNil(t, protoPolicy.Ui) + + roundTrip := SandboxPolicyFromProto(protoPolicy) + require.NotNil(t, roundTrip) + require.NotNil(t, roundTrip.UI) + assert.Equal(t, &v1.UIPolicy{}, roundTrip.UI) + }) + t.Run("only landlock", func(t *testing.T) { original := &v1.SandboxPolicy{ Version: 2, diff --git a/sdk/go/openshell/v1/types/health.go b/sdk/go/openshell/v1/types/health.go index 1db3ec3872..14a4e2544a 100644 --- a/sdk/go/openshell/v1/types/health.go +++ b/sdk/go/openshell/v1/types/health.go @@ -29,9 +29,10 @@ type GatewayInfo struct { // ComputeDriverInfo describes a compute backend available on the gateway. type ComputeDriverInfo struct { - Name string - DriverName string - DriverVersion string + Name string + DriverName string + DriverVersion string + SupportsUIPolicy bool } // CurrentUser holds the authenticated caller's identity. diff --git a/sdk/go/openshell/v1/types/policy.go b/sdk/go/openshell/v1/types/policy.go index 8fb0712b60..ab01014522 100644 --- a/sdk/go/openshell/v1/types/policy.go +++ b/sdk/go/openshell/v1/types/policy.go @@ -112,7 +112,7 @@ type DraftPolicy struct { // SandboxPolicy is the top-level security policy configuration for a sandbox. // It contains filesystem access rules, Landlock LSM configuration, process -// identity rules, and named network access policies. +// identity rules, portable UI capabilities, and named network access policies. type SandboxPolicy struct { // Version is the policy version number. The server may override this on write. Version uint32 @@ -125,6 +125,9 @@ type SandboxPolicy struct { // Process controls the user and group identity for sandboxed processes. // Nil means no process policy is specified. Process *ProcessPolicy + // UI controls portable graphical UI, clipboard, and input-injection capabilities. + // Nil means no UI policy is specified. + UI *UIPolicy // NetworkPolicies contains named network access rules. // Nil means no network policies are specified; an empty map is distinct from nil. NetworkPolicies map[string]NetworkPolicyRule @@ -178,6 +181,33 @@ type ProcessPolicy struct { RunAsGroup string } +// UIClipboardAccess controls host clipboard direction from the sandbox's perspective. +type UIClipboardAccess int + +const ( + // UIClipboardAccessUnspecified resolves to no clipboard access. + UIClipboardAccessUnspecified UIClipboardAccess = iota + // UIClipboardAccessNone denies clipboard reads and writes. + UIClipboardAccessNone + // UIClipboardAccessRead permits reading host clipboard contents. + UIClipboardAccessRead + // UIClipboardAccessWrite permits writing host clipboard contents. + UIClipboardAccessWrite + // UIClipboardAccessAll permits reading and writing host clipboard contents. + UIClipboardAccessAll +) + +// UIPolicy declares platform-neutral user-interface capabilities. +// Every zero value denies access. +type UIPolicy struct { + // AllowGraphicalUI permits the workload to display graphical windows. + AllowGraphicalUI bool + // Clipboard controls host clipboard direction. + Clipboard UIClipboardAccess + // AllowInputInjection permits synthetic keyboard or pointer input. + AllowInputInjection bool +} + // SandboxPolicyRevision represents a versioned policy revision for a sandbox. type SandboxPolicyRevision struct { // Version is the policy version (monotonically increasing per sandbox). diff --git a/sdk/go/proto/openshellv1/openshell.pb.go b/sdk/go/proto/openshellv1/openshell.pb.go index fe5f82d6c1..0a9455d742 100644 --- a/sdk/go/proto/openshellv1/openshell.pb.go +++ b/sdk/go/proto/openshellv1/openshell.pb.go @@ -1171,8 +1171,11 @@ type ComputeDriverCapabilities struct { DriverVersion string `protobuf:"bytes,2,opt,name=driver_version,json=driverVersion,proto3" json:"driver_version,omitempty"` // Static portable resource request forms reported by the driver. ResourceCapabilities *ResourceCapabilities `protobuf:"bytes,3,opt,name=resource_capabilities,json=resourceCapabilities,proto3" json:"resource_capabilities,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Whether the configured driver instance completely enforces the portable + // SandboxPolicy.ui contract. + SupportsUiPolicy bool `protobuf:"varint,4,opt,name=supports_ui_policy,json=supportsUiPolicy,proto3" json:"supports_ui_policy,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ComputeDriverCapabilities) Reset() { @@ -1226,6 +1229,13 @@ func (x *ComputeDriverCapabilities) GetResourceCapabilities() *ResourceCapabilit return nil } +func (x *ComputeDriverCapabilities) GetSupportsUiPolicy() bool { + if x != nil { + return x.SupportsUiPolicy + } + return false +} + // Static portable resource request forms reported by a compute driver. // An omitted domain means the driver does not report that domain. type ResourceCapabilities struct { @@ -3513,8 +3523,18 @@ type DeleteSandboxRequest struct { Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` // Explicit workspace scope. The all-workspaces selection is invalid. WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,3,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Optional immutable sandbox identity precondition. When non-empty, the + // gateway rejects the request with ABORTED unless the currently resolved + // sandbox has this exact metadata ID. The check is repeated under the + // lifecycle lock immediately before any delete mutation. + ExpectedSandboxId string `protobuf:"bytes,4,opt,name=expected_sandbox_id,json=expectedSandboxId,proto3" json:"expected_sandbox_id,omitempty"` + // Optional optimistic-concurrency precondition. Requires + // expected_sandbox_id. When non-zero, the gateway rejects the request with + // ABORTED unless the sandbox's current resource version matches this value + // immediately before any delete mutation. + ExpectedResourceVersion uint64 `protobuf:"varint,5,opt,name=expected_resource_version,json=expectedResourceVersion,proto3" json:"expected_resource_version,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *DeleteSandboxRequest) Reset() { @@ -3561,6 +3581,20 @@ func (x *DeleteSandboxRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelecto return nil } +func (x *DeleteSandboxRequest) GetExpectedSandboxId() string { + if x != nil { + return x.ExpectedSandboxId + } + return "" +} + +func (x *DeleteSandboxRequest) GetExpectedResourceVersion() uint64 { + if x != nil { + return x.ExpectedResourceVersion + } + return 0 +} + // Stop sandbox request. type StopSandboxRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -14721,12 +14755,13 @@ const file_openshell_proto_rawDesc = "" + "\x0fcompute_drivers\x18\x03 \x03(\v2\x1f.openshell.v1.ComputeDriverInfoR\x0ecomputeDrivers\"t\n" + "\x11ComputeDriverInfo\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12K\n" + - "\fcapabilities\x18\x02 \x01(\v2'.openshell.v1.ComputeDriverCapabilitiesR\fcapabilities\"\xbc\x01\n" + + "\fcapabilities\x18\x02 \x01(\v2'.openshell.v1.ComputeDriverCapabilitiesR\fcapabilities\"\xea\x01\n" + "\x19ComputeDriverCapabilities\x12\x1f\n" + "\vdriver_name\x18\x01 \x01(\tR\n" + "driverName\x12%\n" + "\x0edriver_version\x18\x02 \x01(\tR\rdriverVersion\x12W\n" + - "\x15resource_capabilities\x18\x03 \x01(\v2\".openshell.v1.ResourceCapabilitiesR\x14resourceCapabilities\"\xca\x01\n" + + "\x15resource_capabilities\x18\x03 \x01(\v2\".openshell.v1.ResourceCapabilitiesR\x14resourceCapabilities\x12,\n" + + "\x12supports_ui_policy\x18\x04 \x01(\bR\x10supportsUiPolicy\"\xca\x01\n" + "\x14ResourceCapabilities\x127\n" + "\x03cpu\x18\x01 \x01(\v2%.openshell.v1.CpuResourceCapabilitiesR\x03cpu\x12@\n" + "\x06memory\x18\x02 \x01(\v2(.openshell.v1.MemoryResourceCapabilitiesR\x06memory\x127\n" + @@ -14911,10 +14946,12 @@ const file_openshell_proto_rawDesc = "" + "\fsandbox_name\x18\x01 \x01(\tR\vsandboxName\x12#\n" + "\rprovider_name\x18\x02 \x01(\tR\fproviderName\x12:\n" + "\x19expected_resource_version\x18\x03 \x01(\x04R\x17expectedResourceVersion\x12R\n" + - "\x0fworkspace_scope\x18\x05 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x04\x10\x05R\tworkspace\"\x8f\x01\n" + + "\x0fworkspace_scope\x18\x05 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x04\x10\x05R\tworkspace\"\xfb\x01\n" + "\x14DeleteSandboxRequest\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12R\n" + - "\x0fworkspace_scope\x18\x03 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x02\x10\x03R\tworkspace\"\x8d\x01\n" + + "\x0fworkspace_scope\x18\x03 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScope\x12.\n" + + "\x13expected_sandbox_id\x18\x04 \x01(\tR\x11expectedSandboxId\x12:\n" + + "\x19expected_resource_version\x18\x05 \x01(\x04R\x17expectedResourceVersionJ\x04\b\x02\x10\x03R\tworkspace\"\x8d\x01\n" + "\x12StopSandboxRequest\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12R\n" + "\x0fworkspace_scope\x18\x03 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x02\x10\x03R\tworkspace\"\x8e\x01\n" + diff --git a/sdk/go/proto/sandboxv1/sandbox.pb.go b/sdk/go/proto/sandboxv1/sandbox.pb.go index 17d8909f83..7afffa4c7b 100644 --- a/sdk/go/proto/sandboxv1/sandbox.pb.go +++ b/sdk/go/proto/sandboxv1/sandbox.pb.go @@ -25,6 +25,67 @@ const ( _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) +// Directional clipboard access for a sandboxed workload. +type UiClipboardAccess int32 + +const ( + // Unspecified resolves to no clipboard access. + UiClipboardAccess_UI_CLIPBOARD_ACCESS_UNSPECIFIED UiClipboardAccess = 0 + // No clipboard reads or writes. + UiClipboardAccess_UI_CLIPBOARD_ACCESS_NONE UiClipboardAccess = 1 + // The sandbox may read host clipboard contents. + UiClipboardAccess_UI_CLIPBOARD_ACCESS_READ UiClipboardAccess = 2 + // The sandbox may write host clipboard contents. + UiClipboardAccess_UI_CLIPBOARD_ACCESS_WRITE UiClipboardAccess = 3 + // The sandbox may read and write host clipboard contents. + UiClipboardAccess_UI_CLIPBOARD_ACCESS_ALL UiClipboardAccess = 4 +) + +// Enum value maps for UiClipboardAccess. +var ( + UiClipboardAccess_name = map[int32]string{ + 0: "UI_CLIPBOARD_ACCESS_UNSPECIFIED", + 1: "UI_CLIPBOARD_ACCESS_NONE", + 2: "UI_CLIPBOARD_ACCESS_READ", + 3: "UI_CLIPBOARD_ACCESS_WRITE", + 4: "UI_CLIPBOARD_ACCESS_ALL", + } + UiClipboardAccess_value = map[string]int32{ + "UI_CLIPBOARD_ACCESS_UNSPECIFIED": 0, + "UI_CLIPBOARD_ACCESS_NONE": 1, + "UI_CLIPBOARD_ACCESS_READ": 2, + "UI_CLIPBOARD_ACCESS_WRITE": 3, + "UI_CLIPBOARD_ACCESS_ALL": 4, + } +) + +func (x UiClipboardAccess) Enum() *UiClipboardAccess { + p := new(UiClipboardAccess) + *p = x + return p +} + +func (x UiClipboardAccess) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (UiClipboardAccess) Descriptor() protoreflect.EnumDescriptor { + return file_sandbox_proto_enumTypes[0].Descriptor() +} + +func (UiClipboardAccess) Type() protoreflect.EnumType { + return &file_sandbox_proto_enumTypes[0] +} + +func (x UiClipboardAccess) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use UiClipboardAccess.Descriptor instead. +func (UiClipboardAccess) EnumDescriptor() ([]byte, []int) { + return file_sandbox_proto_rawDescGZIP(), []int{0} +} + // Scope that currently controls a setting. type SettingScope int32 @@ -59,11 +120,11 @@ func (x SettingScope) String() string { } func (SettingScope) Descriptor() protoreflect.EnumDescriptor { - return file_sandbox_proto_enumTypes[0].Descriptor() + return file_sandbox_proto_enumTypes[1].Descriptor() } func (SettingScope) Type() protoreflect.EnumType { - return &file_sandbox_proto_enumTypes[0] + return &file_sandbox_proto_enumTypes[1] } func (x SettingScope) Number() protoreflect.EnumNumber { @@ -72,7 +133,7 @@ func (x SettingScope) Number() protoreflect.EnumNumber { // Deprecated: Use SettingScope.Descriptor instead. func (SettingScope) EnumDescriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{0} + return file_sandbox_proto_rawDescGZIP(), []int{1} } // Source used for the policy payload in GetSandboxConfigResponse. @@ -109,11 +170,11 @@ func (x PolicySource) String() string { } func (PolicySource) Descriptor() protoreflect.EnumDescriptor { - return file_sandbox_proto_enumTypes[1].Descriptor() + return file_sandbox_proto_enumTypes[2].Descriptor() } func (PolicySource) Type() protoreflect.EnumType { - return &file_sandbox_proto_enumTypes[1] + return &file_sandbox_proto_enumTypes[2] } func (x PolicySource) Number() protoreflect.EnumNumber { @@ -122,7 +183,7 @@ func (x PolicySource) Number() protoreflect.EnumNumber { // Deprecated: Use PolicySource.Descriptor instead. func (PolicySource) EnumDescriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{1} + return file_sandbox_proto_rawDescGZIP(), []int{2} } // Sandbox security policy configuration. @@ -142,8 +203,12 @@ type SandboxPolicy struct { // policy-local names. At most 10 configs are accepted, and at most 10 stages // can be selected per request. NetworkMiddlewares map[string]*NetworkMiddlewareConfig `protobuf:"bytes,6,rep,name=network_middlewares,json=networkMiddlewares,proto3" json:"network_middlewares,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Static, platform-neutral user-interface access policy. Within an explicit + // section, omitted capabilities deny. Omitting the section preserves the + // compute platform's existing behavior. + Ui *UiPolicy `protobuf:"bytes,7,opt,name=ui,proto3" json:"ui,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *SandboxPolicy) Reset() { @@ -218,6 +283,13 @@ func (x *SandboxPolicy) GetNetworkMiddlewares() map[string]*NetworkMiddlewareCon return nil } +func (x *SandboxPolicy) GetUi() *UiPolicy { + if x != nil { + return x.Ui + } + return nil +} + // Filesystem access policy. type FilesystemPolicy struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -383,6 +455,72 @@ func (x *ProcessPolicy) GetRunAsGroup() string { return "" } +// Platform-neutral user-interface capabilities. Every omitted field in an +// explicit policy defaults to deny. Compute platforms without complete support +// reject the entire explicit policy before provisioning. +type UiPolicy struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Allow the sandbox to display graphical windows. + AllowGraphicalUi bool `protobuf:"varint,1,opt,name=allow_graphical_ui,json=allowGraphicalUi,proto3" json:"allow_graphical_ui,omitempty"` + // Directional host clipboard access. + Clipboard UiClipboardAccess `protobuf:"varint,2,opt,name=clipboard,proto3,enum=openshell.sandbox.v1.UiClipboardAccess" json:"clipboard,omitempty"` + // Allow the sandbox to synthesize keyboard or pointer input. + AllowInputInjection bool `protobuf:"varint,3,opt,name=allow_input_injection,json=allowInputInjection,proto3" json:"allow_input_injection,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UiPolicy) Reset() { + *x = UiPolicy{} + mi := &file_sandbox_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UiPolicy) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UiPolicy) ProtoMessage() {} + +func (x *UiPolicy) ProtoReflect() protoreflect.Message { + mi := &file_sandbox_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UiPolicy.ProtoReflect.Descriptor instead. +func (*UiPolicy) Descriptor() ([]byte, []int) { + return file_sandbox_proto_rawDescGZIP(), []int{4} +} + +func (x *UiPolicy) GetAllowGraphicalUi() bool { + if x != nil { + return x.AllowGraphicalUi + } + return false +} + +func (x *UiPolicy) GetClipboard() UiClipboardAccess { + if x != nil { + return x.Clipboard + } + return UiClipboardAccess_UI_CLIPBOARD_ACCESS_UNSPECIFIED +} + +func (x *UiPolicy) GetAllowInputInjection() bool { + if x != nil { + return x.AllowInputInjection + } + return false +} + // A named network access policy rule. type NetworkPolicyRule struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -398,7 +536,7 @@ type NetworkPolicyRule struct { func (x *NetworkPolicyRule) Reset() { *x = NetworkPolicyRule{} - mi := &file_sandbox_proto_msgTypes[4] + mi := &file_sandbox_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -410,7 +548,7 @@ func (x *NetworkPolicyRule) String() string { func (*NetworkPolicyRule) ProtoMessage() {} func (x *NetworkPolicyRule) ProtoReflect() protoreflect.Message { - mi := &file_sandbox_proto_msgTypes[4] + mi := &file_sandbox_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -423,7 +561,7 @@ func (x *NetworkPolicyRule) ProtoReflect() protoreflect.Message { // Deprecated: Use NetworkPolicyRule.ProtoReflect.Descriptor instead. func (*NetworkPolicyRule) Descriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{4} + return file_sandbox_proto_rawDescGZIP(), []int{5} } func (x *NetworkPolicyRule) GetName() string { @@ -468,7 +606,7 @@ type NetworkMiddlewareConfig struct { func (x *NetworkMiddlewareConfig) Reset() { *x = NetworkMiddlewareConfig{} - mi := &file_sandbox_proto_msgTypes[5] + mi := &file_sandbox_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -480,7 +618,7 @@ func (x *NetworkMiddlewareConfig) String() string { func (*NetworkMiddlewareConfig) ProtoMessage() {} func (x *NetworkMiddlewareConfig) ProtoReflect() protoreflect.Message { - mi := &file_sandbox_proto_msgTypes[5] + mi := &file_sandbox_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -493,7 +631,7 @@ func (x *NetworkMiddlewareConfig) ProtoReflect() protoreflect.Message { // Deprecated: Use NetworkMiddlewareConfig.ProtoReflect.Descriptor instead. func (*NetworkMiddlewareConfig) Descriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{5} + return file_sandbox_proto_rawDescGZIP(), []int{6} } func (x *NetworkMiddlewareConfig) GetName() string { @@ -553,7 +691,7 @@ type MiddlewareEndpointSelector struct { func (x *MiddlewareEndpointSelector) Reset() { *x = MiddlewareEndpointSelector{} - mi := &file_sandbox_proto_msgTypes[6] + mi := &file_sandbox_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -565,7 +703,7 @@ func (x *MiddlewareEndpointSelector) String() string { func (*MiddlewareEndpointSelector) ProtoMessage() {} func (x *MiddlewareEndpointSelector) ProtoReflect() protoreflect.Message { - mi := &file_sandbox_proto_msgTypes[6] + mi := &file_sandbox_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -578,7 +716,7 @@ func (x *MiddlewareEndpointSelector) ProtoReflect() protoreflect.Message { // Deprecated: Use MiddlewareEndpointSelector.ProtoReflect.Descriptor instead. func (*MiddlewareEndpointSelector) Descriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{6} + return file_sandbox_proto_rawDescGZIP(), []int{7} } func (x *MiddlewareEndpointSelector) GetInclude() []string { @@ -607,7 +745,7 @@ type NetworkCredentialBinding struct { func (x *NetworkCredentialBinding) Reset() { *x = NetworkCredentialBinding{} - mi := &file_sandbox_proto_msgTypes[7] + mi := &file_sandbox_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -619,7 +757,7 @@ func (x *NetworkCredentialBinding) String() string { func (*NetworkCredentialBinding) ProtoMessage() {} func (x *NetworkCredentialBinding) ProtoReflect() protoreflect.Message { - mi := &file_sandbox_proto_msgTypes[7] + mi := &file_sandbox_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -632,7 +770,7 @@ func (x *NetworkCredentialBinding) ProtoReflect() protoreflect.Message { // Deprecated: Use NetworkCredentialBinding.ProtoReflect.Descriptor instead. func (*NetworkCredentialBinding) Descriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{7} + return file_sandbox_proto_rawDescGZIP(), []int{8} } func (x *NetworkCredentialBinding) GetProvider() string { @@ -744,7 +882,7 @@ type NetworkEndpoint struct { func (x *NetworkEndpoint) Reset() { *x = NetworkEndpoint{} - mi := &file_sandbox_proto_msgTypes[8] + mi := &file_sandbox_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -756,7 +894,7 @@ func (x *NetworkEndpoint) String() string { func (*NetworkEndpoint) ProtoMessage() {} func (x *NetworkEndpoint) ProtoReflect() protoreflect.Message { - mi := &file_sandbox_proto_msgTypes[8] + mi := &file_sandbox_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -769,7 +907,7 @@ func (x *NetworkEndpoint) ProtoReflect() protoreflect.Message { // Deprecated: Use NetworkEndpoint.ProtoReflect.Descriptor instead. func (*NetworkEndpoint) Descriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{8} + return file_sandbox_proto_rawDescGZIP(), []int{9} } func (x *NetworkEndpoint) GetHost() string { @@ -995,7 +1133,7 @@ type McpOptions struct { func (x *McpOptions) Reset() { *x = McpOptions{} - mi := &file_sandbox_proto_msgTypes[9] + mi := &file_sandbox_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1007,7 +1145,7 @@ func (x *McpOptions) String() string { func (*McpOptions) ProtoMessage() {} func (x *McpOptions) ProtoReflect() protoreflect.Message { - mi := &file_sandbox_proto_msgTypes[9] + mi := &file_sandbox_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1020,7 +1158,7 @@ func (x *McpOptions) ProtoReflect() protoreflect.Message { // Deprecated: Use McpOptions.ProtoReflect.Descriptor instead. func (*McpOptions) Descriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{9} + return file_sandbox_proto_rawDescGZIP(), []int{10} } func (x *McpOptions) GetStrictToolNames() bool { @@ -1059,7 +1197,7 @@ type GraphqlOperation struct { func (x *GraphqlOperation) Reset() { *x = GraphqlOperation{} - mi := &file_sandbox_proto_msgTypes[10] + mi := &file_sandbox_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1071,7 +1209,7 @@ func (x *GraphqlOperation) String() string { func (*GraphqlOperation) ProtoMessage() {} func (x *GraphqlOperation) ProtoReflect() protoreflect.Message { - mi := &file_sandbox_proto_msgTypes[10] + mi := &file_sandbox_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1084,7 +1222,7 @@ func (x *GraphqlOperation) ProtoReflect() protoreflect.Message { // Deprecated: Use GraphqlOperation.ProtoReflect.Descriptor instead. func (*GraphqlOperation) Descriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{10} + return file_sandbox_proto_rawDescGZIP(), []int{11} } func (x *GraphqlOperation) GetOperationType() string { @@ -1139,7 +1277,7 @@ type L7DenyRule struct { func (x *L7DenyRule) Reset() { *x = L7DenyRule{} - mi := &file_sandbox_proto_msgTypes[11] + mi := &file_sandbox_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1151,7 +1289,7 @@ func (x *L7DenyRule) String() string { func (*L7DenyRule) ProtoMessage() {} func (x *L7DenyRule) ProtoReflect() protoreflect.Message { - mi := &file_sandbox_proto_msgTypes[11] + mi := &file_sandbox_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1164,7 +1302,7 @@ func (x *L7DenyRule) ProtoReflect() protoreflect.Message { // Deprecated: Use L7DenyRule.ProtoReflect.Descriptor instead. func (*L7DenyRule) Descriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{11} + return file_sandbox_proto_rawDescGZIP(), []int{12} } func (x *L7DenyRule) GetMethod() string { @@ -1233,7 +1371,7 @@ type L7Rule struct { func (x *L7Rule) Reset() { *x = L7Rule{} - mi := &file_sandbox_proto_msgTypes[12] + mi := &file_sandbox_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1245,7 +1383,7 @@ func (x *L7Rule) String() string { func (*L7Rule) ProtoMessage() {} func (x *L7Rule) ProtoReflect() protoreflect.Message { - mi := &file_sandbox_proto_msgTypes[12] + mi := &file_sandbox_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1258,7 +1396,7 @@ func (x *L7Rule) ProtoReflect() protoreflect.Message { // Deprecated: Use L7Rule.ProtoReflect.Descriptor instead. func (*L7Rule) Descriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{12} + return file_sandbox_proto_rawDescGZIP(), []int{13} } func (x *L7Rule) GetAllow() *L7Allow { @@ -1298,7 +1436,7 @@ type L7Allow struct { func (x *L7Allow) Reset() { *x = L7Allow{} - mi := &file_sandbox_proto_msgTypes[13] + mi := &file_sandbox_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1310,7 +1448,7 @@ func (x *L7Allow) String() string { func (*L7Allow) ProtoMessage() {} func (x *L7Allow) ProtoReflect() protoreflect.Message { - mi := &file_sandbox_proto_msgTypes[13] + mi := &file_sandbox_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1323,7 +1461,7 @@ func (x *L7Allow) ProtoReflect() protoreflect.Message { // Deprecated: Use L7Allow.ProtoReflect.Descriptor instead. func (*L7Allow) Descriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{13} + return file_sandbox_proto_rawDescGZIP(), []int{14} } func (x *L7Allow) GetMethod() string { @@ -1395,7 +1533,7 @@ type L7QueryMatcher struct { func (x *L7QueryMatcher) Reset() { *x = L7QueryMatcher{} - mi := &file_sandbox_proto_msgTypes[14] + mi := &file_sandbox_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1407,7 +1545,7 @@ func (x *L7QueryMatcher) String() string { func (*L7QueryMatcher) ProtoMessage() {} func (x *L7QueryMatcher) ProtoReflect() protoreflect.Message { - mi := &file_sandbox_proto_msgTypes[14] + mi := &file_sandbox_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1420,7 +1558,7 @@ func (x *L7QueryMatcher) ProtoReflect() protoreflect.Message { // Deprecated: Use L7QueryMatcher.ProtoReflect.Descriptor instead. func (*L7QueryMatcher) Descriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{14} + return file_sandbox_proto_rawDescGZIP(), []int{15} } func (x *L7QueryMatcher) GetGlob() string { @@ -1447,7 +1585,7 @@ type NetworkBinary struct { func (x *NetworkBinary) Reset() { *x = NetworkBinary{} - mi := &file_sandbox_proto_msgTypes[15] + mi := &file_sandbox_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1459,7 +1597,7 @@ func (x *NetworkBinary) String() string { func (*NetworkBinary) ProtoMessage() {} func (x *NetworkBinary) ProtoReflect() protoreflect.Message { - mi := &file_sandbox_proto_msgTypes[15] + mi := &file_sandbox_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1472,7 +1610,7 @@ func (x *NetworkBinary) ProtoReflect() protoreflect.Message { // Deprecated: Use NetworkBinary.ProtoReflect.Descriptor instead. func (*NetworkBinary) Descriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{15} + return file_sandbox_proto_rawDescGZIP(), []int{16} } func (x *NetworkBinary) GetPath() string { @@ -1493,7 +1631,7 @@ type GetSandboxConfigRequest struct { func (x *GetSandboxConfigRequest) Reset() { *x = GetSandboxConfigRequest{} - mi := &file_sandbox_proto_msgTypes[16] + mi := &file_sandbox_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1505,7 +1643,7 @@ func (x *GetSandboxConfigRequest) String() string { func (*GetSandboxConfigRequest) ProtoMessage() {} func (x *GetSandboxConfigRequest) ProtoReflect() protoreflect.Message { - mi := &file_sandbox_proto_msgTypes[16] + mi := &file_sandbox_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1518,7 +1656,7 @@ func (x *GetSandboxConfigRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetSandboxConfigRequest.ProtoReflect.Descriptor instead. func (*GetSandboxConfigRequest) Descriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{16} + return file_sandbox_proto_rawDescGZIP(), []int{17} } func (x *GetSandboxConfigRequest) GetSandboxId() string { @@ -1537,7 +1675,7 @@ type GetGatewayConfigRequest struct { func (x *GetGatewayConfigRequest) Reset() { *x = GetGatewayConfigRequest{} - mi := &file_sandbox_proto_msgTypes[17] + mi := &file_sandbox_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1549,7 +1687,7 @@ func (x *GetGatewayConfigRequest) String() string { func (*GetGatewayConfigRequest) ProtoMessage() {} func (x *GetGatewayConfigRequest) ProtoReflect() protoreflect.Message { - mi := &file_sandbox_proto_msgTypes[17] + mi := &file_sandbox_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1562,7 +1700,7 @@ func (x *GetGatewayConfigRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetGatewayConfigRequest.ProtoReflect.Descriptor instead. func (*GetGatewayConfigRequest) Descriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{17} + return file_sandbox_proto_rawDescGZIP(), []int{18} } // Response containing gateway-global settings. @@ -1579,7 +1717,7 @@ type GetGatewayConfigResponse struct { func (x *GetGatewayConfigResponse) Reset() { *x = GetGatewayConfigResponse{} - mi := &file_sandbox_proto_msgTypes[18] + mi := &file_sandbox_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1591,7 +1729,7 @@ func (x *GetGatewayConfigResponse) String() string { func (*GetGatewayConfigResponse) ProtoMessage() {} func (x *GetGatewayConfigResponse) ProtoReflect() protoreflect.Message { - mi := &file_sandbox_proto_msgTypes[18] + mi := &file_sandbox_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1604,7 +1742,7 @@ func (x *GetGatewayConfigResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetGatewayConfigResponse.ProtoReflect.Descriptor instead. func (*GetGatewayConfigResponse) Descriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{18} + return file_sandbox_proto_rawDescGZIP(), []int{19} } func (x *GetGatewayConfigResponse) GetSettings() map[string]*SettingValue { @@ -1637,7 +1775,7 @@ type SettingValue struct { func (x *SettingValue) Reset() { *x = SettingValue{} - mi := &file_sandbox_proto_msgTypes[19] + mi := &file_sandbox_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1649,7 +1787,7 @@ func (x *SettingValue) String() string { func (*SettingValue) ProtoMessage() {} func (x *SettingValue) ProtoReflect() protoreflect.Message { - mi := &file_sandbox_proto_msgTypes[19] + mi := &file_sandbox_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1662,7 +1800,7 @@ func (x *SettingValue) ProtoReflect() protoreflect.Message { // Deprecated: Use SettingValue.ProtoReflect.Descriptor instead. func (*SettingValue) Descriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{19} + return file_sandbox_proto_rawDescGZIP(), []int{20} } func (x *SettingValue) GetValue() isSettingValue_Value { @@ -1747,7 +1885,7 @@ type EffectiveSetting struct { func (x *EffectiveSetting) Reset() { *x = EffectiveSetting{} - mi := &file_sandbox_proto_msgTypes[20] + mi := &file_sandbox_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1759,7 +1897,7 @@ func (x *EffectiveSetting) String() string { func (*EffectiveSetting) ProtoMessage() {} func (x *EffectiveSetting) ProtoReflect() protoreflect.Message { - mi := &file_sandbox_proto_msgTypes[20] + mi := &file_sandbox_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1772,7 +1910,7 @@ func (x *EffectiveSetting) ProtoReflect() protoreflect.Message { // Deprecated: Use EffectiveSetting.ProtoReflect.Descriptor instead. func (*EffectiveSetting) Descriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{20} + return file_sandbox_proto_rawDescGZIP(), []int{21} } func (x *EffectiveSetting) GetValue() *SettingValue { @@ -1831,7 +1969,7 @@ type GetSandboxConfigResponse struct { func (x *GetSandboxConfigResponse) Reset() { *x = GetSandboxConfigResponse{} - mi := &file_sandbox_proto_msgTypes[21] + mi := &file_sandbox_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1843,7 +1981,7 @@ func (x *GetSandboxConfigResponse) String() string { func (*GetSandboxConfigResponse) ProtoMessage() {} func (x *GetSandboxConfigResponse) ProtoReflect() protoreflect.Message { - mi := &file_sandbox_proto_msgTypes[21] + mi := &file_sandbox_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1856,7 +1994,7 @@ func (x *GetSandboxConfigResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetSandboxConfigResponse.ProtoReflect.Descriptor instead. func (*GetSandboxConfigResponse) Descriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{21} + return file_sandbox_proto_rawDescGZIP(), []int{22} } func (x *GetSandboxConfigResponse) GetPolicy() *SandboxPolicy { @@ -1976,7 +2114,7 @@ type SupervisorMiddlewareService struct { func (x *SupervisorMiddlewareService) Reset() { *x = SupervisorMiddlewareService{} - mi := &file_sandbox_proto_msgTypes[22] + mi := &file_sandbox_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1988,7 +2126,7 @@ func (x *SupervisorMiddlewareService) String() string { func (*SupervisorMiddlewareService) ProtoMessage() {} func (x *SupervisorMiddlewareService) ProtoReflect() protoreflect.Message { - mi := &file_sandbox_proto_msgTypes[22] + mi := &file_sandbox_proto_msgTypes[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2001,7 +2139,7 @@ func (x *SupervisorMiddlewareService) ProtoReflect() protoreflect.Message { // Deprecated: Use SupervisorMiddlewareService.ProtoReflect.Descriptor instead. func (*SupervisorMiddlewareService) Descriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{22} + return file_sandbox_proto_rawDescGZIP(), []int{23} } func (x *SupervisorMiddlewareService) GetName() string { @@ -2057,7 +2195,7 @@ var File_sandbox_proto protoreflect.FileDescriptor const file_sandbox_proto_rawDesc = "" + "\n" + - "\rsandbox.proto\x12\x14openshell.sandbox.v1\x1a\x1cgoogle/protobuf/struct.proto\"\xa8\x05\n" + + "\rsandbox.proto\x12\x14openshell.sandbox.v1\x1a\x1cgoogle/protobuf/struct.proto\"\xd8\x05\n" + "\rSandboxPolicy\x12\x18\n" + "\aversion\x18\x01 \x01(\rR\aversion\x12F\n" + "\n" + @@ -2066,7 +2204,8 @@ const file_sandbox_proto_rawDesc = "" + "\blandlock\x18\x03 \x01(\v2$.openshell.sandbox.v1.LandlockPolicyR\blandlock\x12=\n" + "\aprocess\x18\x04 \x01(\v2#.openshell.sandbox.v1.ProcessPolicyR\aprocess\x12c\n" + "\x10network_policies\x18\x05 \x03(\v28.openshell.sandbox.v1.SandboxPolicy.NetworkPoliciesEntryR\x0fnetworkPolicies\x12l\n" + - "\x13network_middlewares\x18\x06 \x03(\v2;.openshell.sandbox.v1.SandboxPolicy.NetworkMiddlewaresEntryR\x12networkMiddlewares\x1ak\n" + + "\x13network_middlewares\x18\x06 \x03(\v2;.openshell.sandbox.v1.SandboxPolicy.NetworkMiddlewaresEntryR\x12networkMiddlewares\x12.\n" + + "\x02ui\x18\a \x01(\v2\x1e.openshell.sandbox.v1.UiPolicyR\x02ui\x1ak\n" + "\x14NetworkPoliciesEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12=\n" + "\x05value\x18\x02 \x01(\v2'.openshell.sandbox.v1.NetworkPolicyRuleR\x05value:\x028\x01\x1at\n" + @@ -2083,7 +2222,11 @@ const file_sandbox_proto_rawDesc = "" + "\rProcessPolicy\x12\x1e\n" + "\vrun_as_user\x18\x01 \x01(\tR\trunAsUser\x12 \n" + "\frun_as_group\x18\x02 \x01(\tR\n" + - "runAsGroup\"\xad\x01\n" + + "runAsGroup\"\xb3\x01\n" + + "\bUiPolicy\x12,\n" + + "\x12allow_graphical_ui\x18\x01 \x01(\bR\x10allowGraphicalUi\x12E\n" + + "\tclipboard\x18\x02 \x01(\x0e2'.openshell.sandbox.v1.UiClipboardAccessR\tclipboard\x122\n" + + "\x15allow_input_injection\x18\x03 \x01(\bR\x13allowInputInjection\"\xad\x01\n" + "\x11NetworkPolicyRule\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12C\n" + "\tendpoints\x18\x02 \x03(\v2%.openshell.sandbox.v1.NetworkEndpointR\tendpoints\x12?\n" + @@ -2233,7 +2376,13 @@ const file_sandbox_proto_rawDesc = "" + "\atimeout\x18\x04 \x01(\tR\atimeout\x12%\n" + "\x0ftls_ca_cert_pem\x18\x05 \x01(\fR\ftlsCaCertPem\x12\x1a\n" + "\baudience\x18\x06 \x01(\tR\baudience\x128\n" + - "\x18allow_insecure_transport\x18\a \x01(\bR\x16allowInsecureTransport*b\n" + + "\x18allow_insecure_transport\x18\a \x01(\bR\x16allowInsecureTransport*\xb0\x01\n" + + "\x11UiClipboardAccess\x12#\n" + + "\x1fUI_CLIPBOARD_ACCESS_UNSPECIFIED\x10\x00\x12\x1c\n" + + "\x18UI_CLIPBOARD_ACCESS_NONE\x10\x01\x12\x1c\n" + + "\x18UI_CLIPBOARD_ACCESS_READ\x10\x02\x12\x1d\n" + + "\x19UI_CLIPBOARD_ACCESS_WRITE\x10\x03\x12\x1b\n" + + "\x17UI_CLIPBOARD_ACCESS_ALL\x10\x04*b\n" + "\fSettingScope\x12\x1d\n" + "\x19SETTING_SCOPE_UNSPECIFIED\x10\x00\x12\x19\n" + "\x15SETTING_SCOPE_SANDBOX\x10\x01\x12\x18\n" + @@ -2255,86 +2404,90 @@ func file_sandbox_proto_rawDescGZIP() []byte { return file_sandbox_proto_rawDescData } -var file_sandbox_proto_enumTypes = make([]protoimpl.EnumInfo, 2) -var file_sandbox_proto_msgTypes = make([]protoimpl.MessageInfo, 32) +var file_sandbox_proto_enumTypes = make([]protoimpl.EnumInfo, 3) +var file_sandbox_proto_msgTypes = make([]protoimpl.MessageInfo, 33) var file_sandbox_proto_goTypes = []any{ - (SettingScope)(0), // 0: openshell.sandbox.v1.SettingScope - (PolicySource)(0), // 1: openshell.sandbox.v1.PolicySource - (*SandboxPolicy)(nil), // 2: openshell.sandbox.v1.SandboxPolicy - (*FilesystemPolicy)(nil), // 3: openshell.sandbox.v1.FilesystemPolicy - (*LandlockPolicy)(nil), // 4: openshell.sandbox.v1.LandlockPolicy - (*ProcessPolicy)(nil), // 5: openshell.sandbox.v1.ProcessPolicy - (*NetworkPolicyRule)(nil), // 6: openshell.sandbox.v1.NetworkPolicyRule - (*NetworkMiddlewareConfig)(nil), // 7: openshell.sandbox.v1.NetworkMiddlewareConfig - (*MiddlewareEndpointSelector)(nil), // 8: openshell.sandbox.v1.MiddlewareEndpointSelector - (*NetworkCredentialBinding)(nil), // 9: openshell.sandbox.v1.NetworkCredentialBinding - (*NetworkEndpoint)(nil), // 10: openshell.sandbox.v1.NetworkEndpoint - (*McpOptions)(nil), // 11: openshell.sandbox.v1.McpOptions - (*GraphqlOperation)(nil), // 12: openshell.sandbox.v1.GraphqlOperation - (*L7DenyRule)(nil), // 13: openshell.sandbox.v1.L7DenyRule - (*L7Rule)(nil), // 14: openshell.sandbox.v1.L7Rule - (*L7Allow)(nil), // 15: openshell.sandbox.v1.L7Allow - (*L7QueryMatcher)(nil), // 16: openshell.sandbox.v1.L7QueryMatcher - (*NetworkBinary)(nil), // 17: openshell.sandbox.v1.NetworkBinary - (*GetSandboxConfigRequest)(nil), // 18: openshell.sandbox.v1.GetSandboxConfigRequest - (*GetGatewayConfigRequest)(nil), // 19: openshell.sandbox.v1.GetGatewayConfigRequest - (*GetGatewayConfigResponse)(nil), // 20: openshell.sandbox.v1.GetGatewayConfigResponse - (*SettingValue)(nil), // 21: openshell.sandbox.v1.SettingValue - (*EffectiveSetting)(nil), // 22: openshell.sandbox.v1.EffectiveSetting - (*GetSandboxConfigResponse)(nil), // 23: openshell.sandbox.v1.GetSandboxConfigResponse - (*SupervisorMiddlewareService)(nil), // 24: openshell.sandbox.v1.SupervisorMiddlewareService - nil, // 25: openshell.sandbox.v1.SandboxPolicy.NetworkPoliciesEntry - nil, // 26: openshell.sandbox.v1.SandboxPolicy.NetworkMiddlewaresEntry - nil, // 27: openshell.sandbox.v1.NetworkEndpoint.GraphqlPersistedQueriesEntry - nil, // 28: openshell.sandbox.v1.L7DenyRule.QueryEntry - nil, // 29: openshell.sandbox.v1.L7DenyRule.ParamsEntry - nil, // 30: openshell.sandbox.v1.L7Allow.QueryEntry - nil, // 31: openshell.sandbox.v1.L7Allow.ParamsEntry - nil, // 32: openshell.sandbox.v1.GetGatewayConfigResponse.SettingsEntry - nil, // 33: openshell.sandbox.v1.GetSandboxConfigResponse.SettingsEntry - (*structpb.Struct)(nil), // 34: google.protobuf.Struct + (UiClipboardAccess)(0), // 0: openshell.sandbox.v1.UiClipboardAccess + (SettingScope)(0), // 1: openshell.sandbox.v1.SettingScope + (PolicySource)(0), // 2: openshell.sandbox.v1.PolicySource + (*SandboxPolicy)(nil), // 3: openshell.sandbox.v1.SandboxPolicy + (*FilesystemPolicy)(nil), // 4: openshell.sandbox.v1.FilesystemPolicy + (*LandlockPolicy)(nil), // 5: openshell.sandbox.v1.LandlockPolicy + (*ProcessPolicy)(nil), // 6: openshell.sandbox.v1.ProcessPolicy + (*UiPolicy)(nil), // 7: openshell.sandbox.v1.UiPolicy + (*NetworkPolicyRule)(nil), // 8: openshell.sandbox.v1.NetworkPolicyRule + (*NetworkMiddlewareConfig)(nil), // 9: openshell.sandbox.v1.NetworkMiddlewareConfig + (*MiddlewareEndpointSelector)(nil), // 10: openshell.sandbox.v1.MiddlewareEndpointSelector + (*NetworkCredentialBinding)(nil), // 11: openshell.sandbox.v1.NetworkCredentialBinding + (*NetworkEndpoint)(nil), // 12: openshell.sandbox.v1.NetworkEndpoint + (*McpOptions)(nil), // 13: openshell.sandbox.v1.McpOptions + (*GraphqlOperation)(nil), // 14: openshell.sandbox.v1.GraphqlOperation + (*L7DenyRule)(nil), // 15: openshell.sandbox.v1.L7DenyRule + (*L7Rule)(nil), // 16: openshell.sandbox.v1.L7Rule + (*L7Allow)(nil), // 17: openshell.sandbox.v1.L7Allow + (*L7QueryMatcher)(nil), // 18: openshell.sandbox.v1.L7QueryMatcher + (*NetworkBinary)(nil), // 19: openshell.sandbox.v1.NetworkBinary + (*GetSandboxConfigRequest)(nil), // 20: openshell.sandbox.v1.GetSandboxConfigRequest + (*GetGatewayConfigRequest)(nil), // 21: openshell.sandbox.v1.GetGatewayConfigRequest + (*GetGatewayConfigResponse)(nil), // 22: openshell.sandbox.v1.GetGatewayConfigResponse + (*SettingValue)(nil), // 23: openshell.sandbox.v1.SettingValue + (*EffectiveSetting)(nil), // 24: openshell.sandbox.v1.EffectiveSetting + (*GetSandboxConfigResponse)(nil), // 25: openshell.sandbox.v1.GetSandboxConfigResponse + (*SupervisorMiddlewareService)(nil), // 26: openshell.sandbox.v1.SupervisorMiddlewareService + nil, // 27: openshell.sandbox.v1.SandboxPolicy.NetworkPoliciesEntry + nil, // 28: openshell.sandbox.v1.SandboxPolicy.NetworkMiddlewaresEntry + nil, // 29: openshell.sandbox.v1.NetworkEndpoint.GraphqlPersistedQueriesEntry + nil, // 30: openshell.sandbox.v1.L7DenyRule.QueryEntry + nil, // 31: openshell.sandbox.v1.L7DenyRule.ParamsEntry + nil, // 32: openshell.sandbox.v1.L7Allow.QueryEntry + nil, // 33: openshell.sandbox.v1.L7Allow.ParamsEntry + nil, // 34: openshell.sandbox.v1.GetGatewayConfigResponse.SettingsEntry + nil, // 35: openshell.sandbox.v1.GetSandboxConfigResponse.SettingsEntry + (*structpb.Struct)(nil), // 36: google.protobuf.Struct } var file_sandbox_proto_depIdxs = []int32{ - 3, // 0: openshell.sandbox.v1.SandboxPolicy.filesystem:type_name -> openshell.sandbox.v1.FilesystemPolicy - 4, // 1: openshell.sandbox.v1.SandboxPolicy.landlock:type_name -> openshell.sandbox.v1.LandlockPolicy - 5, // 2: openshell.sandbox.v1.SandboxPolicy.process:type_name -> openshell.sandbox.v1.ProcessPolicy - 25, // 3: openshell.sandbox.v1.SandboxPolicy.network_policies:type_name -> openshell.sandbox.v1.SandboxPolicy.NetworkPoliciesEntry - 26, // 4: openshell.sandbox.v1.SandboxPolicy.network_middlewares:type_name -> openshell.sandbox.v1.SandboxPolicy.NetworkMiddlewaresEntry - 10, // 5: openshell.sandbox.v1.NetworkPolicyRule.endpoints:type_name -> openshell.sandbox.v1.NetworkEndpoint - 17, // 6: openshell.sandbox.v1.NetworkPolicyRule.binaries:type_name -> openshell.sandbox.v1.NetworkBinary - 34, // 7: openshell.sandbox.v1.NetworkMiddlewareConfig.config:type_name -> google.protobuf.Struct - 8, // 8: openshell.sandbox.v1.NetworkMiddlewareConfig.endpoints:type_name -> openshell.sandbox.v1.MiddlewareEndpointSelector - 14, // 9: openshell.sandbox.v1.NetworkEndpoint.rules:type_name -> openshell.sandbox.v1.L7Rule - 13, // 10: openshell.sandbox.v1.NetworkEndpoint.deny_rules:type_name -> openshell.sandbox.v1.L7DenyRule - 27, // 11: openshell.sandbox.v1.NetworkEndpoint.graphql_persisted_queries:type_name -> openshell.sandbox.v1.NetworkEndpoint.GraphqlPersistedQueriesEntry - 11, // 12: openshell.sandbox.v1.NetworkEndpoint.mcp:type_name -> openshell.sandbox.v1.McpOptions - 9, // 13: openshell.sandbox.v1.NetworkEndpoint.credential_binding:type_name -> openshell.sandbox.v1.NetworkCredentialBinding - 28, // 14: openshell.sandbox.v1.L7DenyRule.query:type_name -> openshell.sandbox.v1.L7DenyRule.QueryEntry - 29, // 15: openshell.sandbox.v1.L7DenyRule.params:type_name -> openshell.sandbox.v1.L7DenyRule.ParamsEntry - 15, // 16: openshell.sandbox.v1.L7Rule.allow:type_name -> openshell.sandbox.v1.L7Allow - 30, // 17: openshell.sandbox.v1.L7Allow.query:type_name -> openshell.sandbox.v1.L7Allow.QueryEntry - 31, // 18: openshell.sandbox.v1.L7Allow.params:type_name -> openshell.sandbox.v1.L7Allow.ParamsEntry - 32, // 19: openshell.sandbox.v1.GetGatewayConfigResponse.settings:type_name -> openshell.sandbox.v1.GetGatewayConfigResponse.SettingsEntry - 21, // 20: openshell.sandbox.v1.EffectiveSetting.value:type_name -> openshell.sandbox.v1.SettingValue - 0, // 21: openshell.sandbox.v1.EffectiveSetting.scope:type_name -> openshell.sandbox.v1.SettingScope - 2, // 22: openshell.sandbox.v1.GetSandboxConfigResponse.policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 33, // 23: openshell.sandbox.v1.GetSandboxConfigResponse.settings:type_name -> openshell.sandbox.v1.GetSandboxConfigResponse.SettingsEntry - 1, // 24: openshell.sandbox.v1.GetSandboxConfigResponse.policy_source:type_name -> openshell.sandbox.v1.PolicySource - 24, // 25: openshell.sandbox.v1.GetSandboxConfigResponse.supervisor_middleware_services:type_name -> openshell.sandbox.v1.SupervisorMiddlewareService - 6, // 26: openshell.sandbox.v1.SandboxPolicy.NetworkPoliciesEntry.value:type_name -> openshell.sandbox.v1.NetworkPolicyRule - 7, // 27: openshell.sandbox.v1.SandboxPolicy.NetworkMiddlewaresEntry.value:type_name -> openshell.sandbox.v1.NetworkMiddlewareConfig - 12, // 28: openshell.sandbox.v1.NetworkEndpoint.GraphqlPersistedQueriesEntry.value:type_name -> openshell.sandbox.v1.GraphqlOperation - 16, // 29: openshell.sandbox.v1.L7DenyRule.QueryEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher - 16, // 30: openshell.sandbox.v1.L7DenyRule.ParamsEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher - 16, // 31: openshell.sandbox.v1.L7Allow.QueryEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher - 16, // 32: openshell.sandbox.v1.L7Allow.ParamsEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher - 21, // 33: openshell.sandbox.v1.GetGatewayConfigResponse.SettingsEntry.value:type_name -> openshell.sandbox.v1.SettingValue - 22, // 34: openshell.sandbox.v1.GetSandboxConfigResponse.SettingsEntry.value:type_name -> openshell.sandbox.v1.EffectiveSetting - 35, // [35:35] is the sub-list for method output_type - 35, // [35:35] is the sub-list for method input_type - 35, // [35:35] is the sub-list for extension type_name - 35, // [35:35] is the sub-list for extension extendee - 0, // [0:35] is the sub-list for field type_name + 4, // 0: openshell.sandbox.v1.SandboxPolicy.filesystem:type_name -> openshell.sandbox.v1.FilesystemPolicy + 5, // 1: openshell.sandbox.v1.SandboxPolicy.landlock:type_name -> openshell.sandbox.v1.LandlockPolicy + 6, // 2: openshell.sandbox.v1.SandboxPolicy.process:type_name -> openshell.sandbox.v1.ProcessPolicy + 27, // 3: openshell.sandbox.v1.SandboxPolicy.network_policies:type_name -> openshell.sandbox.v1.SandboxPolicy.NetworkPoliciesEntry + 28, // 4: openshell.sandbox.v1.SandboxPolicy.network_middlewares:type_name -> openshell.sandbox.v1.SandboxPolicy.NetworkMiddlewaresEntry + 7, // 5: openshell.sandbox.v1.SandboxPolicy.ui:type_name -> openshell.sandbox.v1.UiPolicy + 0, // 6: openshell.sandbox.v1.UiPolicy.clipboard:type_name -> openshell.sandbox.v1.UiClipboardAccess + 12, // 7: openshell.sandbox.v1.NetworkPolicyRule.endpoints:type_name -> openshell.sandbox.v1.NetworkEndpoint + 19, // 8: openshell.sandbox.v1.NetworkPolicyRule.binaries:type_name -> openshell.sandbox.v1.NetworkBinary + 36, // 9: openshell.sandbox.v1.NetworkMiddlewareConfig.config:type_name -> google.protobuf.Struct + 10, // 10: openshell.sandbox.v1.NetworkMiddlewareConfig.endpoints:type_name -> openshell.sandbox.v1.MiddlewareEndpointSelector + 16, // 11: openshell.sandbox.v1.NetworkEndpoint.rules:type_name -> openshell.sandbox.v1.L7Rule + 15, // 12: openshell.sandbox.v1.NetworkEndpoint.deny_rules:type_name -> openshell.sandbox.v1.L7DenyRule + 29, // 13: openshell.sandbox.v1.NetworkEndpoint.graphql_persisted_queries:type_name -> openshell.sandbox.v1.NetworkEndpoint.GraphqlPersistedQueriesEntry + 13, // 14: openshell.sandbox.v1.NetworkEndpoint.mcp:type_name -> openshell.sandbox.v1.McpOptions + 11, // 15: openshell.sandbox.v1.NetworkEndpoint.credential_binding:type_name -> openshell.sandbox.v1.NetworkCredentialBinding + 30, // 16: openshell.sandbox.v1.L7DenyRule.query:type_name -> openshell.sandbox.v1.L7DenyRule.QueryEntry + 31, // 17: openshell.sandbox.v1.L7DenyRule.params:type_name -> openshell.sandbox.v1.L7DenyRule.ParamsEntry + 17, // 18: openshell.sandbox.v1.L7Rule.allow:type_name -> openshell.sandbox.v1.L7Allow + 32, // 19: openshell.sandbox.v1.L7Allow.query:type_name -> openshell.sandbox.v1.L7Allow.QueryEntry + 33, // 20: openshell.sandbox.v1.L7Allow.params:type_name -> openshell.sandbox.v1.L7Allow.ParamsEntry + 34, // 21: openshell.sandbox.v1.GetGatewayConfigResponse.settings:type_name -> openshell.sandbox.v1.GetGatewayConfigResponse.SettingsEntry + 23, // 22: openshell.sandbox.v1.EffectiveSetting.value:type_name -> openshell.sandbox.v1.SettingValue + 1, // 23: openshell.sandbox.v1.EffectiveSetting.scope:type_name -> openshell.sandbox.v1.SettingScope + 3, // 24: openshell.sandbox.v1.GetSandboxConfigResponse.policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 35, // 25: openshell.sandbox.v1.GetSandboxConfigResponse.settings:type_name -> openshell.sandbox.v1.GetSandboxConfigResponse.SettingsEntry + 2, // 26: openshell.sandbox.v1.GetSandboxConfigResponse.policy_source:type_name -> openshell.sandbox.v1.PolicySource + 26, // 27: openshell.sandbox.v1.GetSandboxConfigResponse.supervisor_middleware_services:type_name -> openshell.sandbox.v1.SupervisorMiddlewareService + 8, // 28: openshell.sandbox.v1.SandboxPolicy.NetworkPoliciesEntry.value:type_name -> openshell.sandbox.v1.NetworkPolicyRule + 9, // 29: openshell.sandbox.v1.SandboxPolicy.NetworkMiddlewaresEntry.value:type_name -> openshell.sandbox.v1.NetworkMiddlewareConfig + 14, // 30: openshell.sandbox.v1.NetworkEndpoint.GraphqlPersistedQueriesEntry.value:type_name -> openshell.sandbox.v1.GraphqlOperation + 18, // 31: openshell.sandbox.v1.L7DenyRule.QueryEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher + 18, // 32: openshell.sandbox.v1.L7DenyRule.ParamsEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher + 18, // 33: openshell.sandbox.v1.L7Allow.QueryEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher + 18, // 34: openshell.sandbox.v1.L7Allow.ParamsEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher + 23, // 35: openshell.sandbox.v1.GetGatewayConfigResponse.SettingsEntry.value:type_name -> openshell.sandbox.v1.SettingValue + 24, // 36: openshell.sandbox.v1.GetSandboxConfigResponse.SettingsEntry.value:type_name -> openshell.sandbox.v1.EffectiveSetting + 37, // [37:37] is the sub-list for method output_type + 37, // [37:37] is the sub-list for method input_type + 37, // [37:37] is the sub-list for extension type_name + 37, // [37:37] is the sub-list for extension extendee + 0, // [0:37] is the sub-list for field type_name } func init() { file_sandbox_proto_init() } @@ -2342,8 +2495,8 @@ func file_sandbox_proto_init() { if File_sandbox_proto != nil { return } - file_sandbox_proto_msgTypes[9].OneofWrappers = []any{} - file_sandbox_proto_msgTypes[19].OneofWrappers = []any{ + file_sandbox_proto_msgTypes[10].OneofWrappers = []any{} + file_sandbox_proto_msgTypes[20].OneofWrappers = []any{ (*SettingValue_StringValue)(nil), (*SettingValue_BoolValue)(nil), (*SettingValue_IntValue)(nil), @@ -2354,8 +2507,8 @@ func file_sandbox_proto_init() { File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_sandbox_proto_rawDesc), len(file_sandbox_proto_rawDesc)), - NumEnums: 2, - NumMessages: 32, + NumEnums: 3, + NumMessages: 33, NumExtensions: 0, NumServices: 0, }, diff --git a/skills/generate-sandbox-policy/SKILL.md b/skills/generate-sandbox-policy/SKILL.md index 73c0863df7..2bc77b446e 100644 --- a/skills/generate-sandbox-policy/SKILL.md +++ b/skills/generate-sandbox-policy/SKILL.md @@ -11,7 +11,7 @@ Generate YAML sandbox network policies and network middleware configuration from This skill translates a user's plain-language policy intent into a valid sandbox policy. The amount of detail the user provides determines the granularity of the generated policy — from broad L4 or preset-based policies (just a host:port) up to fine-grained per-endpoint L7 rules (full API docs). -The output is a `network_policies` YAML block, an optional `network_middlewares` block, and optionally a full policy file that conforms to the sandbox policy schema. +The output is a `network_policies` YAML block, an optional `network_middlewares` block, an optional static `ui` block when explicitly requested, and optionally a full policy file that conforms to the sandbox policy schema. ## Step 1: Gather Inputs @@ -168,6 +168,7 @@ Key sections to reference: - **Private IP Access via `allowed_ips`** — CIDR allowlist for private IP space - **Network Middleware** - top-level middleware configs, ordering, host selection, and failure behavior - **Validation Rules** — what combinations are valid/invalid +- **UI** — static, portable capabilities and runtime support boundaries When middleware is requested, also read the published [supervisor middleware guide](https://docs.nvidia.com/openshell/latest/extensibility/supervisor-middleware.md). @@ -257,6 +258,28 @@ Use the most specific pattern that covers the intent. Prefer narrow globs over ` ## Step 5: Generate the Policy +### UI Policy + +Emit `ui` only when the user explicitly requests a graphical surface, +clipboard access, or synthetic input. Choose the narrowest capability and keep +unrequested fields omitted so they remain deny by default: + +```yaml +ui: + allow_graphical_ui: true + clipboard: read # none | read | write | all + allow_input_injection: false +``` + +Treat clipboard direction from the sandbox's perspective. Warn that UI is a +static sandbox-creation control. It is currently enforceable only by the MXC +driver's OpenShell `process_container` backend, which emits MXC's +`processcontainer` containment value and advertises complete support. MXC +`isolation_session` and non-Windows drivers advertise no support, so the gateway +rejects any explicit UI section, including `{}`, before provisioning. Omit the +section rather than emitting deny-only UI for those drivers; omission preserves +their existing behavior. + ### Output Format Generate a complete `network_policies` entry. Use this template: @@ -381,6 +404,7 @@ Before presenting the policy to the user, verify correctness **and** flag breadt - [ ] No fail-closed middleware selector can cover a `tls: skip` endpoint - [ ] Any required WebSocket control advertises `WEBSOCKET_MESSAGE/PRE_CREDENTIALS`, and the user understands that V1 does not inspect binary messages - [ ] Endpoints contributed by a credentialed provider are not L4-only or `tls: skip` unless `allow_uninspected_credentials: true` explicitly records the exception +- [ ] An explicit `ui` section targets a configured driver/backend that advertises complete UI-policy support ### Schema Warnings (log-only, but should be fixed) @@ -443,7 +467,7 @@ The policy needs to go somewhere. Determine which mode applies: 1. **Read the existing file** to understand current state: - What policies already exist under `network_policies` - - What the `filesystem_policy`, `landlock`, and `process` sections look like + - What the `filesystem_policy`, `landlock`, `process`, and `ui` sections look like - Whether the file uses compact (`{ host: ..., port: ... }`) or expanded YAML style 2. **Check for conflicts**: @@ -462,7 +486,7 @@ The policy needs to go somewhere. Determine which mode applies: - **Modifying an existing policy**: Edit the specific policy in place — add/remove endpoints, change access presets, update rules, add binaries, etc. A rule authorizes every binary it lists to reach every endpoint and port it lists, so adding one binary grants it all of that rule's endpoints, and adding one endpoint grants it to all of that rule's binaries. State the resulting pairs to the user before writing them. When the user wants a binary to reach only part of a rule's endpoints, put that binary and those endpoints in a separate rule instead of extending the existing one. An empty `binaries` list means any binary, so leaving it off widens the rule to every process. - **Removing a policy**: Delete the policy block if the user asks. -4. **Preserve everything else**: Do not modify `filesystem_policy`, `landlock`, `process`, or other policies unless the user explicitly asks. +4. **Preserve everything else**: Do not modify `filesystem_policy`, `landlock`, `process`, `ui`, or other policies unless the user explicitly asks. ### Mode B: Create a New Policy File diff --git a/skills/generate-sandbox-policy/examples.md b/skills/generate-sandbox-policy/examples.md index 2cbc21b6b6..a547ea5398 100644 --- a/skills/generate-sandbox-policy/examples.md +++ b/skills/generate-sandbox-policy/examples.md @@ -748,7 +748,7 @@ An exact IP is treated as `/32` — only that specific address is permitted. - { path: /usr/bin/curl } ``` -The agent inserts the new entry after the last existing policy in the `network_policies` block. All other sections (`filesystem_policy`, `landlock`, `process`) are untouched. +The agent inserts the new entry after the last existing policy in the `network_policies` block. All other sections (`filesystem_policy`, `landlock`, `process`, `ui`) are untouched. --- diff --git a/skills/openshell-cli/SKILL.md b/skills/openshell-cli/SKILL.md index 0d30aacb50..b68cb3ca58 100644 --- a/skills/openshell-cli/SKILL.md +++ b/skills/openshell-cli/SKILL.md @@ -408,8 +408,17 @@ openshell logs my-sandbox --since 5m openshell sandbox delete my-sandbox openshell sandbox delete sandbox-1 sandbox-2 sandbox-3 # Multiple at once openshell sandbox delete --all + +# Fail closed if the observed sandbox was replaced or changed +openshell sandbox delete my-sandbox \ + --expected-id \ + --expected-resource-version ``` +Identity preconditions are valid only for one named sandbox, and a resource +version requires the immutable ID. A mismatch returns `ABORTED` before +OpenShell mutates gateway state or calls the compute driver. + ### Stop and start sandboxes Use stop to halt compute while retaining the sandbox and its persistent @@ -434,7 +443,7 @@ the operation that removes retained state. This is the most important multi-step workflow. It enables a tight feedback cycle where sandbox policy is refined based on observed activity. -**Key concept**: Policies have static fields (immutable after creation: `filesystem_policy`, `landlock`, `process`) and two dynamic fields: `network_policies` and `network_middlewares`. Both dynamic fields can be updated without recreating the sandbox when the selected compute driver supports live policy updates. Drivers without the standard supervisor fetch revisions through the sandbox configuration API and report whether they loaded them. +**Key concept**: Policies have static fields (immutable after creation: `filesystem_policy`, `landlock`, `process`, `ui`) and two dynamic fields: `network_policies` and `network_middlewares`. Both dynamic fields can be updated without recreating the sandbox when the selected compute driver supports live policy updates. MXC rejects live policy replacement and merge updates; delete and recreate an MXC sandbox instead. UI capabilities are enforced only by a configured driver/backend advertising complete UI-policy support. Today that is the MXC driver's OpenShell `process_container` backend, which emits MXC's `processcontainer` containment value. `isolation_session` and non-Windows drivers reject any explicit UI section before provisioning; omit it to preserve their existing behavior. An endpoint with omitted `protocol` retains explicit-proxy behavior. Explicit `protocol: tcp` requests policy DNS and transparent TCP and currently requires @@ -504,7 +513,7 @@ Edit `current-policy.yaml` to allow the blocked actions. **For policy content au - Binary matching patterns - Ordered `network_middlewares`, host selection, HTTP and WebSocket bindings, and `fail_open` or `fail_closed` behavior -`network_policies` and `network_middlewares` can be modified at runtime when the selected compute driver supports live policy updates. Use `--wait` to verify that the active runtime loaded the revision; do not infer enforcement from the gateway accepting the update. If `filesystem_policy`, `landlock`, or `process` need changes, the sandbox must be recreated. Built-in middleware such as `openshell/regex` needs no gateway registration. An operator-run middleware must already be registered under `[[openshell.supervisor.middleware]]`; changing that static registration requires a gateway restart. +`network_policies` and `network_middlewares` can be modified at runtime when the selected compute driver supports live policy updates. Use `--wait` to verify that the active runtime loaded the revision; do not infer enforcement from the gateway accepting the update. MXC rejects live policy replacement and merge updates; delete and recreate an MXC sandbox instead. If `filesystem_policy`, `landlock`, `process`, or `ui` need changes, the sandbox must be recreated. Built-in middleware such as `openshell/regex` needs no gateway registration. An operator-run middleware must already be registered under `[[openshell.supervisor.middleware]]`; changing that static registration requires a gateway restart. Middleware can inspect parsed HTTP request bodies and complete client-to-upstream WebSocket text messages over both `ws://` and `wss://` when the implementation advertises the matching binding. The built-in `openshell/regex` advertises both bindings and applies its fixed patterns to UTF-8 text. A host-matched HTTP-only attachment can inspect the upgrade GET but does not join the WebSocket chain; look for `binding_not_selected` coverage. Binary messages pass under both `on_error` modes and active stages emit `unsupported_message_type` coverage; upstream-to-client messages remain uninspected. A broken fail-open WebSocket stage is disabled for the rest of that connection; inspect sandbox OCSF logs for `openshell.middleware.websocket_stage_disabled`. diff --git a/tasks/scripts/windows-msvc.ps1 b/tasks/scripts/windows-msvc.ps1 index 6d8a188efe..e76507032d 100644 --- a/tasks/scripts/windows-msvc.ps1 +++ b/tasks/scripts/windows-msvc.ps1 @@ -6,7 +6,7 @@ [CmdletBinding()] param( [Parameter(Mandatory = $true, Position = 0)] - [ValidateSet("check", "lint", "build", "test", "test-precommit", "test-unsupported", "artifacts", "ci")] + [ValidateSet("check", "lint", "build", "test", "test-precommit", "test-unsupported", "test-mxc-real", "artifacts", "ci")] [string] $Action, [Parameter(Position = 1)] @@ -53,12 +53,13 @@ $WindowsCargoMutex = [System.Threading.Mutex]::new($false, "Local\OpenShellWindo $UnsupportedDriverPackageExcludes = "--exclude openshell-driver-docker --exclude openshell-driver-kubernetes --exclude openshell-driver-kubernetes-secrets --exclude openshell-driver-podman --exclude openshell-driver-vault --exclude openshell-driver-vm --exclude openshell-sandbox --exclude openshell-supervisor-process --exclude openshell-vfio" $WindowsClippyPackageExcludes = $UnsupportedDriverPackageExcludes $WindowsClippyLintArgs = "-D warnings -A dead-code -A unused-imports -A clippy::unused-async" -$BundledZ3WorkspaceFeatures = "--features openshell-prover/bundled-z3" -$BundledZ3ServerFeatures = "--features openshell-server/bundled-z3,openshell-prover/bundled-z3" -$BundledZ3GatewayFeatures = "--features openshell-gateway/bundled-z3" -$Z3WorkspaceFeatures = $BundledZ3WorkspaceFeatures -$Z3ServerFeatures = $BundledZ3ServerFeatures -$Z3GatewayFeatures = $BundledZ3GatewayFeatures +$PrebuiltZ3WorkspaceFeatures = "--features openshell-prover/prebuilt-z3" +$PrebuiltZ3ServerFeatures = "--features openshell-server/prebuilt-z3,openshell-prover/prebuilt-z3" +$PrebuiltZ3GatewayFeatures = "--features openshell-gateway/prebuilt-z3" +$PrebuiltZ3Version = "4.16.0" +$Z3WorkspaceFeatures = $PrebuiltZ3WorkspaceFeatures +$Z3ServerFeatures = $PrebuiltZ3ServerFeatures +$Z3GatewayFeatures = $PrebuiltZ3GatewayFeatures function Get-VsInstallRoots { $programFiles = @( @@ -354,11 +355,13 @@ function Resolve-Z3HeaderPath([string] $HeaderPath) { function Configure-Z3 { if ([string]::IsNullOrWhiteSpace($env:Z3_LIBRARY_PATH_OVERRIDE)) { - Write-Host "==> Z3: bundled" + Write-Host "==> Z3: prebuilt release" + $env:Z3_SYS_Z3_VERSION = $PrebuiltZ3Version + Write-Host " Z3_SYS_Z3_VERSION=$env:Z3_SYS_Z3_VERSION" return [pscustomobject]@{ - WorkspaceFeatures = $BundledZ3WorkspaceFeatures - ServerFeatures = $BundledZ3ServerFeatures - GatewayFeatures = $BundledZ3GatewayFeatures + WorkspaceFeatures = $PrebuiltZ3WorkspaceFeatures + ServerFeatures = $PrebuiltZ3ServerFeatures + GatewayFeatures = $PrebuiltZ3GatewayFeatures } } @@ -513,7 +516,7 @@ function Invoke-Lint([string] $RustTarget) { function Invoke-Build([string] $RustTarget) { Invoke-VsCargo ` -RustTarget $RustTarget ` - -CargoArgs "cargo build --release --target $RustTarget --bin openshell-gateway --bin openshell $Z3GatewayFeatures" ` + -CargoArgs "cargo build --release --target $RustTarget --bin openshell-gateway --bin openshell --bin openshell-supervisor-relay $Z3GatewayFeatures" ` -LogName "build-$RustTarget-release.log" } @@ -543,7 +546,7 @@ function Invoke-UnsupportedContractTests([string] $RustTarget) { foreach ($test in $tests) { Invoke-VsCargo ` -RustTarget $RustTarget ` - -CargoArgs "cargo test -p openshell-gateway --target $RustTarget $test $Z3ServerFeatures" ` + -CargoArgs "cargo test -p openshell-gateway --target $RustTarget $test $Z3GatewayFeatures" ` -LogName "test-$RustTarget-unsupported-$test.log" } @@ -557,6 +560,14 @@ function Invoke-UnsupportedContractTests([string] $RustTarget) { } } +function Invoke-MxcRealTests([string] $RustTarget) { + Assert-NativeTestTarget $RustTarget + Invoke-VsCargo ` + -RustTarget $RustTarget ` + -CargoArgs "cargo test -p openshell-driver-mxc --test wxc_exec_real --target $RustTarget -- --ignored --test-threads=1 --nocapture" ` + -LogName "test-$RustTarget-mxc-real.log" +} + function Get-Sha256([string] $Path) { $stream = [System.IO.File]::OpenRead($Path) try { @@ -574,7 +585,7 @@ function Get-Sha256([string] $Path) { function Show-Artifacts([string[]] $RustTargets) { $rows = @() foreach ($rustTarget in $RustTargets) { - foreach ($binary in @("openshell-gateway.exe", "openshell.exe")) { + foreach ($binary in @("openshell-gateway.exe", "openshell.exe", "openshell-supervisor-relay.exe")) { $path = Join-Path $TargetDir "$rustTarget\release\$binary" if (-not (Test-Path $path)) { continue @@ -601,13 +612,13 @@ if ($Action -eq "ci" -and (Get-HostArch) -ne "amd64") { } $targets = Get-SelectedTargets $Target -if ($Action -in @("test", "test-precommit", "test-unsupported")) { +if ($Action -in @("test", "test-precommit", "test-unsupported", "test-mxc-real")) { foreach ($rustTarget in $targets) { Assert-NativeTestTarget $rustTarget } } -if ($Action -in @("check", "lint", "build", "test", "test-precommit", "test-unsupported", "ci")) { +if ($Action -in @("check", "lint", "build", "test", "test-precommit", "test-unsupported", "test-mxc-real", "ci")) { $z3Features = Configure-Z3 $Z3WorkspaceFeatures = $z3Features.WorkspaceFeatures $Z3ServerFeatures = $z3Features.ServerFeatures @@ -650,6 +661,11 @@ switch ($Action) { Invoke-UnsupportedContractTests $rustTarget } } + "test-mxc-real" { + foreach ($rustTarget in $targets) { + Invoke-MxcRealTests $rustTarget + } + } "artifacts" { Show-Artifacts $targets } diff --git a/tasks/windows.toml b/tasks/windows.toml index 708ba2c248..7509a934c6 100644 --- a/tasks/windows.toml +++ b/tasks/windows.toml @@ -76,9 +76,14 @@ run = "echo 'windows:* tasks require a Windows MSVC host' && exit 1" run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 ci all" ["windows:test:mxc-real:x64"] -description = "Run real-wxc-exec Tier-2 integration tests (skip-safe: tests print SKIP when binary/backend absent)" +description = "Run native x64 real-wxc-exec Tier-2 integration tests (skip-safe: tests print SKIP when binary/backend absent)" run = "echo 'windows:* tasks require a Windows MSVC host' && exit 1" -run_windows = "cargo test -p openshell-driver-mxc --test wxc_exec_real --target x86_64-pc-windows-msvc -- --ignored --test-threads=1" +run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 test-mxc-real x86_64-pc-windows-msvc" + +["windows:test:mxc-real:arm64"] +description = "Run native ARM64 real-wxc-exec Tier-2 integration tests (skip-safe: tests print SKIP when binary/backend absent)" +run = "echo 'windows:* tasks require a Windows MSVC host' && exit 1" +run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 test-mxc-real aarch64-pc-windows-msvc" ["windows:e2e:mxc"] description = "Run MXC Tier-3 e2e scenario runner against real wxc-exec (probe-gated; skip-safe on hosts without the binary)" From fb2980e077288b61ef03a2e6187e162d158526aa Mon Sep 17 00:00:00 2001 From: Prekshi Vyas <34834085+prekshivyas@users.noreply.github.com> Date: Fri, 18 Sep 2026 10:31:55 -0700 Subject: [PATCH 02/42] fix(windows): restore MXC qualification and cold-start readiness (#3468) * fix(windows): restore MXC qualification gates Signed-off-by: Prekshi Vyas * fix(mxc): poll target for full readiness budget --------- Signed-off-by: Prekshi Vyas --- .../examples/mxc-gateway.toml | 3 ++ crates/openshell-driver-mxc/src/driver.rs | 34 +++++++++++++++++++ .../tests/wxc_exec_real.rs | 21 +++++++++--- .../openshell-supervisor-network/src/host.rs | 1 + .../openshell-supervisor-network/src/proxy.rs | 7 ++-- crates/openshell-supervisor-relay/src/imp.rs | 24 +++++++++---- tasks/scripts/windows-msvc.ps1 | 27 +++++++++++++-- 7 files changed, 102 insertions(+), 15 deletions(-) diff --git a/crates/openshell-driver-mxc/examples/mxc-gateway.toml b/crates/openshell-driver-mxc/examples/mxc-gateway.toml index 4fbd2c41f5..601b66bec6 100644 --- a/crates/openshell-driver-mxc/examples/mxc-gateway.toml +++ b/crates/openshell-driver-mxc/examples/mxc-gateway.toml @@ -7,6 +7,9 @@ # Supply command and cwd through --driver-config-json when creating a sandbox, # and environment variables through the standard sandbox --env option. +[openshell] +version = 2 + [openshell.drivers.mxc] # Path to wxc-exec.exe. Required for live runs. Leave the default for mock-mode # smoke tests (set OPENSHELL_MXC_MOCK_WXC=1 instead). diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index ce728cbeec..072b1debf1 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -660,6 +660,31 @@ fn append_tls_readonly_grant( } } fn encode_windows_command_line(args: &[String]) -> String { + if args + .first() + .and_then(|executable| executable.rsplit(['\\', '/']).next()) + .is_some_and(|executable| { + executable.eq_ignore_ascii_case("cmd") || executable.eq_ignore_ascii_case("cmd.exe") + }) + && let Some(command_index) = args + .iter() + .position(|arg| arg.eq_ignore_ascii_case("/c") || arg.eq_ignore_ascii_case("/k")) + { + let mut encoded = args[..=command_index] + .iter() + .map(|arg| quote_windows_argument(arg)) + .collect::>() + .join(" "); + if command_index + 1 < args.len() { + encoded.push(' '); + // cmd.exe parses the command tail with its own grammar. Escaping + // embedded quotes as C argv would leave literal backslashes in + // paths and redirections (for example `\"C:\\work file\"`). + encoded.push_str(&args[command_index + 1..].join(" ")); + } + return encoded; + } + args.iter() .map(|arg| quote_windows_argument(arg)) .collect::>() @@ -2919,6 +2944,15 @@ mod lifecycle_tests { quote_windows_argument("trailing slash\\ "), r#""trailing slash\ ""# ); + assert_eq!( + encode_windows_command_line(&[ + r"C:\Windows\System32\CMD.EXE".into(), + "/d".into(), + "/c".into(), + r#"echo hello > "C:\work dir\output.txt""#.into(), + ]), + r#"C:\Windows\System32\CMD.EXE /d /c echo hello > "C:\work dir\output.txt""# + ); } #[tokio::test] async fn positive_in_policy_write_reaches_ready_and_materializes_file() { diff --git a/crates/openshell-driver-mxc/tests/wxc_exec_real.rs b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs index c7bb11d86b..36b6edb804 100644 --- a/crates/openshell-driver-mxc/tests/wxc_exec_real.rs +++ b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs @@ -832,15 +832,24 @@ async fn pc_https_egress_reads_injected_ca_bundle() { let output_dir = tempfile::tempdir().expect("HTTPS output directory"); let output_path = output_dir.path().join("example.html"); let certificate_path = output_dir.path().join("peer-certificate.txt"); + let diagnostic_path = output_dir.path().join("https-diagnostic.txt"); let output_dir_string = output_dir.path().to_string_lossy().into_owned(); let output_path_string = output_path.to_string_lossy().into_owned(); let certificate_path_string = certificate_path.to_string_lossy().into_owned(); + let diagnostic_path_string = diagnostic_path.to_string_lossy().into_owned(); let cmd_string = cmd.to_string_lossy().into_owned(); + // Schannel's revocation lookup targets are intentionally outside this + // test's example.com-only policy. Disable that network lookup while still + // requiring curl to validate the proxy-issued certificate against the + // injected CA bundle. let script = format!( - "type \"%CURL_CA_BUNDLE%\" 1>NUL && \ - \"{}\" --fail --silent --show-error --cacert \"%CURL_CA_BUNDLE%\" \ + "echo CURL_CA_BUNDLE=%CURL_CA_BUNDLE% 1>\"{diagnostic_path_string}\" && \ + type \"%CURL_CA_BUNDLE%\" 1>NUL 2>>\"{diagnostic_path_string}\" && \ + \"{}\" --fail --silent --show-error --ssl-no-revoke \ + --cacert \"%CURL_CA_BUNDLE%\" \ https://example.com/ --output \"{output_path_string}\" \ - --write-out \"%{{certs}}\" 1>\"{certificate_path_string}\"", + --write-out \"%{{certs}}\" 1>\"{certificate_path_string}\" \ + 2>>\"{diagnostic_path_string}\"", curl.display() ); let command = vec![ @@ -932,10 +941,12 @@ async fn pc_https_egress_reads_injected_ca_bundle() { } let condition = terminal_condition.expect("HTTPS sandbox should reach a terminal condition"); + let diagnostic = std::fs::read_to_string(diagnostic_path) + .unwrap_or_else(|error| format!("failed to read HTTPS diagnostic: {error}")); assert_eq!( condition.reason, "AgentCompleted", - "HTTPS workload failed: {}", - condition.message + "HTTPS workload failed: {}; diagnostic: {diagnostic}", + condition.message, ); assert!(output_path.exists(), "curl should write the HTTPS response"); assert!( diff --git a/crates/openshell-supervisor-network/src/host.rs b/crates/openshell-supervisor-network/src/host.rs index 9d6f5cfffa..eb09634772 100644 --- a/crates/openshell-supervisor-network/src/host.rs +++ b/crates/openshell-supervisor-network/src/host.rs @@ -231,6 +231,7 @@ pub async fn start_host_proxy(config: HostProxyConfig) -> Result` -- `` //! is the TCP port the launched command is expected to bind (an early //! liveness check, `wait_for_port_ready`: if the target never binds it -//! within 60s, this process exits with an error instead of sitting around -//! with a target that will never work). This binary uses no `share_dir` files +//! within the bounded five-minute cold-start budget, this process exits with +//! an error instead of sitting around with a target that will never work). +//! This binary uses no `share_dir` files //! at all -- command/env and shutdown both travel over the control channel. //! //! Shutdown: driver sends a `"shutdown"` request over the control channel @@ -164,7 +165,7 @@ pub async fn run() -> anyhow::Result<()> { // budget after a caller was told shutdown succeeded. let mut shutdown_rx = shutdown_rx; tokio::select! { - result = wait_for_port_ready(&mut child, port, Duration::from_mins(1)) => { + result = wait_for_port_ready(&mut child, port, PORT_READY_PER_TRY_TIMEOUT) => { result?; } _ = &mut shutdown_rx => { @@ -715,8 +716,11 @@ where /// Number of full-budget tries `wait_for_port_ready` makes -- each try gets /// its own complete `per_try_timeout` window, not a slice of it. Worst case -/// total wait is `max_tries * per_try_timeout` (3 * 60s = 180s today). -const PORT_READY_MAX_TRIES: u32 = 3; +/// total wait is `max_tries * per_try_timeout` (5 * 60s = 300s today). Keep +/// this aligned with the MXC driver's 310-second target-ready timeout so a +/// cold process remains actively probed for the full advertised budget. +const PORT_READY_MAX_TRIES: u32 = 5; +const PORT_READY_PER_TRY_TIMEOUT: Duration = Duration::from_mins(1); /// Poll for the target port accepting TCP connections, bailing out early /// (rather than waiting out all tries) if the child process exits first — a @@ -1013,12 +1017,20 @@ async fn run_lifecycle(mut child: tokio::process::Child, shutdown_rx: oneshot::R #[cfg(test)] mod forward_connect_tests { - use super::connect_forward_target; + use super::{PORT_READY_MAX_TRIES, PORT_READY_PER_TRY_TIMEOUT, connect_forward_target}; use std::io::{Error, ErrorKind}; use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; use std::time::Duration; + #[test] + fn port_ready_budget_covers_five_minute_cold_start_contract() { + assert_eq!( + PORT_READY_PER_TRY_TIMEOUT * PORT_READY_MAX_TRIES, + Duration::from_mins(5), + ); + } + #[tokio::test(start_paused = true)] async fn forward_connect_returns_success_without_retry_delay() { let start = tokio::time::Instant::now(); diff --git a/tasks/scripts/windows-msvc.ps1 b/tasks/scripts/windows-msvc.ps1 index e76507032d..92569c57fd 100644 --- a/tasks/scripts/windows-msvc.ps1 +++ b/tasks/scripts/windows-msvc.ps1 @@ -518,6 +518,29 @@ function Invoke-Build([string] $RustTarget) { -RustTarget $RustTarget ` -CargoArgs "cargo build --release --target $RustTarget --bin openshell-gateway --bin openshell --bin openshell-supervisor-relay $Z3GatewayFeatures" ` -LogName "build-$RustTarget-release.log" + + $z3Runtime = if ([string]::IsNullOrWhiteSpace($env:Z3_LIBRARY_PATH_OVERRIDE)) { + $buildRoot = Join-Path $TargetDir "$RustTarget\release\build" + $candidates = @( + Get-ChildItem -Path $buildRoot -Filter "libz3.dll" -File -Recurse -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -like "*\z3-$PrebuiltZ3Version\bin\libz3.dll" } | + Sort-Object LastWriteTimeUtc -Descending + ) + if ($candidates.Count -eq 0) { + throw "The prebuilt Z3 runtime was not found under: $buildRoot" + } + $candidates[0].FullName + } else { + $path = Join-Path $env:Z3_LIBRARY_PATH_OVERRIDE "libz3.dll" + if (-not (Test-Path $path -PathType Leaf)) { + throw "Z3_LIBRARY_PATH_OVERRIDE is set but libz3.dll was not found at: $path" + } + $path + } + + $z3RuntimeDestination = Join-Path $TargetDir "$RustTarget\release\libz3.dll" + Copy-Item -LiteralPath $z3Runtime -Destination $z3RuntimeDestination -Force + Write-Host "==> Staged Z3 runtime: $z3RuntimeDestination" } function Invoke-Test([string] $RustTarget) { @@ -555,7 +578,7 @@ function Invoke-UnsupportedContractTests([string] $RustTarget) { $variant = if ($features) { $features.Replace(",", "-") } else { "protocol-only" } Invoke-VsCargo ` -RustTarget $RustTarget ` - -CargoArgs "cargo test -p openshell-gateway --lib --target $RustTarget --no-default-features $featureArgs $Z3ServerFeatures" ` + -CargoArgs "cargo test -p openshell-gateway --lib --target $RustTarget --no-default-features $featureArgs $Z3GatewayFeatures" ` -LogName "test-$RustTarget-selective-$variant.log" } } @@ -585,7 +608,7 @@ function Get-Sha256([string] $Path) { function Show-Artifacts([string[]] $RustTargets) { $rows = @() foreach ($rustTarget in $RustTargets) { - foreach ($binary in @("openshell-gateway.exe", "openshell.exe", "openshell-supervisor-relay.exe")) { + foreach ($binary in @("openshell-gateway.exe", "openshell.exe", "openshell-supervisor-relay.exe", "libz3.dll")) { $path = Join-Path $TargetDir "$rustTarget\release\$binary" if (-not (Test-Path $path)) { continue From 4f06e23cc8e4bfb48f78f43645771683f674fe2d Mon Sep 17 00:00:00 2001 From: Prekshi Vyas <34834085+prekshivyas@users.noreply.github.com> Date: Sat, 19 Sep 2026 18:12:12 -0700 Subject: [PATCH 03/42] fix(mxc): harden governed proxy lifecycle (NVBug 6783325) (#3472) * fix(mxc): gate host proxy on explicit network policy Signed-off-by: Prekshi Vyas * fix(mxc): reject unrestricted egress fallback Signed-off-by: Prekshi Vyas * chore(mise): refresh lockfile Signed-off-by: Shailendra Singh * fix(mxc): unblock Windows validation Signed-off-by: Shailendra Singh * test(mxc): satisfy Windows clippy Signed-off-by: Shailendra Singh --------- Signed-off-by: Prekshi Vyas Signed-off-by: Shailendra Singh Co-authored-by: Shailendra Singh --- crates/openshell-driver-mxc/README.md | 23 +- .../examples/e2e-policies/ws-agent.yaml | 17 +- .../examples/mxc-ws-agent.rs | 3 +- .../examples/mxc-ws-gateway.toml | 9 +- .../examples/ocsf-audit.yaml | 17 +- .../examples/run-ocsf-audit.ps1 | 3 + .../examples/run-ws-agent-test.ps1 | 15 +- crates/openshell-driver-mxc/src/driver.rs | 362 ++++++++++++------ crates/openshell-driver-mxc/src/mxc.rs | 4 - crates/openshell-driver-mxc/src/relay.rs | 21 +- .../tests/wxc_exec_real.rs | 5 +- mise.lock | 20 +- 12 files changed, 324 insertions(+), 175 deletions(-) diff --git a/crates/openshell-driver-mxc/README.md b/crates/openshell-driver-mxc/README.md index b8c52f9898..7f595bf19c 100644 --- a/crates/openshell-driver-mxc/README.md +++ b/crates/openshell-driver-mxc/README.md @@ -23,7 +23,7 @@ it does not implement the Linux `ConnectSupervisor` protocol. |---|---| | Filesystem policy | Read-only/read-write grants come only from `SandboxPolicy`. `process_container` enforces default-deny; `isolation_session` is an explicit grant-only compatibility mode. | | UI policy | `process_container` advertises complete support and maps portable graphical UI, clipboard-direction, and input-injection controls to MXC; omitted fields inside an explicit section deny. `isolation_session` advertises no support, so the gateway rejects any explicit section before provisioning. | -| Network policy | With `egress_proxy = true` on `process_container`, split into MXC 0.8 loopback-only egress plus the full policy enforced by a per-sandbox OpenShell host CONNECT proxy. The driver injects proxy environment variables for proxy-aware clients; direct Internet access remains denied by MXC. Otherwise rejected synchronously. `isolation_session` remains fail-closed. | +| Network policy | With `egress_proxy = true` on `process_container`, an explicit `network_policies` rule activates MXC 0.8 loopback-only egress plus the full policy enforced by a per-sandbox OpenShell host CONNECT proxy. The driver injects proxy environment variables for proxy-aware clients; direct Internet access remains denied by MXC. A policy without network rules does not activate the proxy. Otherwise rejected synchronously. `isolation_session` remains fail-closed. | | Provider credentials | The child receives revision-scoped placeholders and non-secret provider environment only. The per-sandbox host proxy retains the resolver and substitutes credentials only for their bound endpoints. | | Process policy | Unsupported; MXC supplies OS isolation only. | | Dynamic forwarding | Supported through `openshell-supervisor-relay`; interactive exec/connect remain unsupported. | @@ -60,6 +60,10 @@ pc_relay_target_port = 0 # (SYSTEMROOT/WINDIR/PATH/COMSPEC/LOCALAPPDATA); pc_minimal_env starts from an # EMPTY env for runtimes that need a fully curated per-sandbox environment. pc_minimal_env = false +# processContainer only: compatibility fallback for unrestricted outbound TCP. +# A sandbox with egress_proxy enabled but no explicit network rules rejects +# this fallback instead of silently changing governed egress to allow-all. +pc_network_allow = false # processContainer only: include "allowLocalNetwork": true in the MXC # network section. This compatibility setting broadens network access and is # not required by the BaseContainer qualification profile. @@ -72,8 +76,14 @@ etw_audit = false ``` When `egress_proxy` is enabled, `egress_proxy_addr` must be a loopback -`IP:PORT` seed. The driver preserves the configured IP and allocates a unique -ephemeral port for each sandbox's authenticated host CONNECT proxy. +`IP:PORT` seed. For policies with explicit network rules, the driver preserves +the configured IP and allocates a unique ephemeral port for that sandbox's +authenticated host CONNECT proxy. + +`pc_network_allow = true` is an explicit unrestricted-egress compatibility +fallback. If it is combined with `egress_proxy = true`, a sandbox policy +without explicit network rules is rejected synchronously rather than falling +through from governed egress to `defaultPolicy = "allow"`. Supply workload settings for each sandbox. The public config is keyed by driver name; the gateway forwards only the inner `mxc` object to the driver: @@ -87,7 +97,7 @@ The `command` array is required and preserves Windows argument boundaries. `cwd` UI capability (Win32k syscalls, clipboard, input injection) is a `SandboxPolicy` concern, not gateway TOML -- see the Capability Matrix above and `docs/reference/policy-schema.mdx`'s `ui` section. Defaults to disabled (Win32k syscall lockdown) when a policy has no explicit `ui:` section; set `allow_graphical_ui: true` for agents that touch user32/gdi32 at startup even without opening a real window (e.g. Node.js-based targets like OpenClaw's gateway -- see `examples/e2e-policies/openclaw-gateway.yaml`). -`egress_proxy_addr` must be a `127.0.0.1:PORT` address. The port acts only as a configuration seed: the driver reserves a unique ephemeral loopback port for every sandbox. MXC 0.8 denies direct Internet egress and permits `127.0.0.1/32`; the driver points proxy-aware clients at the per-sandbox listener using environment variables. The current policy permits all loopback ports, so sandboxes can also reach unrelated host services bound to loopback. Control-channel forwarding does not require the legacy reverse-WebSocket connections to fresh host ports; restricting the generated policy is separate hardening work. Do not treat this path as loopback-service isolation. Live policy replacement or merge updates remain unsupported; delete and recreate the sandbox to apply a different policy. +`egress_proxy_addr` must be a `127.0.0.1:PORT` address. The port acts only as a configuration seed: for a sandbox policy with explicit network rules, the driver reserves a unique ephemeral loopback port. MXC 0.8 denies direct Internet egress and permits `127.0.0.1/32`; the driver points proxy-aware clients at the per-sandbox listener using environment variables. A policy without network rules keeps MXC's default network posture and receives neither a host listener nor proxy environment variables. The current governed-egress policy permits all loopback ports, so governed sandboxes can also reach unrelated host services bound to loopback. Control-channel forwarding does not require the legacy reverse-WebSocket connections to fresh host ports; restricting the generated policy is separate hardening work. Do not treat this path as loopback-service isolation. Live policy replacement or merge updates remain unsupported; delete and recreate the sandbox to apply a different policy. When `etw_audit` is enabled, each gateway process owns a distinct real-time ETW session named from the stable `OpenShell-MXC-ETW` prefix, its process ID, and a @@ -142,8 +152,9 @@ or MXC-specific gateway composition variant. Provider resolver state uses a separate, create-scoped in-process handoff because it intentionally cannot be represented in the public compute-driver protobuf. -When `egress_proxy` is enabled, `EmbeddedPolicyMapper` uses `split_policy` -instead: MXC receives filesystem grants plus loopback-only egress, +When `egress_proxy` is enabled and the policy contains explicit network rules, +`EmbeddedPolicyMapper` uses `split_policy` instead: MXC receives filesystem +grants plus loopback-only egress, and the driver starts a host CONNECT proxy from the trimmed network-only `SandboxPolicy`. Policies containing `network_middlewares` are rejected synchronously until this host-proxy path can receive the gateway's diff --git a/crates/openshell-driver-mxc/examples/e2e-policies/ws-agent.yaml b/crates/openshell-driver-mxc/examples/e2e-policies/ws-agent.yaml index 6160be6f5c..683a8c0c66 100644 --- a/crates/openshell-driver-mxc/examples/e2e-policies/ws-agent.yaml +++ b/crates/openshell-driver-mxc/examples/e2e-policies/ws-agent.yaml @@ -10,9 +10,8 @@ # system paths and share_dir). # - TCP socket binding on port 22000 (governed by pc_capabilities in the # gateway TOML, not by filesystem policy here). -# - Outbound TCP through the egress proxy, for openshell-supervisor-relay -# to dial the driver's on-demand relay — governed by egress_proxy in the -# TOML. +# - Private/loopback client access for openshell-supervisor-relay to dial +# the driver's on-demand relay — granted by pc_capabilities in the TOML. # - No writes to the host filesystem. # # workload directory (passed by run-ws-agent-test.ps1, default C:\work\openshell-mxc-ws) @@ -24,15 +23,9 @@ # patches this path and the per-sandbox driver config when -AgentDir overrides # the default below. # -# This example intentionally omits network_policies, not because the driver -# would reject it: with egress_proxy = true (set in mxc-ws-gateway.toml), the -# driver takes the lossless split path (policy_map::split_policy) and -# delegates network_policies verbatim to the OpenShell host CONNECT proxy for -# enforcement -- an "info" loss item, not an error, so it would be accepted. -# (Only the no-proxy coarse path, or an unsupported rule shape, can turn a -# network_policies entry into a rejected "error" loss item -- see -# policy_map/map.rs.) This scenario just doesn't need host-enforced network -# rules beyond the loopback/pc_capabilities grant above. +# This example intentionally omits network_policies because it does not need +# governed Internet egress. The driver therefore does not start a host CONNECT +# proxy or inject proxy environment variables for this sandbox. version: 1 filesystem_policy: diff --git a/crates/openshell-driver-mxc/examples/mxc-ws-agent.rs b/crates/openshell-driver-mxc/examples/mxc-ws-agent.rs index dbd63f1426..1c09e7956a 100644 --- a/crates/openshell-driver-mxc/examples/mxc-ws-agent.rs +++ b/crates/openshell-driver-mxc/examples/mxc-ws-agent.rs @@ -282,7 +282,8 @@ async fn run_relay_proxy(relay_url: String, local_url: String, mut stop_rx: ones const LOCAL_CONNECT_ATTEMPTS: u32 = 15; const LOCAL_CONNECT_TIMEOUT: Duration = Duration::from_millis(500); const LOCAL_CONNECT_BACKOFF: Duration = Duration::from_millis(300); - // Connect to the gateway relay (outbound via egress_proxy). + // Connect directly to the gateway relay over the private network access + // granted by the qualification profile's privateNetworkClientServer capability. let relay_ws = match tokio_tungstenite::connect_async(&relay_url).await { Ok((ws, _)) => { eprintln!("[spawner] relay connected: {relay_url}"); diff --git a/crates/openshell-driver-mxc/examples/mxc-ws-gateway.toml b/crates/openshell-driver-mxc/examples/mxc-ws-gateway.toml index afd04a44cd..8a2aa4e9b8 100644 --- a/crates/openshell-driver-mxc/examples/mxc-ws-gateway.toml +++ b/crates/openshell-driver-mxc/examples/mxc-ws-gateway.toml @@ -48,11 +48,10 @@ pc_capabilities = ["privateNetworkClientServer"] # process_container only: keep standard privilege level (not LPA). pc_least_privilege = false -# Egress proxy for outbound TCP connectivity -- required for -# openshell-supervisor-relay to dial out to the driver's on-demand relay -# (see mxc-openclaw-gateway.toml, which uses the same pattern). -egress_proxy = true -egress_proxy_addr = "127.0.0.1:18080" +# No governed Internet egress is needed. The relay reaches the driver's +# on-demand private-interface listener through privateNetworkClientServer above. +egress_proxy = false +egress_proxy_addr = "" # The workload command and cwd are supplied per sandbox by # run-ws-agent-test.ps1 through `sandbox create --driver-config-json`. diff --git a/crates/openshell-driver-mxc/examples/ocsf-audit.yaml b/crates/openshell-driver-mxc/examples/ocsf-audit.yaml index 13e7cd571e..e036cc90ef 100644 --- a/crates/openshell-driver-mxc/examples/ocsf-audit.yaml +++ b/crates/openshell-driver-mxc/examples/ocsf-audit.yaml @@ -7,9 +7,10 @@ # else is default-deny. run-ocsf-audit.ps1 copies this policy into the result # bundle and replaces the default grant with -ShareDir for that run. # -# No network_policies block is needed here: the per-sandbox egress proxy is driven -# by `egress_proxy = true` in mxc-ocsf-audit.toml (that is what makes MXC emit the -# SandboxProxyConfigured event we map to OCSF), not by a policy rule. +# The explicit network rule activates the per-sandbox egress proxy configured +# in mxc-ocsf-audit.toml. That proxy configuration makes MXC emit the +# SandboxProxyConfigured event mapped to OCSF. run-ocsf-audit.ps1 removes this +# block for its explicit -NoProxy comparison. version: 1 filesystem_policy: @@ -17,3 +18,13 @@ filesystem_policy: read_only: [] read_write: - "C:/work/openshell-mxc-demo" + +network_policies: + audit_probe: + name: audit-probe + endpoints: + - host: example.com + port: 443 + protocol: tcp + binaries: + - path: "C:/Windows/System32/cmd.exe" diff --git a/crates/openshell-driver-mxc/examples/run-ocsf-audit.ps1 b/crates/openshell-driver-mxc/examples/run-ocsf-audit.ps1 index 591c38bf24..00d32e10ef 100644 --- a/crates/openshell-driver-mxc/examples/run-ocsf-audit.ps1 +++ b/crates/openshell-driver-mxc/examples/run-ocsf-audit.ps1 @@ -144,6 +144,9 @@ try { $shareDirPolicy = $ShareDir.Replace('\', '/') $shareDirJson = ConvertTo-Json $shareDirPolicy -Compress $policyText = Get-Content $policySrc -Raw + if (-not $proxyOn) { + $policyText = [regex]::Replace($policyText, '(?ms)^network_policies:\s*.*\z', '') + } $defaultGrant = ' - "C:/work/openshell-mxc-demo"' if (-not $policyText.Contains($defaultGrant)) { throw "policy template does not contain the expected default ShareDir grant" diff --git a/crates/openshell-driver-mxc/examples/run-ws-agent-test.ps1 b/crates/openshell-driver-mxc/examples/run-ws-agent-test.ps1 index 0564d17fc5..0372864928 100644 --- a/crates/openshell-driver-mxc/examples/run-ws-agent-test.ps1 +++ b/crates/openshell-driver-mxc/examples/run-ws-agent-test.ps1 @@ -359,15 +359,12 @@ try { } Ok "wxc-exec: $WxcExecPath" - # A real run exercises process_container with egress_proxy = true - # (mxc-ws-gateway.toml). MXC schema 0.8.0-alpha's network_json() - # (mxc.rs) now emits a direct egress.allow rule for 127.0.0.0/8 - # instead of runtimeConfig.networkProxy when a proxy is configured, - # so the driver no longer calls the elevation-only - # NetworkIsolationSetAppContainerConfig -- process_container + - # egress_proxy selects the BaseContainer/PSEC tier and runs - # non-elevated. Elevation is therefore no longer required here; keep - # logging the elevation state for diagnostics only. + # A real run exercises process_container with egress_proxy disabled + # (mxc-ws-gateway.toml). The sandbox connects directly to the driver's + # route-selected private-interface relay listener through the + # privateNetworkClientServer capability; the governed host CONNECT + # proxy is not part of this qualification path. Elevation is not + # required here; keep logging the elevation state for diagnostics only. $wid = [Security.Principal.WindowsIdentity]::GetCurrent() $wp = New-Object Security.Principal.WindowsPrincipal($wid) $admin = $wp.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index 072b1debf1..513a92d858 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -82,7 +82,10 @@ pub struct MxcComputeConfig { /// `processContainer` only: inject a network section with /// `defaultPolicy: "allow"` so the `AppContainer` has unrestricted outbound /// TCP access. Required when `pc_capabilities` alone is insufficient to - /// enable network access in the target wxc-exec build. + /// enable network access in the target wxc-exec build. When `egress_proxy` + /// is also enabled, sandbox policies without explicit network rules are + /// rejected instead of falling back from governed egress to unrestricted + /// access. pub pc_network_allow: bool, /// `processContainer` only: include `"allowLocalNetwork": true` in the /// MXC network section. Required for node.js (and other runtimes that @@ -123,9 +126,11 @@ pub struct MxcComputeConfig { /// MXC `configurationId` for isolation session. Default: `"composable"`. /// Never use `"small"` (known OS bug). pub default_configuration_id: String, - /// Enable Pattern-C governed egress. When true, MXC permits loopback-only - /// egress, the driver injects proxy environment variables, and the host - /// CONNECT proxy receives the full network policy. + /// Enable Pattern-C governed egress for sandbox policies that contain + /// explicit network rules. MXC permits loopback-only egress, the driver + /// injects proxy environment variables, and the host CONNECT proxy receives + /// the full network policy. Policies without network rules do not start a + /// listener or receive proxy environment variables. pub egress_proxy: bool, /// Loopback `IP:PORT` seed for MXC `network.proxy` while governed egress is /// enabled. The driver preserves the loopback IP and allocates a unique @@ -462,6 +467,26 @@ fn configured_egress_addr(config: &MxcComputeConfig) -> Result) -> bool { + policy.is_some_and(|policy| { + !policy.network_policies.is_empty() || !policy.network_middlewares.is_empty() + }) +} + +fn governed_egress_addr( + config: &MxcComputeConfig, + policy: Option<&SandboxPolicy>, +) -> Result, tonic::Status> { + let configured = configured_egress_addr(config)?; + let policy_activates_egress = policy_activates_governed_egress(policy); + if configured.is_some() && !policy_activates_egress && config.pc_network_allow { + return Err(tonic::Status::invalid_argument( + "mxc egress_proxy cannot be combined with pc_network_allow for a sandbox policy without explicit network rules; refusing unrestricted egress fallback", + )); + } + Ok(configured.filter(|_| policy_activates_egress)) +} + fn allocate_sandbox_proxy_addr( configured: SocketAddr, ) -> std::io::Result<(SocketAddr, std::net::TcpListener)> { @@ -816,7 +841,7 @@ impl MxcComputeBackend { } } - fn validate_sandbox_fields(&self, sandbox: &DriverSandbox) -> Result<(), tonic::Status> { + fn validate_sandbox_fields(sandbox: &DriverSandbox) -> Result<(), tonic::Status> { if let Some(spec) = &sandbox.spec { if effective_driver_gpu_count(driver_gpu_requirements( spec.resource_requirements.as_ref(), @@ -859,9 +884,9 @@ impl MxcComputeBackend { } pub fn validate_sandbox_create(&self, sandbox: &DriverSandbox) -> Result<(), tonic::Status> { - self.validate_sandbox_fields(sandbox)?; + Self::validate_sandbox_fields(sandbox)?; let policy = sandbox.spec.as_ref().and_then(|spec| spec.policy.as_ref()); - let egress_addr = configured_egress_addr(&self.config)?; + let egress_addr = governed_egress_addr(&self.config, policy)?; self.map_sandbox_policy(&sandbox.id, policy, egress_addr)?; Ok(()) } @@ -890,9 +915,13 @@ impl MxcComputeBackend { .remove(&sandbox_id); validate_provider_child_env_keys(provider_credentials.as_ref())?; - self.validate_sandbox_fields(sandbox)?; + Self::validate_sandbox_fields(sandbox)?; let sandbox_config = sandbox_config(sandbox)?; - let (egress_addr, reserved_proxy_listener) = match configured_egress_addr(&self.config)? { + let policy = sandbox.spec.as_ref().and_then(|spec| spec.policy.as_ref()); + let (egress_addr, reserved_proxy_listener) = match governed_egress_addr( + &self.config, + policy, + )? { Some(configured_addr) => { let (addr, reservation) = allocate_sandbox_proxy_addr(configured_addr).map_err( |error| { @@ -909,7 +938,6 @@ impl MxcComputeBackend { // Policy translation is deterministic and side-effect free. Do it before // inserting the registry entry or launching MXC so invalid requests fail // synchronously at the CreateSandbox boundary. - let policy = sandbox.spec.as_ref().and_then(|spec| spec.policy.as_ref()); let mapped = self.map_sandbox_policy(&sandbox_id, policy, egress_addr)?; if provider_credentials .as_ref() @@ -917,7 +945,7 @@ impl MxcComputeBackend { && egress_addr.is_none() { return Err(tonic::Status::failed_precondition( - "mxc provider credentials require governed egress; enable egress_proxy so placeholders can be resolved by the host proxy", + "mxc provider credentials require governed egress; enable egress_proxy and configure at least one network policy so placeholders can be resolved by the host proxy", )); } @@ -1425,9 +1453,8 @@ async fn run_lifecycle( let proxy_addr = mapped.proxy_addr; let proxy_auth = proxy_addr.map(|_| SandboxProxyAuth::generate()); let trimmed_policy = mapped.trimmed_policy.clone(); - let host_proxy = if !invoker.is_mock() - && let (Some(addr), Some(proxy_policy), Some(proxy_auth)) = - (proxy_addr, trimmed_policy.clone(), proxy_auth.as_ref()) + let host_proxy = if let (Some(addr), Some(proxy_policy), Some(proxy_auth)) = + (proxy_addr, trimmed_policy.clone(), proxy_auth.as_ref()) { drop(reserved_proxy_listener.take()); match openshell_supervisor_network::host::start_host_proxy( @@ -1447,7 +1474,14 @@ async fn run_lifecycle( ) .await { - Ok(handle) => Some(handle), + Ok(handle) => { + info!( + sandbox = %sandbox_name, + address = %addr, + "MXC host egress proxy started" + ); + Some(handle) + } Err(error) => { set_failed( ®istry, @@ -2366,7 +2400,9 @@ mod lifecycle_tests { use std::time::Duration; fn driver_sandbox(id: &str) -> DriverSandbox { - driver_sandbox_with_command(id, "", vec!["cmd".into(), "/c".into(), "exit 0".into()]) + let shell = + std::env::var("COMSPEC").unwrap_or_else(|_| r"C:\Windows\System32\cmd.exe".to_string()); + driver_sandbox_with_command(id, "", vec![shell, "/c".into(), "exit 0".into()]) } #[tokio::test] @@ -2432,6 +2468,19 @@ mod lifecycle_tests { } } + fn long_running_command(share: &str) -> (String, Vec) { + let shell = + std::env::var("COMSPEC").unwrap_or_else(|_| r"C:\Windows\System32\cmd.exe".to_string()); + let command = vec![ + shell.clone(), + "/d".into(), + "/s".into(), + "/c".into(), + format!(r#"cd /d "{share}" && ping -n 61 127.0.0.1 >nul"#), + ]; + (shell, command) + } + fn github_provider_credentials() -> ProviderCredentialState { ProviderCredentialState::from_bound_environment( 42, @@ -2659,6 +2708,8 @@ mod lifecycle_tests { .expect("provider credential staging lock poisoned") .insert("sb-provider-env".to_string(), credentials); + let shell = + std::env::var("COMSPEC").unwrap_or_else(|_| r"C:\Windows\System32\cmd.exe".to_string()); let mut policy = fs_policy(&[]); policy.network_policies.insert( "github".to_string(), @@ -2668,13 +2719,12 @@ mod lifecycle_tests { host: "api.github.com".to_string(), port: 443, protocol: "rest".to_string(), + enforcement: "enforce".to_string(), + access: "full".to_string(), provider_credentialed: true, ..Default::default() }], - binaries: vec![NetworkBinary { - path: "cmd".to_string(), - ..Default::default() - }], + binaries: vec![NetworkBinary { path: shell }], }, ); let mut sandbox = with_policy(driver_sandbox("sb-provider-env"), policy); @@ -2695,11 +2745,15 @@ mod lifecycle_tests { .create_sandbox(&sandbox) .await .expect("create accepted"); - wait_for(&backend, "sb-provider-env", |_| { + let recorded = wait_for(&backend, "sb-provider-env", |_| { crate::mxc::mock_recorded_config("sb-provider-env").is_some() }) - .await - .expect("MXC config should be recorded"); + .await; + assert!( + recorded.is_some(), + "MXC config should be recorded; sandbox: {:#?}", + backend.get_sandbox("sb-provider-env").await + ); let recorded = crate::mxc::mock_recorded_config("sb-provider-env").expect("mock recorded config"); @@ -3065,121 +3119,195 @@ mod lifecycle_tests { } #[tokio::test] - async fn split_path_provisions_with_proxy_redirect() { - use openshell_core::proto::{NetworkBinary, NetworkEndpoint, NetworkPolicyRule}; + async fn explicit_network_policies_start_and_cleanup_host_proxy() { + for (sandbox_id, host, pc_network_allow) in [ + ("sb-egress-allow", "example.com", false), + ("sb-egress-no-match", "allowed.invalid", true), + ] { + let tmp = tempfile::tempdir().unwrap(); + let share = tmp.path().to_string_lossy().replace('\\', "/"); + let (shell, command) = long_running_command(&share); + let config = MxcComputeConfig { + backend: MxcBackend::ProcessContainer, + pc_network_allow, + egress_proxy: true, + egress_proxy_addr: "127.0.0.1:18080".into(), + ..Default::default() + }; + let backend = MxcComputeBackend::new_mocked(config); + + let mut policy = fs_policy(&[&share]); + policy.network_policies.insert( + "explicit".into(), + NetworkPolicyRule { + name: "explicit".into(), + endpoints: vec![NetworkEndpoint { + host: host.into(), + ports: vec![443], + protocol: "tcp".into(), + ..Default::default() + }], + binaries: vec![NetworkBinary { path: shell }], + }, + ); + let sandbox = with_policy( + driver_sandbox_with_command(sandbox_id, &share, command), + policy.clone(), + ); + backend + .create_sandbox(&sandbox) + .await + .expect("create accepted"); + wait_for(&backend, sandbox_id, |sandbox| { + ready_condition(sandbox).is_some_and(|condition| condition.reason == "AgentRunning") + }) + .await + .expect("explicit network policy sandbox should reach Ready=True"); + + let recorded = + crate::mxc::mock_recorded_config(sandbox_id).expect("mock recorded config"); + assert_eq!(recorded["network"]["egress"]["default"], "deny"); + assert_eq!( + recorded["network"]["egress"]["allow"], + serde_json::json!([{"to": [{"cidr": "127.0.0.1/32"}]}]) + ); + assert!(recorded["network"].get("proxy").is_none()); + + let proxy_addr = { + let registry = backend.registry.lock().await; + let entry = registry.get(sandbox_id).expect("registry entry"); + assert!( + entry.host_proxy.is_some(), + "{sandbox_id}: governed egress must hold a live host proxy" + ); + assert_eq!( + entry.trimmed_policy.as_ref().unwrap().network_policies, + policy.network_policies + ); + entry.proxy_addr.expect("proxy address") + }; + tokio::time::timeout( + Duration::from_secs(2), + tokio::net::TcpStream::connect(proxy_addr), + ) + .await + .expect("proxy listener connect timed out") + .expect("proxy listener must accept connections"); + + let child_env = recorded["process"]["env"].as_array().expect("child env"); + let proxy_env = child_env + .iter() + .filter_map(serde_json::Value::as_str) + .find_map(|entry| entry.strip_prefix("HTTP_PROXY=")) + .expect("HTTP_PROXY must direct clients to the authenticated proxy"); + let (credentials, address) = proxy_env + .strip_prefix("http://openshell:") + .and_then(|value| value.split_once('@')) + .expect("proxy URL must contain the per-sandbox credential"); + assert!(!credentials.is_empty()); + assert_eq!(address, proxy_addr.to_string()); + + backend + .stop_sandbox(sandbox_id) + .await + .expect("stop should clean up the governed egress proxy"); + assert!( + backend + .registry + .lock() + .await + .get(sandbox_id) + .is_some_and(|entry| entry.host_proxy.is_none()), + "{sandbox_id}: stop must drop the host proxy handle" + ); + assert!( + backend + .delete_sandbox(&sandbox.id, &sandbox.name) + .await + .expect("delete after stop"), + "sandbox should be removed" + ); + } + } + + #[tokio::test] + async fn empty_network_policy_does_not_activate_host_proxy() { let tmp = tempfile::tempdir().unwrap(); let share = tmp.path().to_string_lossy().replace('\\', "/"); - let hello = format!("{share}/hello.txt"); - let cmd = vec![ - "powershell".into(), - "-NoProfile".into(), - "-Command".into(), - format!("Set-Content -LiteralPath {hello} -Value hi"), - ]; - let config = MxcComputeConfig { + let (_shell, command) = long_running_command(&share); + let backend = MxcComputeBackend::new_mocked(MxcComputeConfig { backend: MxcBackend::ProcessContainer, egress_proxy: true, egress_proxy_addr: "127.0.0.1:18080".into(), ..Default::default() - }; - let backend = MxcComputeBackend::new_mocked(config); - let mut stream = backend.watch_sandboxes().await; - - let mut policy = fs_policy(&[&share]); - policy.network_policies.insert( - "api".into(), - NetworkPolicyRule { - name: "api".into(), - endpoints: vec![NetworkEndpoint { - host: "example.com".into(), - ports: vec![443], - protocol: "rest".into(), - ..Default::default() - }], - binaries: vec![NetworkBinary { - path: "/usr/bin/curl".into(), - ..Default::default() - }], - }, - ); + }); let sandbox = with_policy( - driver_sandbox_with_command("sb-egress", &share, cmd), - policy.clone(), + driver_sandbox_with_command("sb-egress-default", &share, command), + fs_policy(&[&share]), ); backend .create_sandbox(&sandbox) .await .expect("create accepted"); - - let ready = wait_for(&backend, "sb-egress", |s| { - ready_condition(s).is_some_and(|c| c.status == "True" && c.reason == "AgentRunning") + wait_for(&backend, &sandbox.name, |sandbox| { + ready_condition(sandbox).is_some_and(|condition| condition.reason == "AgentRunning") }) - .await; + .await + .expect("default-policy sandbox should reach Ready=True"); + + let recorded = crate::mxc::mock_recorded_config(&sandbox.id).expect("mock recorded config"); + let registry = backend.registry.lock().await; + let entry = registry.get(&sandbox.id).expect("registry entry"); + assert!(entry.proxy_addr.is_none()); + assert!(entry.host_proxy.is_none()); + assert!(entry.trimmed_policy.is_none()); + drop(registry); + assert!(recorded.get("network").is_none()); assert!( - ready.is_some(), - "egress split sandbox should reach Ready=True" + recorded["process"]["env"] + .as_array() + .expect("child env") + .iter() + .filter_map(serde_json::Value::as_str) + .all(|entry| !PROXY_ENV_KEYS.iter().any(|key| { + entry + .split_once('=') + .is_some_and(|(entry_key, _)| entry_key.eq_ignore_ascii_case(key)) + })) ); - let recorded = crate::mxc::mock_recorded_config("sb-egress").expect("mock recorded config"); - assert_eq!(recorded["version"], "0.8.0-alpha"); - assert_eq!(recorded["network"]["egress"]["default"], "deny"); - assert_eq!( - recorded["network"]["egress"]["allow"], - serde_json::json!([{"to": [{"cidr": "127.0.0.1/32"}]}]) - ); - assert!(recorded["network"].get("allowedHosts").is_none()); - assert!(recorded["network"].get("blockedHosts").is_none()); - assert!(recorded["network"].get("proxy").is_none()); - assert!(recorded.get("networkProxy").is_none()); - - let reg = backend.registry.lock().await; - let entry = reg.get("sb-egress").expect("registry entry"); - let entry_proxy_addr = entry.proxy_addr.expect("proxy addr"); - assert_eq!( - entry_proxy_addr.ip(), - std::net::IpAddr::from([127, 0, 0, 1]) + backend.stop_sandbox(&sandbox.name).await.expect("stop"); + assert!( + backend + .delete_sandbox(&sandbox.id, &sandbox.name) + .await + .expect("delete after stop") ); - assert_ne!(entry_proxy_addr.port(), 0); - let child_env = recorded["process"]["env"].as_array().expect("child env"); - let proxy_env = child_env - .iter() - .filter_map(serde_json::Value::as_str) - .find_map(|entry| entry.strip_prefix("HTTP_PROXY=")) - .expect("HTTP_PROXY must direct clients to the authenticated proxy"); - let (credentials, address) = proxy_env - .strip_prefix("http://openshell:") - .and_then(|value| value.split_once('@')) - .expect("proxy URL must contain the per-sandbox credential"); - assert!(!credentials.is_empty()); - assert_eq!(address, entry_proxy_addr.to_string()); - assert_eq!( - entry.trimmed_policy.as_ref().unwrap().network_policies, - policy.network_policies + } + + #[test] + fn empty_network_policy_rejects_unrestricted_fallback() { + let backend = MxcComputeBackend::new_mocked(MxcComputeConfig { + backend: MxcBackend::ProcessContainer, + pc_network_allow: true, + egress_proxy: true, + egress_proxy_addr: "127.0.0.1:18080".into(), + ..Default::default() + }); + let sandbox = with_policy( + driver_sandbox("sb-egress-unrestricted-fallback"), + fs_policy(&[]), ); - drop(reg); - let mut saw_redirect = false; - let deadline = tokio::time::Instant::now() + Duration::from_secs(15); - while tokio::time::Instant::now() < deadline { - match tokio::time::timeout(Duration::from_millis(500), stream.next()).await { - Ok(Some(Ok(ev))) => { - if let Some(watch_sandboxes_event::Payload::PlatformEvent(pe)) = ev.payload - && pe - .event - .as_ref() - .is_some_and(|e| e.reason == "EgressRedirect") - { - saw_redirect = true; - break; - } - } - Ok(_) => break, - Err(_) => {} - } - } - assert!(saw_redirect, "expected EgressRedirect platform event"); + let error = backend + .validate_sandbox_create(&sandbox) + .expect_err("mixed egress configuration must fail closed without network rules"); + assert_eq!(error.code(), tonic::Code::InvalidArgument); + assert!(error.message().contains("egress_proxy")); + assert!(error.message().contains("pc_network_allow")); + assert!(error.message().contains("unrestricted egress fallback")); } #[tokio::test] diff --git a/crates/openshell-driver-mxc/src/mxc.rs b/crates/openshell-driver-mxc/src/mxc.rs index eecc1f7155..dd65b06358 100644 --- a/crates/openshell-driver-mxc/src/mxc.rs +++ b/crates/openshell-driver-mxc/src/mxc.rs @@ -407,10 +407,6 @@ impl WxcExecInvoker { } } - pub(crate) const fn is_mock(&self) -> bool { - self.mock - } - /// Test-only constructor that forces mock mode without touching the /// process-global `OPENSHELL_MXC_MOCK_WXC` env var (avoids races/UB across /// parallel tests under edition 2024's `unsafe` `set_var`). diff --git a/crates/openshell-driver-mxc/src/relay.rs b/crates/openshell-driver-mxc/src/relay.rs index da0a000188..45b0776290 100644 --- a/crates/openshell-driver-mxc/src/relay.rs +++ b/crates/openshell-driver-mxc/src/relay.rs @@ -3,16 +3,17 @@ //! WebSocket relay embedded in the gateway for MXC `ProcessContainer` sandboxes. //! -//! When `egress_proxy = true` the `AppContainer` has outbound TCP via the -//! `OpenShell` host CONNECT proxy. The driver binds a relay listener on demand -//! (`start_relay`, e.g. from `ForwardSink::open_dynamic_forward`) and tells -//! the in-sandbox spawner its address over the stdin/stdout control channel; -//! the spawner connects outward to it as a WebSocket CLIENT (Phase A). Host -//! clients connect as raw TCP (Phase B); the relay tunnels their bytes -//! through Phase A so the in-sandbox agent can pipe them directly to the -//! target service. Each relay is per-request and short-lived — bound fresh -//! for each `openshell forward service` call, torn down when that forward -//! ends. +//! The `AppContainer` needs private-network client access (for example, the +//! `privateNetworkClientServer` capability used by the qualification profile). +//! The driver binds a relay listener on demand (`start_relay`, e.g. from +//! `ForwardSink::open_dynamic_forward`) and tells the in-sandbox spawner its +//! address over the stdin/stdout control channel; the spawner connects directly +//! to it as a WebSocket CLIENT (Phase A). Governed egress and its host CONNECT +//! proxy are not part of this relay path. Host clients connect as raw TCP +//! (Phase B); the relay tunnels their bytes through Phase A so the in-sandbox +//! agent can pipe them directly to the target service. Each relay is +//! per-request and short-lived — bound fresh for each `openshell forward +//! service` call, torn down when that forward ends. //! //! ```text //! host TCP client -> relay (gateway, raw TCP accept) diff --git a/crates/openshell-driver-mxc/tests/wxc_exec_real.rs b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs index 36b6edb804..abd048c73c 100644 --- a/crates/openshell-driver-mxc/tests/wxc_exec_real.rs +++ b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs @@ -885,10 +885,7 @@ async fn pc_https_egress_reads_injected_ca_bundle() { access: "read-only".to_string(), ..Default::default() }], - binaries: vec![NetworkBinary { - path: cmd_string, - ..Default::default() - }], + binaries: vec![NetworkBinary { path: cmd_string }], }, )]), ..Default::default() diff --git a/mise.lock b/mise.lock index a5e38be2c2..77d25ea64e 100644 --- a/mise.lock +++ b/mise.lock @@ -168,6 +168,12 @@ url = "https://github.com/nextest-rs/nextest/releases/download/cargo-nextest-0.9 url_api = "https://api.github.com/repos/nextest-rs/nextest/releases/assets/501885637" provenance = "github-attestations" +[tools."github:nextest-rs/nextest"."platforms.windows-arm64"] +checksum = "sha256:58c1637ba2396e6c556aa0092f9aa4388695594b8ddda5a4b8b39212574678ce" +url = "https://github.com/nextest-rs/nextest/releases/download/cargo-nextest-0.9.143/cargo-nextest-0.9.143-aarch64-pc-windows-msvc.zip" +url_api = "https://api.github.com/repos/nextest-rs/nextest/releases/assets/501886128" +provenance = "github-attestations" + [tools."github:nextest-rs/nextest"."platforms.windows-x64"] checksum = "sha256:c670ba18e8731fd2eff33a47af33a0fa53d1afa6d0678344e82dc6f8fc7344ac" url = "https://github.com/nextest-rs/nextest/releases/download/cargo-nextest-0.9.143/cargo-nextest-0.9.143-x86_64-pc-windows-msvc.zip" @@ -439,15 +445,19 @@ backend = "aqua:GoogleContainerTools/skaffold" "vars.version_prefix" = "v" [tools.skaffold."platforms.linux-arm64"] +checksum = "sha256:0062e1ff61f35200168454fa5a836306dd30b51acf30999d5d8836770d70ad2f" url = "https://storage.googleapis.com/skaffold/releases/v2.20.0/skaffold-linux-arm64" [tools.skaffold."platforms.linux-x64"] +checksum = "sha256:de75d5697d4a14d6a5e87105671ef9a30990270ab14ad301913f8f8d41a1ecca" url = "https://storage.googleapis.com/skaffold/releases/v2.20.0/skaffold-linux-amd64" [tools.skaffold."platforms.macos-arm64"] +checksum = "sha256:73478a431c17674d2f75d2c6e2fcbf35aa019de4ad6027e5573cc0f581767fbc" url = "https://storage.googleapis.com/skaffold/releases/v2.20.0/skaffold-darwin-arm64" [tools.skaffold."platforms.windows-x64"] +checksum = "sha256:a257da2988782b4c1971bb58b9c7bb52176f8a0b587b6520dec0064f271fa416" url = "https://storage.googleapis.com/skaffold/releases/v2.20.0/skaffold-windows-amd64.exe" [[tools.uv]] @@ -455,13 +465,15 @@ version = "0.10.12" backend = "aqua:astral-sh/uv" [tools.uv."platforms.linux-arm64"] -checksum = "sha256:55bd1c1c10ec8b95a8c184f5e18b566703c6ab105f0fc118aaa4d748aabf28e4" -url = "https://github.com/astral-sh/uv/releases/download/0.10.12/uv-aarch64-unknown-linux-musl.tar.gz" +checksum = "sha256:0ed7d20f49f6b9b60d45fdfcac28f3ac01a671a6ef08672401ed2833423fea2a" +url = "https://github.com/astral-sh/uv/releases/download/0.10.12/uv-aarch64-unknown-linux-gnu.tar.gz" +url_api = "https://api.github.com/repos/astral-sh/uv/releases/assets/377491938" provenance = "github-attestations" [tools.uv."platforms.linux-x64"] -checksum = "sha256:adccf40b5d1939a5e0093081ec2307ea24235adf7c2d96b122c561fa37711c46" -url = "https://github.com/astral-sh/uv/releases/download/0.10.12/uv-x86_64-unknown-linux-musl.tar.gz" +checksum = "sha256:ec72570c9d1f33021aa80b176d7baba390de2cfeb1abcbefca346d563bf17484" +url = "https://github.com/astral-sh/uv/releases/download/0.10.12/uv-x86_64-unknown-linux-gnu.tar.gz" +url_api = "https://api.github.com/repos/astral-sh/uv/releases/assets/377491996" provenance = "github-attestations" [tools.uv."platforms.macos-arm64"] From d78430c66cd8ac85f463e8d6d266fdb3f42b0407 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas <34834085+prekshivyas@users.noreply.github.com> Date: Mon, 21 Sep 2026 15:25:31 -0700 Subject: [PATCH 04/42] test(mxc): verify ProcessContainer token isolation (#3430) Adds real-MXC regression coverage for ProcessContainer token isolation, including an unsandboxed SCM positive control, and documents the AppContainer authorization model. --- .../tests/wxc_exec_real.rs | 197 ++++++++++++++++++ docs/reference/gateway-config.mdx | 11 + 2 files changed, 208 insertions(+) diff --git a/crates/openshell-driver-mxc/tests/wxc_exec_real.rs b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs index abd048c73c..97359d4edc 100644 --- a/crates/openshell-driver-mxc/tests/wxc_exec_real.rs +++ b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs @@ -40,6 +40,97 @@ use std::path::{Path, PathBuf}; use std::process::Command; use std::time::Duration; +mod token_probe { + #![allow(unsafe_code)] + + use std::ffi::c_void; + use std::ptr; + + #[link(name = "kernel32")] + unsafe extern "system" { + fn GetCurrentProcess() -> isize; + fn CloseHandle(handle: isize) -> i32; + fn GetLastError() -> u32; + } + + #[link(name = "advapi32")] + unsafe extern "system" { + fn OpenProcessToken(process: isize, access: u32, token: *mut isize) -> i32; + fn GetTokenInformation( + token: isize, + class: i32, + info: *mut c_void, + len: u32, + return_len: *mut u32, + ) -> i32; + fn OpenSCManagerW(machine: *const u16, database: *const u16, access: u32) -> isize; + fn CloseServiceHandle(handle: isize) -> i32; + } + + fn token_u32(token: isize, class: i32) -> Option { + let mut value = 0u32; + let mut returned = 0u32; + let ok = unsafe { + GetTokenInformation( + token, + class, + (&raw mut value).cast(), + u32::try_from(size_of::()).expect("u32 size fits Win32 length"), + &raw mut returned, + ) + }; + (ok != 0).then_some(value) + } + + fn has_appcontainer_sid(token: isize) -> bool { + let mut buf = [0u8; 256]; + let mut returned = 0u32; + let ok = unsafe { + GetTokenInformation( + token, + 31, // TokenAppContainerSid + buf.as_mut_ptr().cast(), + u32::try_from(buf.len()).expect("token buffer length fits u32"), + &raw mut returned, + ) + }; + ok != 0 && !unsafe { ptr::read_unaligned(buf.as_ptr().cast::<*const c_void>()) }.is_null() + } + + pub fn service_manager_create_access() -> (bool, u32) { + let manager = unsafe { + OpenSCManagerW( + ptr::null(), + ptr::null(), + 0x0002, // SC_MANAGER_CREATE_SERVICE + ) + }; + if manager == 0 { + return (false, unsafe { GetLastError() }); + } + unsafe { CloseServiceHandle(manager) }; + (true, 0) + } + + pub fn snapshot() -> serde_json::Value { + let mut token = 0isize; + let opened = unsafe { OpenProcessToken(GetCurrentProcess(), 0x0008, &raw mut token) }; + assert_ne!(opened, 0, "OpenProcessToken failed"); + + let (can_create_service, create_service_error) = service_manager_create_access(); + let snapshot = serde_json::json!({ + "is_appcontainer": token_u32(token, 29), // TokenIsAppContainer + "has_appcontainer_sid": has_appcontainer_sid(token), + "can_create_service": can_create_service, + "create_service_error": create_service_error, + }); + unsafe { CloseHandle(token) }; + snapshot + } +} + +const TOKEN_PROBE_MARKER: &str = "OPENSHELL_MXC_TOKEN_PROBE="; + // ── Path resolution ────────────────────────────────────────────────────────── /// Resolve the path to `wxc-exec.exe`. @@ -477,6 +568,17 @@ fn dryrun_accepts_split_policy_output() { // These skip on this box (processcontainer velocity keys not enabled; // isolation_session backend absent). They PASS where backends are live. +/// Entrypoint used by `pc_oneshot_token_is_appcontainer_without_admin_access`. +/// The parent test relaunches this integration-test binary inside MXC so the +/// probe observes the workload token rather than the host test runner's token. +#[test] +fn child_token_probe_entry() { + if std::env::var("OPENSHELL_MXC_CHILD_TOKEN_PROBE").as_deref() != Ok("1") { + return; + } + println!("{TOKEN_PROBE_MARKER}{}", token_probe::snapshot()); +} + /// Probe the processcontainer backend. /// /// Runs a trivial one-shot (`cmd /c exit 0`, user-owned temp grant). Returns @@ -801,6 +903,101 @@ fn pc_oneshot_in_policy_write_succeeds() { ); } +/// Verify the default `ProcessContainer` token and an administrator-gated +/// access attempt. `whoami /all` is not sufficient for this assertion: the +/// package SID is exposed through `TokenAppContainerSid`, and `AppContainer` +/// access is the intersection of the user/group and package/capability grants. +#[test] +#[ignore = "requires real wxc-exec"] +fn pc_oneshot_token_is_appcontainer_without_admin_access() { + let Some(wxc) = wxc_path() else { + eprintln!("SKIP: wxc-exec not found"); + return; + }; + let (host_can_create_service, host_error) = token_probe::service_manager_create_access(); + if !host_can_create_service { + eprintln!( + "SKIP: host test runner lacks SC_MANAGER_CREATE_SERVICE (Win32 error {host_error}); sandboxed denial would not prove isolation" + ); + return; + } + if let Err(reason) = probe_processcontainer(&wxc) { + eprintln!("SKIP: processcontainer not live: {reason}"); + return; + } + + let (tempdir, temp_path) = temp_fixture(); + let test_exe = std::env::current_exe().expect("resolve integration-test executable"); + let test_exe_parent = test_exe + .parent() + .expect("integration-test executable has a parent") + .to_string_lossy() + .into_owned(); + let command_line = format!( + "\"{}\" --exact child_token_probe_entry --nocapture", + test_exe.display() + ); + let mut child_env = vec!["OPENSHELL_MXC_CHILD_TOKEN_PROBE=1".to_string()]; + child_env.extend( + ["SYSTEMROOT", "WINDIR", "PATH", "COMSPEC", "LOCALAPPDATA"] + .into_iter() + .filter_map(|key| { + std::env::var(key) + .ok() + .map(|value| format!("{key}={value}")) + }), + ); + let config = serde_json::json!({ + "version": "0.8.0-alpha", + "containerId": "pc-token-identity", + "containment": "processcontainer", + "process": { + "commandLine": command_line, + "cwd": temp_path, + "env": child_env, + "timeout": 30_000, + }, + "filesystem": { + "readwritePaths": [temp_path], + "readonlyPaths": [test_exe_parent], + }, + "processContainer": { + "leastPrivilege": false, + }, + "ui": { + "disable": false, + "clipboard": "none", + "injection": false, + }, + }); + let json = serde_json::to_string(&config).unwrap(); + let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); + let out = Command::new(&wxc) + .arg("--config-base64") + .arg(&b64) + .output() + .expect("wxc-exec token probe spawn"); + let stdout = String::from_utf8_lossy(&out.stdout).into_owned(); + let stderr = String::from_utf8_lossy(&out.stderr).into_owned(); + assert!( + out.status.success(), + "token probe should exit successfully\nstdout={stdout}\nstderr={stderr}" + ); + let snapshot: serde_json::Value = stdout + .lines() + .find_map(|line| line.trim().strip_prefix(TOKEN_PROBE_MARKER)) + .map_or_else( + || panic!("token probe marker missing\nstdout={stdout}\nstderr={stderr}"), + |value| serde_json::from_str(value).expect("parse token probe JSON"), + ); + + assert_eq!(snapshot["is_appcontainer"], 1); + assert_eq!(snapshot["has_appcontainer_sid"], true); + assert_eq!(snapshot["can_create_service"], false); + assert_eq!(snapshot["create_service_error"], 5); // ERROR_ACCESS_DENIED + drop(tempdir); +} + /// Run an HTTPS request through the real driver and `ProcessContainer`. The /// workload explicitly reads the injected bundle before curl uses it, proving /// that the driver's internal TLS share is reachable from the `AppContainer`. diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index 44de908678..a292cb030c 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -949,6 +949,17 @@ debug = false etw_audit = false ``` +The default `pc_least_privilege = false` still runs the workload with an +AppContainer token. Set it to `true` to request the stricter Less Privileged +AppContainer (LPAC) variant. Windows AppContainer tokens retain the launching +account's user SID and group SIDs. Seeing the same username or +`BUILTIN\\Administrators` in `whoami /all` therefore does not mean the sandbox +can exercise administrator access: Windows +requires both the user/group and AppContainer package/capability sides of an +access check to grant the requested operation. The MXC real-binary integration +suite verifies `TokenIsAppContainer`, `TokenAppContainerSid`, and denial of a +non-mutating administrator-gated Service Control Manager open. + Set `egress_proxy = true` with `egress_proxy_addr = "127.0.0.1:18080"` to enable the Windows Pattern C split. The address must be a `127.0.0.1:PORT` socket. The driver allocates a unique ephemeral port per sandbox, injects that listener through proxy environment variables, and stages the public proxy CA beneath the sandbox's configured `/.openshell-proxy//`. A non-empty per-sandbox `cwd` is therefore required when governed egress is enabled; sandbox-specific subdirectories prevent concurrent sandboxes from overwriting each other's trust files. MXC denies direct Internet egress but allows `127.0.0.1/32`; this permits dynamic forwarding but also means the sandbox can reach unrelated host services bound to loopback. MXC rejects policies containing `network_middlewares` before launch because this host-proxy path does not receive the gateway middleware registry. From b2d628079495310e1593bcb156648c31910da364 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas <34834085+prekshivyas@users.noreply.github.com> Date: Mon, 21 Sep 2026 16:17:48 -0700 Subject: [PATCH 05/42] fix(windows): repair OpenClaw ProcessContainer startup (NVBug 6782898) (#3475) --- Cargo.toml | 2 +- .../examples/mxc-openclaw-gateway.toml | 3 + .../examples/mxc-openclaw-isolation.toml | 3 + .../examples/mxc-openclaw-localnet.toml | 3 + .../examples/run-openclaw-forward-test.ps1 | 99 +++-- .../src/control_channel.rs | 84 ++-- crates/openshell-driver-mxc/src/driver.rs | 283 ++++++++++--- .../tests/openclaw_appcontainer_compat.rs | 52 +++ .../tests/wxc_exec_real.rs | 74 ++++ crates/openshell-supervisor-relay/src/imp.rs | 376 +++++++++++------- .../tests/control_channel_contract.rs | 112 ++++-- 11 files changed, 781 insertions(+), 310 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index e916b4c52a..8f8a2efcc6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -57,7 +57,7 @@ miette = { version = "7", features = ["fancy"] } thiserror = "2" # Windows platform APIs (ETW/TDH audit consumer in openshell-driver-mxc; Windows-only) -windows = { version = "0.62", features = ["Wdk_System_Threading", "Win32_Foundation", "Win32_System_Diagnostics_Etw", "Win32_System_Time"] } +windows = { version = "0.62", features = ["Wdk_System_Threading", "Win32_Foundation", "Win32_NetworkManagement_IpHelper", "Win32_Networking_WinSock", "Win32_System_Diagnostics_Etw", "Win32_System_Time"] } anyhow = "1" # Logging/Tracing diff --git a/crates/openshell-driver-mxc/examples/mxc-openclaw-gateway.toml b/crates/openshell-driver-mxc/examples/mxc-openclaw-gateway.toml index b85617a216..b49116fca7 100644 --- a/crates/openshell-driver-mxc/examples/mxc-openclaw-gateway.toml +++ b/crates/openshell-driver-mxc/examples/mxc-openclaw-gateway.toml @@ -15,6 +15,9 @@ # (the AppContainer here can only read paths granted by policy -- see the # script's "Stage artifacts into share_dir" step for why). +[openshell] +version = 2 + [openshell.drivers.mxc] wxc_exec_path = "C:\\mxc-kit\\bin\\wxc-exec.exe" diff --git a/crates/openshell-driver-mxc/examples/mxc-openclaw-isolation.toml b/crates/openshell-driver-mxc/examples/mxc-openclaw-isolation.toml index b81f5b5565..2bab7c5655 100644 --- a/crates/openshell-driver-mxc/examples/mxc-openclaw-isolation.toml +++ b/crates/openshell-driver-mxc/examples/mxc-openclaw-isolation.toml @@ -26,6 +26,9 @@ # openshell-supervisor-relay.exe / the caller's OpenClaw install into # the sandbox's policy-authorized working directory before creating it. +[openshell] +version = 2 + [openshell.drivers.mxc] wxc_exec_path = "C:\\mxc-kit\\bin\\wxc-exec.exe" diff --git a/crates/openshell-driver-mxc/examples/mxc-openclaw-localnet.toml b/crates/openshell-driver-mxc/examples/mxc-openclaw-localnet.toml index f0e4c578d9..6fbaced702 100644 --- a/crates/openshell-driver-mxc/examples/mxc-openclaw-localnet.toml +++ b/crates/openshell-driver-mxc/examples/mxc-openclaw-localnet.toml @@ -6,6 +6,9 @@ # allowLocalNetwork=true lets the AppContainer reach the host's loopback (the gateway # relay) without routing through the egress proxy, which breaks node.js DLL init. +[openshell] +version = 2 + [openshell.drivers.mxc] wxc_exec_path = "C:\\FromSenthil\\mxc-fixes-env-vars\\wxc-exec.exe" diff --git a/crates/openshell-driver-mxc/examples/run-openclaw-forward-test.ps1 b/crates/openshell-driver-mxc/examples/run-openclaw-forward-test.ps1 index 6935b68754..7c3a7c6e80 100644 --- a/crates/openshell-driver-mxc/examples/run-openclaw-forward-test.ps1 +++ b/crates/openshell-driver-mxc/examples/run-openclaw-forward-test.ps1 @@ -56,15 +56,10 @@ param( [Parameter(Mandatory = $true)] [string] $OpenClawInstallDir, # Must be a DIRECT CHILD of a drive root (e.g. C:\openshell-openclaw, not - # C:\work\openshell-openclaw). Node's CommonJS module resolver calls - # fs.realpathSync while resolving the entry script, which lstat()s every - # parent directory up the chain -- including ones OUTSIDE share_dir. The - # AppContainer only grants share_dir itself, so an intermediate parent like - # C:\work fails with EPERM (confirmed empirically: this exact test failed - # with "EPERM: operation not permitted, lstat 'C:\work'" until the share - # dir was moved to the drive root). The drive root itself (C:\) apparently - # doesn't need an explicit grant to lstat successfully, so a one-level path - # sidesteps the problem entirely. + # C:\work\openshell-openclaw). The staged Node invocation below uses + # --preserve-symlinks-main so Node does not realpath the main module before + # our capture script starts; keeping a one-level path also avoids exposing + # or depending on unrelated intermediate directories. [string] $ShareDir = "C:\openshell-openclaw", [int] $TargetPort = 18889, [int] $ForwardLocalPort = 28889, @@ -217,6 +212,7 @@ $fwdProc = $null $fwdLog = Join-Path $resultDir "forward.log" $fwdErrLog = Join-Path $resultDir "forward.err.log" $passed = $false +$failureMessage = "" $healthJson = $null $selfProbeOutcome = "not-recorded" $selfProbeResponseBytes = 0 @@ -426,6 +422,7 @@ try { mxc = @{ command = @( "$shareDirToml/node.exe", + "--preserve-symlinks-main", "$shareDirToml/openclaw-capture.mjs", "gateway", "run", "--dev", "--allow-unconfigured", "--auth", "token", "--bind", "loopback", "--port", "$TargetPort" @@ -456,43 +453,23 @@ try { "--env", "NEMOCLAW_MXC_EGRESS_LOOPBACK_PORT=29999", "--no-tty", "--", "exit" ) + # Windows PowerShell 5.1 wraps native stderr as ErrorRecord objects. Keep + # warnings in the captured diagnostic without letting them terminate the + # command before its real exit code and output are collected. + $createPrevEAP = $ErrorActionPreference + $ErrorActionPreference = "Continue" try { $createOut = & $cli @createArgs 2>&1; $createCode = $LASTEXITCODE } catch { $createOut = $_.Exception.Message; $createCode = 1 } + finally { $ErrorActionPreference = $createPrevEAP } $createBenign = Show-SandboxCreate $createOut $SandboxName if ($createCode -ne 0 -and -not $createBenign) { throw "sandbox create '$SandboxName' failed (exit $createCode): $($createOut | Out-String)" } - # 9. Wait for OpenClaw's gateway to report ready, by tailing the gateway's - # own log for the line it prints on successful startup (forwarded from - # the sandbox's stdout via "wxc-exec stdout:"). Generous timeout: Node - # startup + AppContainer/UAC elevation + plugin warmup can take a while - # on a cold run. - Step "Wait for OpenClaw gateway readiness" - $readyDeadline = (Get-Date).AddSeconds(90) - $openclawReady = $false - while ((Get-Date) -lt $readyDeadline) { - if (Test-Path $gwLog) { - # `.*` (not `\s+`) between "[gateway]" and "ready": OpenClaw wraps its - # log lines in ANSI color codes whenever it inherits enough of the host - # env to detect a color-capable terminal -- which happens with - # mxc-openclaw-localnet.toml (-UseLocalNetwork), since that config - # doesn't set pc_minimal_env and so inherits the full host env, unlike - # mxc-openclaw-gateway.toml's curated minimal set. A strict \s+ match - # missed this entirely and timed out waiting for a line that had - # already printed. Those codes render in this log as LITERAL backslash- - # escaped text (e.g. "...\x1b[36mready..."), not real ESC bytes -- so - # "m" from "36m" directly abuts "ready" with no word boundary, which is - # why a \bready\b tightening (tried once) also failed to match; a bare - # substring check is what actually works here. The resulting collision - # risk with "already" is theoretical -- no such line has been observed - # on this "[gateway]"-tagged forwarded-stdout path in practice. - if (Select-String -Path $gwLog -Pattern '\[gateway\].*ready' -Quiet -ErrorAction SilentlyContinue) { $openclawReady = $true; break } - } - Start-Sleep -Seconds 2 - } - if (-not $openclawReady) { throw "OpenClaw did not report ready within 90s (see gateway.log in the results bundle)" } - Ok "OpenClaw gateway ready" + # `sandbox create` does not return success until the MXC driver receives the + # relay's target_ready event. Trust that lifecycle result directly instead + # of racing a second, text-based poll against gateway.log. + Ok "OpenClaw gateway ready (sandbox target_ready received)" # 10. openshell forward service: opens a fresh, on-demand relay for this # one call and bridges TargetPort (inside the sandbox) to @@ -610,26 +587,55 @@ try { } } catch { - Bad $_.Exception.Message + $failureMessage = $_.Exception.Message + Bad $failureMessage } finally { # Stop the forward before the sandbox so its relay tears down cleanly. if ($fwdProc -and -not $fwdProc.HasExited) { try { Stop-Process -Id $fwdProc.Id -Force -ErrorAction SilentlyContinue } catch {} } + if ($fwdProc) { + try { + if (-not $fwdProc.WaitForExit(5000)) { + throw "forward process did not exit within 5s" + } + } catch { + Info "forward teardown: $($_.Exception.Message)" + if ($passed) { $passed = $false; $failureMessage = $_.Exception.Message } + } + } # Tear down the sandbox while the gateway is still up (delete needs it). if ($cli -and $SandboxName) { - try { & $cli sandbox delete $SandboxName 2>&1 | Out-Null } - catch { Info "sandbox teardown '$SandboxName': $($_.Exception.Message) (continuing)" } + $deleteCode = 1 + $deleteOut = @() + $deletePrevEAP = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { $deleteOut = & $cli sandbox delete $SandboxName 2>&1; $deleteCode = $LASTEXITCODE } + catch { $deleteOut = $_.Exception.Message } + finally { $ErrorActionPreference = $deletePrevEAP } + if ($deleteCode -ne 0) { + $cleanupFailure = "sandbox teardown '$SandboxName' failed (exit $deleteCode): $($deleteOut | Out-String)" + Info $cleanupFailure + if ($passed) { $passed = $false; $failureMessage = $cleanupFailure } + } } if ($KeepRunning -and $gw -and -not $gw.HasExited) { Info "leaving gateway pid $($gw.Id) running (-KeepRunning); stop with: Stop-Process -Id $($gw.Id) -Force" } elseif ($gw -and -not $gw.HasExited) { Step "Cleanup"; Stop-Process -Id $gw.Id -Force -ErrorAction SilentlyContinue - try { $gw.WaitForExit(5000) | Out-Null } catch {} - Info "stopped gateway pid $($gw.Id)" + try { + if (-not $gw.WaitForExit(5000)) { + throw "gateway process did not exit within 5s" + } + Info "stopped gateway pid $($gw.Id)" + } catch { + $cleanupFailure = "gateway teardown failed: $($_.Exception.Message)" + Info $cleanupFailure + if ($passed) { $passed = $false; $failureMessage = $cleanupFailure } + } } Step "Gateway log (tail)" @@ -663,12 +669,17 @@ finally { Step "RESULT" $verdict = if ($passed) { "PASS" } else { "FAIL" } + if (-not $passed -and [string]::IsNullOrWhiteSpace($failureMessage)) { + $failureMessage = "one or more qualification assertions failed" + } + $failureSummary = ($failureMessage -replace '\s+', ' ').Trim() $summary = @" OpenShell MXC OpenClaw + dynamic forward test ===================================================================== timestamp : $stamp machine : $env:COMPUTERNAME verdict : $verdict +failure : $failureSummary sandbox : $SandboxName backend : $Backend config : $tomlName diff --git a/crates/openshell-driver-mxc/src/control_channel.rs b/crates/openshell-driver-mxc/src/control_channel.rs index 673b82177e..76df43456a 100644 --- a/crates/openshell-driver-mxc/src/control_channel.rs +++ b/crates/openshell-driver-mxc/src/control_channel.rs @@ -30,13 +30,13 @@ pub enum ControlChannelError { type PendingMap = Mutex>>; /// Slot for one of the spawner's one-time, unsolicited events -- startup- -/// ready (see `try_route_ready`) and target-ready (see -/// `try_route_target_ready`) each get their own instance of this type. +/// ready (see `try_route_ready`) and target status (see +/// `try_route_target_status`) each get their own instance of this type. /// Not part of `PendingMap`: neither has a correlation id or is a reply to /// anything the driver sent. The payload is `Ok(())` for a normal fire, or /// `Err(reason)` when the event fired but something about it was rejected -/// (currently only the "ready" event's protocol version check uses this; -/// `"target_ready"` always sends `Ok(())`). +/// `"target_ready"` sends `Ok(())`; `"target_failed"` sends its diagnostic +/// as `Err(reason)`. pub type ReadySlot = Mutex>>>; /// Wire protocol version this driver requires from @@ -50,7 +50,7 @@ pub type ReadySlot = Mutex>>>; /// "forward", or the `"target_ready"` event itself) -- an independently /// staged, stale relay binary then fails fast with a clear error instead of /// hanging or misbehaving against fields/events it doesn't understand. -const REQUIRED_SUPERVISOR_RELAY_PROTOCOL_VERSION: u64 = 2; +const REQUIRED_SUPERVISOR_RELAY_PROTOCOL_VERSION: u64 = 4; /// One control channel per sandboxed process. `request()` is safe to call /// concurrently — each call gets its own correlation id and awaits only its @@ -129,20 +129,25 @@ impl ControlChannel { .await } - /// Try to recognize `line` as the spawner's unsolicited target-ready - /// event (`{"event":"target_ready"}`) -- sent once the spawner has - /// actually spawned the target and confirmed its configured port is - /// accepting connections (see `wait_for_port_ready` in - /// `openshell-supervisor-relay`). Distinct from the `"launch"` - /// control-channel *response*, which only confirms the command/env - /// arrived, not that the target is running: driver.rs awaits this event - /// too before publishing the sandbox `Ready=True`, so a caller acting on - /// `Ready` can't race a target that hasn't bound its port yet. Returns - /// `true` if `line` was consumed this way. No version gate here -- the - /// startup "ready" handshake above already rejected an incompatible - /// peer long before this could fire. - pub async fn try_route_target_ready(target_ready: &ReadySlot, line: &str) -> bool { - Self::try_route_named_event(target_ready, line, "target_ready", |_| Ok(())).await + /// Route the spawner's one-time target status event. `target_ready` + /// confirms the configured port is accepting connections; + /// `target_failed` carries the target's actual exit/error diagnostic. + /// Both are distinct from the `launch` response, which only confirms the + /// command and environment arrived. No version gate is needed here: the + /// startup handshake already rejected an incompatible peer. + pub async fn try_route_target_status(target_status: &ReadySlot, line: &str) -> bool { + if Self::try_route_named_event(target_status, line, "target_ready", |_| Ok(())).await { + return true; + } + Self::try_route_named_event(target_status, line, "target_failed", |value| { + let error = value + .get("error") + .and_then(Value::as_str) + .filter(|error| !error.trim().is_empty()) + .unwrap_or("target failed without a diagnostic"); + Err(error.to_string()) + }) + .await } async fn try_route_named_event( @@ -279,7 +284,7 @@ mod tests { let (slot, rx) = armed_ready_slot(); let consumed = - ControlChannel::try_route_ready(&slot, r#"{"event":"ready","protocol_version":2}"#) + ControlChannel::try_route_ready(&slot, r#"{"event":"ready","protocol_version":4}"#) .await; assert!(consumed); @@ -298,13 +303,13 @@ mod tests { consumed, "a recognized ready event is consumed even when rejected" ); - let err = rx.await.unwrap().expect_err("version 2 must be rejected"); + let err = rx.await.unwrap().expect_err("version 1 must be rejected"); assert!( - err.contains('2'), + err.contains('1'), "error should name the offending version: {err}" ); assert!( - err.contains('1'), + err.contains('4'), "error should name the required version: {err}" ); } @@ -334,7 +339,7 @@ mod tests { assert!(!ControlChannel::try_route_ready(&slot, "garbage").await); } - // ── try_route_target_ready ─────────────────────────────────────────── + // ── try_route_target_status ────────────────────────────────────────── #[tokio::test] async fn try_route_target_ready_fires_ok_with_no_version_gate() { @@ -342,9 +347,9 @@ mod tests { // No protocol_version field at all -- unlike "ready", "target_ready" // must not be gated on one (see the doc comment on - // try_route_target_ready). + // try_route_target_status). let consumed = - ControlChannel::try_route_target_ready(&slot, r#"{"event":"target_ready"}"#).await; + ControlChannel::try_route_target_status(&slot, r#"{"event":"target_ready"}"#).await; assert!(consumed); assert_eq!(rx.await.unwrap(), Ok(())); @@ -356,9 +361,9 @@ mod tests { // "ready" and "target_ready" must not be cross-routed into each // other's slot. - let consumed = ControlChannel::try_route_target_ready( + let consumed = ControlChannel::try_route_target_status( &slot, - r#"{"event":"ready","protocol_version":2}"#, + r#"{"event":"ready","protocol_version":4}"#, ) .await; @@ -369,13 +374,15 @@ mod tests { async fn try_route_named_event_is_a_safe_no_op_once_the_slot_is_already_empty() { let (slot, rx) = armed_ready_slot(); - assert!(ControlChannel::try_route_target_ready(&slot, r#"{"event":"target_ready"}"#).await); + assert!( + ControlChannel::try_route_target_status(&slot, r#"{"event":"target_ready"}"#).await + ); // The slot's sender was taken (and used) on the first fire. A // repeat of the same event on the wire is still recognized as a // "target_ready" line (so the caller doesn't mistake it for plain // log text) but must not panic just because the slot is now empty. let consumed_again = - ControlChannel::try_route_target_ready(&slot, r#"{"event":"target_ready"}"#).await; + ControlChannel::try_route_target_status(&slot, r#"{"event":"target_ready"}"#).await; assert!( consumed_again, @@ -388,6 +395,23 @@ mod tests { ); } + #[tokio::test] + async fn try_route_target_failed_preserves_the_diagnostic() { + let (slot, rx) = armed_ready_slot(); + + let consumed = ControlChannel::try_route_target_status( + &slot, + r#"{"event":"target_failed","error":"exit 23; stderr: early crash"}"#, + ) + .await; + + assert!(consumed); + assert_eq!( + rx.await.unwrap(), + Err("exit 23; stderr: early crash".to_string()) + ); + } + // ── fail_all_pending ────────────────────────────────────────────────── #[tokio::test] diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index 513a92d858..01a679eb4b 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -21,6 +21,7 @@ use openshell_core::proto_struct::struct_to_json_value; use openshell_core::provider_credentials::ProviderCredentialState; use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use std::mem::size_of; use std::net::SocketAddr; use std::path::{Path, PathBuf}; use std::pin::Pin; @@ -30,12 +31,119 @@ use tokio::process::Child; use tokio::sync::{Mutex, broadcast, mpsc, oneshot, watch}; use tokio_stream::wrappers::ReceiverStream; use tracing::{info, warn}; +use windows::Win32::Foundation::ERROR_INSUFFICIENT_BUFFER; +use windows::Win32::NetworkManagement::IpHelper::{ + GetExtendedTcpTable, MIB_TCP_STATE_LISTEN, MIB_TCPROW_LH, MIB_TCPTABLE, + TCP_TABLE_BASIC_LISTENER, +}; +use windows::Win32::Networking::WinSock::AF_INET; const DRIVER_NAME: &str = "mxc"; const DRIVER_VERSION: &str = env!("CARGO_PKG_VERSION"); /// Sentinel image name — MXC has no OCI image; this string must be non-empty /// so the gateway's `default_image` cache is satisfied, but it is not pullable. const DEFAULT_IMAGE_SENTINEL: &str = "mxc:process-container"; +const TARGET_READY_TIMEOUT: std::time::Duration = std::time::Duration::from_mins(5); +const TARGET_READY_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(300); + +#[allow(unsafe_code)] +fn tcp_listener_is_present(port: u16) -> std::io::Result { + let mut byte_count = 0_u32; + // SAFETY: The null-buffer call only asks Windows for the required size; + // `byte_count` points to initialized writable storage. + let status = unsafe { + GetExtendedTcpTable( + None, + &raw mut byte_count, + false, + u32::from(AF_INET.0), + TCP_TABLE_BASIC_LISTENER, + 0, + ) + }; + if status != ERROR_INSUFFICIENT_BUFFER.0 && status != 0 { + return Err(std::io::Error::from_raw_os_error(status.cast_signed())); + } + if (byte_count as usize) < size_of::() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "Windows returned an invalid TCP listener table size", + )); + } + + let mut buffer; + loop { + let word_count = (byte_count as usize).div_ceil(size_of::()); + buffer = vec![0_u32; word_count]; + // SAFETY: `buffer` has the returned table's alignment and at least + // the requested byte count. Windows updates `byte_count` if the table + // grows concurrently. + let status = unsafe { + GetExtendedTcpTable( + Some(buffer.as_mut_ptr().cast()), + &raw mut byte_count, + false, + u32::from(AF_INET.0), + TCP_TABLE_BASIC_LISTENER, + 0, + ) + }; + if status == ERROR_INSUFFICIENT_BUFFER.0 { + continue; + } + if status != 0 { + return Err(std::io::Error::from_raw_os_error(status.cast_signed())); + } + break; + } + + let table = buffer.as_ptr().cast::(); + // SAFETY: A successful call writes a `MIB_TCPTABLE` header followed by + // `dwNumEntries` rows into the caller-provided buffer. + let entry_count = unsafe { (*table).dwNumEntries as usize }; + let row_offset = std::mem::offset_of!(MIB_TCPTABLE, table); + let available_rows = (byte_count as usize) + .saturating_sub(row_offset) + .checked_div(size_of::()) + .unwrap_or_default(); + if entry_count > available_rows { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "Windows returned a truncated TCP listener table", + )); + } + // SAFETY: The bounds check proves every row lies within `buffer`. + let rows = unsafe { std::slice::from_raw_parts((*table).table.as_ptr(), entry_count) }; + Ok(rows.iter().any(|row| { + let port_bytes = row.dwLocalPort.to_ne_bytes(); + let listener_port = u16::from_be_bytes([port_bytes[0], port_bytes[1]]); + // SAFETY: `dwState` and `State` are views of the same SDK union field, + // and Windows initialized every returned row. + let state = unsafe { row.Anonymous.dwState }; + state == MIB_TCP_STATE_LISTEN.0.cast_unsigned() && listener_port == port + })) +} + +async fn wait_for_target_listener(port: u16) -> std::io::Result<()> { + let start = tokio::time::Instant::now(); + let deadline = start + TARGET_READY_TIMEOUT; + info!(port, timeout = ?TARGET_READY_TIMEOUT, "waiting for target listener in host TCP table"); + loop { + if tcp_listener_is_present(port)? { + info!(port, elapsed = ?start.elapsed(), "target listener observed in host TCP table"); + return Ok(()); + } + let now = tokio::time::Instant::now(); + if now >= deadline { + break; + } + tokio::time::sleep_until(std::cmp::min(now + TARGET_READY_POLL_INTERVAL, deadline)).await; + } + Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + format!("timed out after {TARGET_READY_TIMEOUT:?} waiting for port {port}"), + )) +} // ── Config ──────────────────────────────────────────────────────────────────── @@ -1801,14 +1909,11 @@ async fn run_lifecycle( } else { (None, None) }; - // Target-ready signal from the spawner (see control_channel.rs's - // try_route_target_ready) -- fired once the target is actually running - // and its configured port is accepting connections, distinct from the - // "launch" response below (which only confirms the command/env - // arrived). Awaited after "launch" succeeds and before publishing - // Ready=True, so Ready can't be reported while the target is still - // unreachable. Always Ok(()) when it fires (no version gate on this - // event -- see try_route_target_ready). + // Target-status signal from the spawner (see control_channel.rs's + // try_route_target_status): Ok once the host observes the target listener + // and the relay confirms the target has not exited, or Err with the + // target's real exit/stderr diagnostic. Distinct from the "launch" + // response below, which only confirms the command/env arrived. let (target_ready_slot, target_ready_rx) = if spawner_wrapping_active { let (tx, rx) = oneshot::channel::>(); (Some(Arc::new(Mutex::new(Some(tx)))), Some(rx)) @@ -1830,7 +1935,9 @@ async fn run_lifecycle( None => false, }; let routed_target_ready = match &target_ready_slot { - Some(slot) => ControlChannel::try_route_target_ready(slot, &line).await, + Some(slot) => { + ControlChannel::try_route_target_status(slot, &line).await + } None => false, }; let routed = routed_ready @@ -1861,7 +1968,7 @@ async fn run_lifecycle( }); } // Drop this scope's Arc clones now that the stdout task holds its own: - // if the spawner exits before ever sending "ready"/"target_ready", the + // if the spawner exits before ever sending "ready"/target status, the // stdout task's clone is the only thing keeping the // Mutex> alive, so its loop ending (EOF) drops the last // reference -- which drops the still-`Some` Sender and makes @@ -1888,14 +1995,14 @@ async fn run_lifecycle( // Publish a cancellable handle (exec_child, and for ProcessContainer // shutdown_tx/terminated_rx too) and release the startup gate now, // rather than holding it until the target-readiness wait below (up to - // ~430s worst case: 120s ready + 310s target_ready) completes or times + // ~430s worst case: 120s relay-ready + 300s listener + handshakes) completes or times // out. stop_sandbox/delete_sandbox block on lifecycle_gate before doing // anything else, so holding it this long meant a stop/delete arriving // while a target is slow to (or never does) come up had no way to // interrupt that wait -- it just queued up behind it. See also imp.rs's - // matching fix: openshell-supervisor-relay now races its own - // port-readiness wait against a "shutdown" request instead of only - // observing shutdown once that wait finishes. + // matching fix: openshell-supervisor-relay now races the host-readiness + // confirmation against a "shutdown" request instead of only observing + // shutdown once startup finishes. let shutdown_rx = { let mut reg = registry.lock().await; let Some(entry) = reg.get_mut(&sandbox_id) else { @@ -2021,54 +2128,100 @@ async fn run_lifecycle( let launch_err = if let Some(e) = ready_err { Some(e) } else { - let launch_data = serde_json::json!({ - "command": sandbox_config.command, - "env": env, - }); - match channel - .request("launch", launch_data, std::time::Duration::from_mins(2)) - .await - { - Ok(resp) if resp.get("ok").and_then(serde_json::Value::as_bool) == Some(true) => { - info!(sandbox = %sandbox_name, "control-channel launch acknowledged"); - // The "launch" response above only confirms the - // command/env reached the spawner -- it still needs - // to spawn the target and confirm its configured - // port is accepting connections - // (openshell-supervisor-relay's own - // wait_for_port_ready, up to ~300s worst case across - // its own retries). Await that distinct - // "target_ready" event before treating launch as - // successful, so a caller acting on Ready=True below - // can never race a target that hasn't bound its port - // yet. - let target_ready_timeout = std::time::Duration::from_secs(310); - match tokio::time::timeout(target_ready_timeout, target_ready_rx).await { - Ok(Ok(Ok(()))) => { - info!(sandbox = %sandbox_name, "control-channel target ready"); - None - } - // No version gate on this event, so this arm - // never actually fires today -- see - // try_route_target_ready -- but match it - // explicitly rather than unreachable!(), in case - // that ever changes. - Ok(Ok(Err(target_err))) => Some(target_err), - Ok(Err(_)) => { - Some("spawner exited before its target became ready".to_string()) + let target_port = config.pc_relay_target_port; + match tcp_listener_is_present(target_port) { + Ok(true) => Some(format!( + "target port {target_port} is already listening before launch" + )), + Err(error) => Some(format!( + "failed to inspect target port {target_port} before launch: {error}" + )), + Ok(false) => { + let launch_data = serde_json::json!({ + "command": sandbox_config.command, + "env": env, + }); + match channel + .request("launch", launch_data, std::time::Duration::from_mins(2)) + .await + { + Ok(resp) + if resp.get("ok").and_then(serde_json::Value::as_bool) + == Some(true) => + { + info!(sandbox = %sandbox_name, "control-channel launch acknowledged"); + // The AppContainer cannot safely probe its own + // pre-listener loopback port or inspect the TCP + // table. Observe the listener from the host while + // racing the relay's early-exit diagnostic. + let mut target_ready_rx = target_ready_rx; + let listener_error = tokio::select! { + result = wait_for_target_listener(target_port) => { + result.err().map(|error| error.to_string()) + } + status = &mut target_ready_rx => { + Some(match status { + Ok(Err(target_err)) => target_err, + Ok(Ok(())) => "spawner reported target ready before host confirmation".to_string(), + Err(_) => "spawner exited before its target became ready".to_string(), + }) + } + }; + if let Some(error) = listener_error { + Some(error) + } else { + let confirm_timeout = std::time::Duration::from_secs(10); + match channel + .request( + "target_ready", + serde_json::Value::Null, + confirm_timeout, + ) + .await + { + Ok(resp) + if resp.get("ok").and_then(serde_json::Value::as_bool) + == Some(true) => + { + match tokio::time::timeout( + confirm_timeout, + &mut target_ready_rx, + ) + .await + { + Ok(Ok(Ok(()))) => { + info!(sandbox = %sandbox_name, "control-channel target ready"); + None + } + Ok(Ok(Err(target_err))) => Some(target_err), + Ok(Err(_)) => Some( + "spawner exited before confirming target readiness" + .to_string(), + ), + Err(_) => Some(format!( + "timed out after {confirm_timeout:?} waiting for target readiness confirmation" + )), + } + } + Ok(resp) => Some( + resp.get("error") + .and_then(|value| value.as_str()) + .unwrap_or("target readiness confirmation rejected") + .to_string(), + ), + Err(error) => Some(error.to_string()), + } + } } - Err(_) => Some(format!( - "timed out after {target_ready_timeout:?} waiting for target to become ready" - )), + Ok(resp) => Some( + resp.get("error") + .and_then(|v| v.as_str()) + .unwrap_or("launch rejected") + .to_string(), + ), + Err(e) => Some(e.to_string()), } } - Ok(resp) => Some( - resp.get("error") - .and_then(|v| v.as_str()) - .unwrap_or("launch rejected") - .to_string(), - ), - Err(e) => Some(e.to_string()), } }; if let Some(err) = launch_err { @@ -2399,6 +2552,18 @@ mod lifecycle_tests { }; use std::time::Duration; + #[test] + fn target_ready_budget_remains_five_minutes() { + assert_eq!(TARGET_READY_TIMEOUT, Duration::from_mins(5)); + } + + #[tokio::test] + async fn host_tcp_table_observes_loopback_listener() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + assert!(tcp_listener_is_present(port).unwrap()); + } + fn driver_sandbox(id: &str) -> DriverSandbox { let shell = std::env::var("COMSPEC").unwrap_or_else(|_| r"C:\Windows\System32\cmd.exe".to_string()); diff --git a/crates/openshell-driver-mxc/tests/openclaw_appcontainer_compat.rs b/crates/openshell-driver-mxc/tests/openclaw_appcontainer_compat.rs index 3a3a16af59..609de6ee05 100644 --- a/crates/openshell-driver-mxc/tests/openclaw_appcontainer_compat.rs +++ b/crates/openshell-driver-mxc/tests/openclaw_appcontainer_compat.rs @@ -3,6 +3,9 @@ const CAPTURE: &str = include_str!("../examples/openclaw-capture.mjs"); const RUNNER: &str = include_str!("../examples/run-openclaw-forward-test.ps1"); +const PROCESS_CONTAINER_CONFIG: &str = include_str!("../examples/mxc-openclaw-gateway.toml"); +const ISOLATION_CONFIG: &str = include_str!("../examples/mxc-openclaw-isolation.toml"); +const LOCAL_NETWORK_CONFIG: &str = include_str!("../examples/mxc-openclaw-localnet.toml"); #[cfg(windows)] #[test] @@ -44,6 +47,30 @@ fn capture_preloads_appcontainer_safe_realpath_before_openclaw() { assert!(CAPTURE.contains("syncBuiltinESMExports()")); } +#[test] +fn runner_preserves_the_node_main_symlink_inside_processcontainer() { + let node = RUNNER + .find("$shareDirToml/node.exe") + .expect("runner must launch the staged Node.js binary"); + let preserve_main = RUNNER[node..] + .find("--preserve-symlinks-main") + .map(|offset| node + offset) + .expect("runner must prevent Node's pre-entrypoint realpath of the drive root"); + let capture = RUNNER[node..] + .find("$shareDirToml/openclaw-capture.mjs") + .map(|offset| node + offset) + .expect("runner must launch the OpenClaw capture entry point"); + + assert!( + preserve_main < capture, + "--preserve-symlinks-main must be a Node option before the main module" + ); + assert!( + !RUNNER.contains("Grant-AppContainerWritableDirectory $shareDirNorm"), + "the runtime workaround must not widen the share grant to the drive root" + ); +} + #[test] fn runner_limits_package_group_dacl_grants_to_writable_data_directories() { assert!(RUNNER.contains("*S-1-15-2-1:(OI)(CI)(M)")); @@ -56,3 +83,28 @@ fn runner_limits_package_group_dacl_grants_to_writable_data_directories() { ); assert!(!RUNNER.contains("Grant-AppContainerWritableDirectory $shareDirNorm")); } + +#[test] +fn runner_uses_lifecycle_readiness_and_preserves_failure_diagnostics() { + assert!(RUNNER.contains("sandbox target_ready received")); + assert!( + !RUNNER.contains("Select-String -Path $gwLog -Pattern '\\[gateway\\].*ready'"), + "target readiness must come from the lifecycle event, not log polling" + ); + assert!(RUNNER.contains("$failureMessage = $_.Exception.Message")); + assert!(RUNNER.contains("failure : $failureSummary")); + assert!(RUNNER.contains("$fwdProc.WaitForExit(5000)")); + assert!(RUNNER.contains("$deleteCode = $LASTEXITCODE")); + assert!(RUNNER.contains("$gw.WaitForExit(5000)")); +} + +#[test] +fn openclaw_gateway_configs_declare_the_current_schema() { + for config in [ + PROCESS_CONTAINER_CONFIG, + ISOLATION_CONFIG, + LOCAL_NETWORK_CONFIG, + ] { + assert!(config.contains("[openshell]\nversion = 2")); + } +} diff --git a/crates/openshell-driver-mxc/tests/wxc_exec_real.rs b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs index 97359d4edc..1e6e041932 100644 --- a/crates/openshell-driver-mxc/tests/wxc_exec_real.rs +++ b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs @@ -1227,6 +1227,80 @@ fn pc_oneshot_out_of_policy_write_denied() { ); } +/// Reading an unrelated root-level path remains denied when only the workload +/// fixture is granted. This guards the `OpenClaw` Node.js workaround against +/// accidentally granting broad access beneath `C:\`. +#[test] +#[ignore = "requires real wxc-exec"] +fn pc_oneshot_unrelated_root_path_read_denied() { + let Some(wxc) = wxc_path() else { + eprintln!("SKIP: wxc-exec not found"); + return; + }; + + if let Err(reason) = probe_processcontainer(&wxc) { + eprintln!("SKIP: processcontainer not live: {reason}"); + return; + } + + let granted_dir = tempfile::tempdir().expect("granted tempdir"); + let denied_root_dir = tempfile::Builder::new() + .prefix("openshell-pc-denied-") + .tempdir_in(r"C:\") + .expect("root-level denied tempdir"); + let denied_file = denied_root_dir.path().join("sentinel.txt"); + std::fs::write(&denied_file, "root-level secret").expect("write denied sentinel"); + let denied_file_str = denied_file.to_string_lossy().into_owned(); + let granted_str = granted_dir.path().to_string_lossy().into_owned(); + let diagnostic = granted_dir.path().join("root-read.txt"); + let diagnostic_str = diagnostic.to_string_lossy().into_owned(); + let config = serde_json::json!({ + "version": "0.6.0-alpha", + "containerId": "pc-root-read-denied", + "containment": "processcontainer", + "process": { + "commandLine": format!("cmd /d /c type \"{denied_file_str}\" 1>\"{diagnostic_str}\" 2>&1"), + "cwd": granted_str, + "timeout": 30_000, + }, + "filesystem": { + "readwritePaths": [granted_str], + }, + "processContainer": { + "leastPrivilege": false, + }, + "ui": { + "disable": false, + "clipboard": "none", + "injection": false, + }, + }); + + let json = serde_json::to_string(&config).unwrap(); + let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); + let out = Command::new(&wxc) + .arg("--config-base64") + .arg(&b64) + .output() + .expect("wxc-exec spawn"); + let code = out.status.code().unwrap_or(-1); + let root_read = std::fs::read_to_string(&diagnostic) + .expect("sandboxed cmd must run and write its drive-root diagnostic"); + + assert_ne!( + code, 0, + "unrelated root-level read must remain denied; diagnostic={root_read}" + ); + assert!( + root_read.to_ascii_lowercase().contains("access is denied"), + "failure must specifically be the root-level access denial; diagnostic={root_read}" + ); + assert!( + !root_read.contains("root-level secret"), + "root-level file contents must not be readable; diagnostic={root_read}" + ); +} + // ── Isolation session enforcement tests ────────────────────────────────────── /// Full `isolation_session` round trip: provision → start → exec → stop → diff --git a/crates/openshell-supervisor-relay/src/imp.rs b/crates/openshell-supervisor-relay/src/imp.rs index 8db89f176e..568696b96e 100644 --- a/crates/openshell-supervisor-relay/src/imp.rs +++ b/crates/openshell-supervisor-relay/src/imp.rs @@ -36,10 +36,9 @@ //! ``` //! //! Usage: `openshell-supervisor-relay.exe ` -- `` -//! is the TCP port the launched command is expected to bind (an early -//! liveness check, `wait_for_port_ready`: if the target never binds it -//! within the bounded five-minute cold-start budget, this process exits with -//! an error instead of sitting around with a target that will never work). +//! is the TCP port the launched command is expected to bind. The host MXC +//! driver observes that listener and confirms it over the control channel; +//! the relay races that confirmation against target exit and shutdown. //! This binary uses no `share_dir` files //! at all -- command/env and shutdown both travel over the control channel. //! @@ -63,7 +62,9 @@ //! Request: `{"id": , "op": "", "data": }` //! Response: `{"id": , "ok": true, "data": }` //! or `{"id": , "ok": false, "error": ""}` -//! Event (unsolicited, no id): `{"event": ""}` +//! Event (unsolicited, no id): `{"event": ""}` (startup also emits +//! either `target_ready` or `target_failed`; the latter includes an +//! `error` string with the target's exit and bounded stderr detail) //! //! Startup handshake: before spawning anything, this process emits //! `{"event":"ready","protocol_version":N}` on stdout (`N` = `PROTOCOL_VERSION` @@ -82,7 +83,8 @@ //! //! Ops: `launch` (see above), `shutdown` (no data; acked, then wakes //! `run_lifecycle` to kill the target and exit -- see Shutdown above), -//! `ping`, `echo`, and `forward` -- `forward` opens a new, independent relay +//! `target_ready` (host listener confirmation), `ping`, `echo`, and `forward` +//! -- `forward` opens a new, independent relay //! bridge for the target port and relay address given in the request (see //! `handle_control_request`'s doc comment for the full shape). //! @@ -93,8 +95,8 @@ use base64::Engine; use futures::{SinkExt, StreamExt}; -use std::collections::HashMap; -use std::sync::Arc; +use std::collections::{HashMap, VecDeque}; +use std::sync::{Arc, Mutex as StdMutex}; use std::time::Duration; use tokio::sync::oneshot; use tokio_tungstenite::tungstenite::Message; @@ -108,7 +110,22 @@ use tokio_tungstenite::tungstenite::Message; /// out-of-sync peer can't safely ignore, so an independently staged, stale /// binary on either side fails fast with a clear version-mismatch error /// instead of hanging or misbehaving against a field/event it predates. -const PROTOCOL_VERSION: u64 = 2; +const PROTOCOL_VERSION: u64 = 4; + +const TARGET_STDERR_TAIL_LINES: usize = 20; +const TARGET_STDERR_LINE_CHARS: usize = 1024; + +struct SpawnedTarget { + child: tokio::process::Child, + stderr_tail: Arc>>, + stderr_forwarder: Option>, +} + +enum StartupOutcome { + Ready, + Exited(std::io::Result), + Shutdown, +} struct ForwardSession { reader: tokio::sync::Mutex, @@ -131,19 +148,24 @@ pub async fn run() -> anyhow::Result<()> { // files are used by this process at all. let (launch_tx, launch_rx) = oneshot::channel::<(Vec, Vec)>(); let launch_slot = Arc::new(tokio::sync::Mutex::new(Some(launch_tx))); + let (target_ready_tx, target_ready_rx) = oneshot::channel::<()>(); + let target_ready_slot = Arc::new(tokio::sync::Mutex::new(Some(target_ready_tx))); let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); let shutdown_slot = Arc::new(tokio::sync::Mutex::new(Some(shutdown_tx))); let forward_sessions = Arc::new(ForwardSessions::new(HashMap::new())); - // Lets main() ask run_control_channel's task to announce "target_ready" - // on stdout once the target is actually up (see below) -- routed - // through that task rather than a second independent stdout handle - // here, since concurrent writers to tokio's stdout can interleave. - let (target_ready_tx, target_ready_rx) = oneshot::channel::<()>(); + // Lets main() ask run_control_channel's task to announce either + // "target_ready" or "target_failed" on stdout. The acknowledgement makes + // failure delivery deterministic: do not let this process exit until the + // real target diagnostic has been flushed into the pipe to the driver. + let (target_status_tx, target_status_rx) = oneshot::channel::>(); + let (target_status_ack_tx, target_status_ack_rx) = oneshot::channel::<()>(); tokio::spawn(run_control_channel( launch_slot, + target_ready_slot, shutdown_slot, forward_sessions, - target_ready_rx, + target_status_rx, + target_status_ack_tx, )); eprintln!("[openshell-supervisor-relay] waiting for launch request from driver..."); @@ -151,29 +173,56 @@ pub async fn run() -> anyhow::Result<()> { .await .map_err(|_| anyhow::anyhow!("control channel closed before a launch request arrived"))?; - let mut child = spawn_target(command, env)?; + let mut target = match spawn_target(command, env) { + Ok(target) => target, + Err(error) => { + let message = format!("target process failed to start: {error:#}"); + announce_target_status(target_status_tx, target_status_ack_rx, Err(message.clone())) + .await; + eprintln!("[openshell-supervisor-relay] {message}"); + std::process::exit(1); + } + }; - eprintln!("[openshell-supervisor-relay] waiting for target on 127.0.0.1:{port} ..."); - // Race the (up to ~300s worst case) port-readiness wait against a - // "shutdown" control-channel request, rather than only observing - // shutdown once run_lifecycle's select starts below. Without this, a - // shutdown arriving while the target is still coming up (or never - // binds) gets acknowledged immediately by run_control_channel -- which - // just fires this oneshot, nothing more -- but nothing actually acts on - // it until wait_for_port_ready returns on its own, leaving this process - // (and the target it spawned) alive for up to the full port-readiness - // budget after a caller was told shutdown succeeded. + eprintln!( + "[openshell-supervisor-relay] waiting for host-confirmed target readiness on port {port} ..." + ); + // The AppContainer cannot reliably connect to a not-yet-listening + // loopback port or inspect the Windows TCP table. The host driver can + // observe that table, so it sends `target_ready` after the listener + // appears. Race that confirmation against target exit and shutdown so a + // failed target still reports its real status/stderr immediately. let mut shutdown_rx = shutdown_rx; - tokio::select! { - result = wait_for_port_ready(&mut child, port, PORT_READY_PER_TRY_TIMEOUT) => { - result?; + let startup = tokio::select! { + biased; + // If the target exits at the same instant the host confirms its + // listener, preserve the real exit/stderr diagnostic instead of + // briefly publishing a stale Ready state. + status = target.child.wait() => StartupOutcome::Exited(status), + _ = &mut shutdown_rx => StartupOutcome::Shutdown, + result = target_ready_rx => match result { + Ok(()) => StartupOutcome::Ready, + Err(_) => StartupOutcome::Exited(Err(std::io::Error::new( + std::io::ErrorKind::BrokenPipe, + "control channel closed before target readiness was confirmed", + ))), + }, + }; + match startup { + StartupOutcome::Ready => {} + StartupOutcome::Exited(status) => { + let message = target_failure_message(&mut target, port, status).await; + announce_target_status(target_status_tx, target_status_ack_rx, Err(message.clone())) + .await; + eprintln!("[openshell-supervisor-relay] {message}"); + std::process::exit(1); } - _ = &mut shutdown_rx => { + StartupOutcome::Shutdown => { eprintln!( "[openshell-supervisor-relay] shutdown request -- stopping before target became ready" ); - let _ = child.kill().await; - let _ = child.wait().await; + let _ = target.child.kill().await; + let _ = target.child.wait().await; eprintln!("[openshell-supervisor-relay] done"); // Not `return Ok(())`: run_control_channel loops on // stdin.next_line() for this process's entire lifetime and @@ -187,18 +236,18 @@ pub async fn run() -> anyhow::Result<()> { std::process::exit(0); } } - eprintln!("[openshell-supervisor-relay] target is up on port {port}"); + eprintln!("[openshell-supervisor-relay] host confirmed target listener on port {port}"); // Unsolicited event, distinct from the "launch" control-channel // response (which only confirmed the command/env arrived, not that the // target is actually reachable) -- driver.rs awaits this before // publishing the sandbox Ready=True. A send failure just means the // control-channel task already exited; nothing to do about that here. - let _ = target_ready_tx.send(()); + announce_target_status(target_status_tx, target_status_ack_rx, Ok(())).await; // No bridge at startup -- relay bridging is entirely on-demand via the // control channel's "forward" op (see module docs). Just run the target // process's lifecycle from here. - run_lifecycle(child, shutdown_rx).await; + run_lifecycle(target, shutdown_rx).await; Ok(()) } @@ -244,7 +293,7 @@ fn byte_preview(data: &[u8]) -> String { /// because our stdout is reserved exclusively for the control-channel /// protocol with the driver. The child's stdin is closed; it isn't part of /// this channel. -fn spawn_target(command: Vec, env: Vec) -> anyhow::Result { +fn spawn_target(command: Vec, env: Vec) -> anyhow::Result { if command.is_empty() { anyhow::bail!("launch command must not be empty"); } @@ -284,26 +333,93 @@ fn spawn_target(command: Vec, env: Vec) -> anyhow::Result>>>, +) { use tokio::io::{AsyncBufReadExt, BufReader}; let mut lines = BufReader::new(reader).lines(); while let Ok(Some(line)) = lines.next_line().await { + if let Some(tail) = &capture_tail { + let mut chars = line.chars(); + let mut captured: String = chars.by_ref().take(TARGET_STDERR_LINE_CHARS).collect(); + if chars.next().is_some() { + captured.push_str("..."); + } + if let Ok(mut tail) = tail.lock() { + if tail.len() == TARGET_STDERR_TAIL_LINES { + tail.pop_front(); + } + tail.push_back(captured); + } + } eprintln!("[{label}] {line}"); } } +async fn announce_target_status( + sender: oneshot::Sender>, + announced: oneshot::Receiver<()>, + status: Result<(), String>, +) { + if sender.send(status).is_err() { + return; + } + if tokio::time::timeout(Duration::from_secs(5), announced) + .await + .is_err() + { + eprintln!("[openshell-supervisor-relay] control channel did not flush target status"); + } +} + +async fn target_failure_message( + target: &mut SpawnedTarget, + port: u16, + status: std::io::Result, +) -> String { + if let Some(forwarder) = target.stderr_forwarder.take() { + let _ = tokio::time::timeout(Duration::from_secs(2), forwarder).await; + } + let status = status.map_or_else( + |error| format!("status unavailable: {error}"), + |status| status.to_string(), + ); + let stderr = target + .stderr_tail + .lock() + .map(|tail| tail.iter().cloned().collect::>().join(" | ")) + .unwrap_or_default(); + let stderr = if stderr.is_empty() { + String::new() + } else { + format!("; stderr: {stderr}") + }; + format!("target process exited before port {port} came up: {status}{stderr}") +} + // ── Control channel ─────────────────────────────────────────────────────────── // // See module docs for the protocol and why this is safe to run with no @@ -314,6 +430,9 @@ async fn forward_tagged_lines(reader: impl tokio::io::AsyncRead + Unpin, label: /// env)` to `main()`. `None` after the first successful launch (or if /// `main()` already gave up on it) -- a second `"launch"` is rejected. type LaunchSlot = tokio::sync::Mutex, Vec)>>>; +/// Holds the one-shot sender the driver's host-side listener observation +/// fires. Startup does not become ready until this confirmation arrives. +type TargetReadySlot = tokio::sync::Mutex>>; /// Holds the one-shot sender the `"shutdown"` op fires, waking /// `run_lifecycle`'s select so it can kill the target and exit. `None` /// after the first shutdown request -- a second one is a no-op ack. @@ -321,9 +440,11 @@ type ShutdownSlot = tokio::sync::Mutex>>; async fn run_control_channel( launch: Arc, + target_ready: Arc, shutdown: Arc, forward_sessions: Arc, - target_ready_rx: oneshot::Receiver<()>, + target_status_rx: oneshot::Receiver>, + target_status_ack: oneshot::Sender<()>, ) { use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; @@ -344,11 +465,11 @@ async fn run_control_channel( } eprintln!("[openshell-supervisor-relay] control channel ready (stdin/stdout)"); - // `None` once fired (or once main()'s sender is dropped without firing, - // e.g. wait_for_port_ready failed) -- the `if` guard below then disables - // that select arm instead of it firing repeatedly on every subsequent - // poll of an already-resolved oneshot. - let mut target_ready_rx = Some(target_ready_rx); + // `None` once fired (or once main()'s sender is dropped without firing) -- + // the `if` guard below then disables that select arm instead of it firing + // repeatedly on every subsequent poll of an already-resolved oneshot. + let mut target_status_rx = Some(target_status_rx); + let mut target_status_ack = Some(target_status_ack); loop { tokio::select! { @@ -381,6 +502,7 @@ async fn run_control_channel( let response = handle_control_request( trimmed, &launch, + &target_ready, &shutdown, &forward_sessions, ) @@ -395,19 +517,25 @@ async fn run_control_channel( // See module docs' startup handshake -- distinct from the // "launch" response, which only confirms the command/env // arrived. Unsolicited, like "ready" above. - result = async { target_ready_rx.as_mut().unwrap().await }, if target_ready_rx.is_some() => { - target_ready_rx = None; - if result.is_ok() { - let event = serde_json::json!({"event": "target_ready"}).to_string() + "\n"; - if stdout.write_all(event.as_bytes()).await.is_err() || stdout.flush().await.is_err() { - eprintln!("[openshell-supervisor-relay] control channel: failed to announce target_ready"); + result = async { target_status_rx.as_mut().unwrap().await }, if target_status_rx.is_some() => { + target_status_rx = None; + let event = match result { + Ok(Ok(())) => Some(serde_json::json!({"event": "target_ready"})), + Ok(Err(error)) => Some(serde_json::json!({"event": "target_failed", "error": error})), + Err(_) => None, + }; + if let Some(event) = event { + let out = event.to_string() + "\n"; + if stdout.write_all(out.as_bytes()).await.is_err() || stdout.flush().await.is_err() { + eprintln!("[openshell-supervisor-relay] control channel: failed to announce target status"); + } + if let Some(ack) = target_status_ack.take() { + let _ = ack.send(()); } } - // A dropped sender (main() bailed before the target ever - // came up, e.g. wait_for_port_ready's own error) means - // there's nothing to announce -- driver.rs's timeout on the - // corresponding event will surface that as a launch failure - // on its own. + // A dropped sender means main() exited before announcing + // target status. There is nothing to write in that case; + // driver.rs will observe control-channel EOF. } } } @@ -446,6 +574,7 @@ fn describe_control_request(line: &str) -> String { async fn handle_control_request( line: &str, launch: &LaunchSlot, + target_ready: &TargetReadySlot, shutdown: &ShutdownSlot, forward_sessions: &ForwardSessions, ) -> serde_json::Value { @@ -461,6 +590,33 @@ async fn handle_control_request( let op = req.get("op").and_then(|v| v.as_str()).unwrap_or(""); match op { + // The host MXC driver can observe the AppContainer listener without + // the AppContainer's own network/table restrictions. It sends this + // only after the configured target port appears in the host TCP + // table; main races this signal against target exit and shutdown. + "target_ready" => { + let slot = target_ready.lock().await.take(); + slot.map_or_else( + || { + serde_json::json!({ + "id": id, + "ok": false, + "error": "target readiness already confirmed" + }) + }, + |tx| { + if tx.send(()).is_ok() { + serde_json::json!({"id": id, "ok": true}) + } else { + serde_json::json!({ + "id": id, + "ok": false, + "error": "target readiness receiver is unavailable" + }) + } + }, + ) + } // Driver sends this on sandbox delete instead of writing a // openshell-shutdown.signal file -- wakes run_lifecycle's select so // it can kill the target and exit. Acked even on a repeat (the @@ -714,74 +870,6 @@ where Err(last_error) } -/// Number of full-budget tries `wait_for_port_ready` makes -- each try gets -/// its own complete `per_try_timeout` window, not a slice of it. Worst case -/// total wait is `max_tries * per_try_timeout` (5 * 60s = 300s today). Keep -/// this aligned with the MXC driver's 310-second target-ready timeout so a -/// cold process remains actively probed for the full advertised budget. -const PORT_READY_MAX_TRIES: u32 = 5; -const PORT_READY_PER_TRY_TIMEOUT: Duration = Duration::from_mins(1); - -/// Poll for the target port accepting TCP connections, bailing out early -/// (rather than waiting out all tries) if the child process exits first — a -/// dead child will never open the port, so there's no reason to wait. Makes -/// up to `PORT_READY_MAX_TRIES` tries, each given the full `per_try_timeout` -/// budget, logging the start of every try so a long cold-start wait (Node.js -/// first-run JIT/module resolution, first-touch AV scan of freshly staged -/// binaries, etc.) is visible rather than silent until success or final -/// timeout. -async fn wait_for_port_ready( - child: &mut tokio::process::Child, - port: u16, - per_try_timeout: Duration, -) -> anyhow::Result<()> { - // Bound each individual connect attempt: on at least one observed - // wxc-exec build, a connect() against a not-yet-listening loopback port - // inside the AppContainer never resolved at all (no fast ECONNREFUSED, - // no error) instead of failing quickly like an ordinary closed-port - // connect. Without this, a single early attempt can hang forever and - // this function -- and the whole readiness wait -- never returns even - // after the target's port genuinely opens, since nothing ever retries. - const ATTEMPT_TIMEOUT: Duration = Duration::from_secs(2); - - let overall_start = tokio::time::Instant::now(); - for try_num in 1..=PORT_READY_MAX_TRIES { - eprintln!( - "[openshell-supervisor-relay] port readiness try {try_num}/{PORT_READY_MAX_TRIES} (up to {per_try_timeout:?}) for port {port}" - ); - let try_deadline = tokio::time::Instant::now() + per_try_timeout; - loop { - let attempt = tokio::time::timeout( - ATTEMPT_TIMEOUT, - tokio::net::TcpStream::connect(("127.0.0.1", port)), - ) - .await; - if let Ok(Ok(_)) = attempt { - eprintln!( - "[openshell-supervisor-relay] port {port} ready after {:?} (try {try_num}/{PORT_READY_MAX_TRIES})", - overall_start.elapsed() - ); - return Ok(()); - } - if let Ok(Some(status)) = child.try_wait() { - anyhow::bail!("target process exited before port {port} came up: {status}"); - } - if tokio::time::Instant::now() >= try_deadline { - eprintln!( - "[openshell-supervisor-relay] port readiness try {try_num}/{PORT_READY_MAX_TRIES} timed out after {per_try_timeout:?} (elapsed {:?} total)", - overall_start.elapsed() - ); - break; - } - tokio::time::sleep(Duration::from_millis(300)).await; - } - } - anyhow::bail!( - "timed out after {PORT_READY_MAX_TRIES} tries ({:?} total) waiting for port {port}", - overall_start.elapsed() - ); -} - // ── Relay bridge ────────────────────────────────────────────────────────────── // // Implements the sandbox side of relay.rs's Phase A protocol exactly: @@ -893,14 +981,14 @@ async fn run_relay_bridge( msg = relay_read.next() => match msg { Some(Ok(Message::Text(t))) => { if t == "SESSION_START" { - // Retry briefly: even though wait_for_port_ready() already - // confirmed a raw TCP accept succeeds once, that doesn't - // guarantee the target's listener stays continuously - // accept-ready under a freshly-started process (observed as - // a genuine, reproducible ~500ms startup race elsewhere in - // this codebase -- see mxc-ws-agent.rs's local-connect - // retry). A session-open failure here would otherwise - // silently drop the host's connection attempt. + // Retry briefly: the host driver's TCP-table observation + // confirms a listener exists, but does not guarantee it + // stays continuously accept-ready under a freshly-started + // process (observed as a genuine, reproducible ~500ms + // startup race elsewhere in this codebase -- see + // mxc-ws-agent.rs's local-connect retry). A session-open + // failure here would otherwise silently drop the host's + // connection attempt. match connect_forward_target(|| tokio::net::TcpStream::connect(("127.0.0.1", port))).await { Ok(s) => { // Latency-sensitive request/response tunnel -- @@ -998,17 +1086,17 @@ async fn run_relay_bridge( /// Any active dynamic relay bridges are tokio tasks in this same process, so /// `std::process::exit` below tears them down too -- no separate stop signal /// needed. -async fn run_lifecycle(mut child: tokio::process::Child, shutdown_rx: oneshot::Receiver<()>) { +async fn run_lifecycle(mut target: SpawnedTarget, shutdown_rx: oneshot::Receiver<()>) { tokio::select! { - status = child.wait() => { + status = target.child.wait() => { let code = status.map_or(1, |s| s.code().unwrap_or(1)); eprintln!("[openshell-supervisor-relay] target exited with code {code}"); std::process::exit(code); } _ = shutdown_rx => { eprintln!("[openshell-supervisor-relay] shutdown request -- stopping"); - let _ = child.kill().await; - let _ = child.wait().await; + let _ = target.child.kill().await; + let _ = target.child.wait().await; eprintln!("[openshell-supervisor-relay] done"); std::process::exit(0); } @@ -1017,20 +1105,12 @@ async fn run_lifecycle(mut child: tokio::process::Child, shutdown_rx: oneshot::R #[cfg(test)] mod forward_connect_tests { - use super::{PORT_READY_MAX_TRIES, PORT_READY_PER_TRY_TIMEOUT, connect_forward_target}; + use super::connect_forward_target; use std::io::{Error, ErrorKind}; use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; use std::time::Duration; - #[test] - fn port_ready_budget_covers_five_minute_cold_start_contract() { - assert_eq!( - PORT_READY_PER_TRY_TIMEOUT * PORT_READY_MAX_TRIES, - Duration::from_mins(5), - ); - } - #[tokio::test(start_paused = true)] async fn forward_connect_returns_success_without_retry_delay() { let start = tokio::time::Instant::now(); diff --git a/crates/openshell-supervisor-relay/tests/control_channel_contract.rs b/crates/openshell-supervisor-relay/tests/control_channel_contract.rs index 1f3fb51464..c74fbc63ca 100644 --- a/crates/openshell-supervisor-relay/tests/control_channel_contract.rs +++ b/crates/openshell-supervisor-relay/tests/control_channel_contract.rs @@ -187,7 +187,7 @@ impl RelayProcess { async fn expect_ready(&mut self) { let v = self.next_json().await; assert_eq!(v["event"], "ready"); - assert_eq!(v["protocol_version"], 2); + assert_eq!(v["protocol_version"], 4); } async fn launch(&mut self, id: u64, command: &[&str]) -> Value { @@ -199,6 +199,11 @@ impl RelayProcess { .await; self.next_json().await } + + async fn confirm_target_ready(&mut self, id: u64) -> Value { + self.send(json!({"id": id, "op": "target_ready"})).await; + self.next_json().await + } } /// Spawn an in-process TCP echo server (a stand-in for whatever real @@ -318,7 +323,8 @@ async fn launch_success_then_target_ready_ordering() { l.local_addr().unwrap().port() }; let script = format!( - "$l=[System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback,{port}); \ + "Start-Sleep -Milliseconds 800; \ + $l=[System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback,{port}); \ $l.Start(); Start-Sleep -Seconds 30" ); @@ -340,12 +346,24 @@ async fn launch_success_then_target_ready_ordering() { assert_eq!(ack["id"], 1); assert_eq!(ack["ok"], true, "launch ack: {ack}"); - // The "launch" response only confirms the command/env arrived -- the - // unsolicited "target_ready" event (no correlation id) is the actual - // liveness confirmation once the port readiness poll succeeds, and it - // must not have been sent already (it can't have been: nothing before - // this point in the protocol lets the spawner know the port bound). - // Reading it as the very next line asserts the ordering directly. + // Production performs this observation in the host driver, where the TCP + // table is accessible. Stand in for that driver by waiting until the + // delayed target listener is visible, then explicitly confirm it. + tokio::time::timeout(TIMEOUT, async { + loop { + if TcpStream::connect(("127.0.0.1", port)).await.is_ok() { + break; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + }) + .await + .expect("delayed target did not bind"); + let confirmation = relay.confirm_target_ready(2).await; + assert_eq!(confirmation, json!({"id": 2, "ok": true})); + + // The correlated confirmation response precedes the relay's unsolicited + // event, which remains the driver's final race-safe startup signal. let target_ready = relay.next_json().await; assert_eq!(target_ready["event"], "target_ready"); assert!( @@ -354,6 +372,45 @@ async fn launch_success_then_target_ready_ordering() { ); } +#[tokio::test(flavor = "multi_thread")] +async fn early_child_crash_is_reported_promptly_with_its_stderr() { + let port = { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + listener.local_addr().unwrap().port() + }; + let mut relay = RelayProcess::spawn(port).await; + relay.expect_ready().await; + + let ack = relay + .launch( + 1, + &[ + "cmd", + "/d", + "/c", + "echo early-child-crash-sentinel 1>&2 & exit /b 23", + ], + ) + .await; + assert_eq!(ack["ok"], true, "launch ack: {ack}"); + + let failed = relay.next_json().await; + assert_eq!(failed["event"], "target_failed", "failure event: {failed}"); + let error = failed["error"] + .as_str() + .expect("target_failed error string"); + assert!(error.contains("23"), "exit status missing from: {error}"); + assert!( + error.contains("early-child-crash-sentinel"), + "target stderr missing from: {error}" + ); + + tokio::time::timeout(TIMEOUT, relay.child.wait()) + .await + .expect("relay must exit promptly after reporting an early target crash") + .expect("wait() failed"); +} + #[tokio::test(flavor = "multi_thread")] async fn shutdown_is_acked_and_the_process_exits() { let port = { @@ -380,12 +437,14 @@ async fn shutdown_is_acked_and_the_process_exits() { ) .await; assert_eq!(ack["ok"], true); + let confirmation = relay.confirm_target_ready(2).await; + assert_eq!(confirmation, json!({"id": 2, "ok": true})); let target_ready = relay.next_json().await; assert_eq!(target_ready["event"], "target_ready"); - relay.send(json!({"id": 2, "op": "shutdown"})).await; + relay.send(json!({"id": 3, "op": "shutdown"})).await; let ack = relay.next_json().await; - assert_eq!(ack, json!({"id": 2, "ok": true})); + assert_eq!(ack, json!({"id": 3, "ok": true})); let status = tokio::time::timeout(TIMEOUT, relay.child.wait()) .await @@ -395,11 +454,10 @@ async fn shutdown_is_acked_and_the_process_exits() { } /// Reproduces the reported race: a "shutdown" request arriving while the -/// target is still coming up (here, one that never binds the port at all) -/// must stop this process promptly, not leave it waiting out the full -/// port-readiness budget (~300s worst case, see `wait_for_port_ready`) -/// before ever observing the shutdown that `run_control_channel` already -/// acknowledged. Without racing that wait against shutdown, the final +/// target is still coming up (here, one the host never confirms as ready) +/// must stop this process promptly instead of leaving it waiting for host +/// confirmation after `run_control_channel` already acknowledged shutdown. +/// Without racing that wait against shutdown, the final /// `child.wait()` below would time out instead of completing within /// `SHUTDOWN_TIMEOUT`. /// @@ -428,12 +486,11 @@ async fn shutdown_is_acked_and_the_process_exits() { /// process, observed externally, never exited. This test would have /// caught that. #[tokio::test(flavor = "multi_thread")] -async fn shutdown_during_port_wait_stops_promptly() { +async fn shutdown_while_waiting_for_host_readiness_stops_promptly() { // Generous: covers this host's observed CreateProcess stalls (up to // ~60s) plus real margin, not just the fast path. const SPAWN_TIMEOUT: Duration = Duration::from_mins(2); - // Well under the ~300s port-readiness budget this fix exists to avoid - // waiting out -- generous only relative to `TIMEOUT`, since a bare + // Generous only relative to `TIMEOUT`, since a bare // `std::process::exit` completes in well under a second. const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(20); @@ -444,20 +501,19 @@ async fn shutdown_during_port_wait_stops_promptly() { let mut relay = RelayProcess::spawn_capturing_stderr(port).await; relay.expect_ready().await; - // A real target that never binds `port` -- port-readiness polling never - // succeeds on its own. cmd.exe rather than powershell.exe: smaller, - // simpler, and this test's target just needs to run for a while and - // never bind `port`, not do anything powershell-specific. + // A real target the host never confirms as ready. Do not use `timeout.exe` + // here: it exits immediately + // when its stdin is not attached to a console, which is precisely how the + // relay launches targets. let ack = relay - .launch(1, &["cmd", "/c", "timeout /t 300 /nobreak >nul"]) + .launch(1, &["cmd", "/d", "/c", "ping -n 301 127.0.0.1 >nul"]) .await; assert_eq!(ack["ok"], true, "launch ack: {ack}"); - // Confirms spawn_target() has returned and wait_for_port_ready has - // started -- only from this point on is the fix under test actually - // in play. + // Confirms spawn_target() has returned and the host-confirmation wait has + // started -- only from this point on is the fix under test in play. relay - .wait_for_stderr_line("waiting for target on", SPAWN_TIMEOUT) + .wait_for_stderr_line("waiting for host-confirmed target readiness", SPAWN_TIMEOUT) .await; relay.send(json!({"id": 2, "op": "shutdown"})).await; @@ -468,7 +524,7 @@ async fn shutdown_during_port_wait_stops_promptly() { .await .expect( "relay did not exit promptly after shutdown during port-wait \ - (see imp.rs's port-wait/shutdown race)", + (see imp.rs's host-readiness/shutdown race)", ) .expect("wait() failed"); assert!(status.success(), "expected a clean exit, got {status:?}"); From 80b35dbfe0dd6ee63813ac8d209fff22b3ac7db5 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas <34834085+prekshivyas@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:28:52 -0700 Subject: [PATCH 06/42] fix(mxc): repair Windows inference demos (NVBug 6782874) (#3473) * fix(mxc): repair Windows inference demos Signed-off-by: Prekshi Vyas * fix(mxc): address inference demo review feedback Signed-off-by: Prekshi Vyas --------- Signed-off-by: Prekshi Vyas --- Cargo.lock | 1 + crates/openshell-driver-mxc/Cargo.toml | 2 + .../openshell-driver-mxc/examples/README.txt | 48 +++ .../examples/inference.yaml | 24 ++ .../examples/mxc-inference.toml | 19 ++ .../examples/mxc-ollama.toml | 21 ++ .../openshell-driver-mxc/examples/ollama.yaml | 24 ++ .../examples/run-inference-test.ps1 | 294 ++++++++++++++++++ .../examples/run-ollama-test.ps1 | 282 +++++++++++++++++ crates/openshell-driver-mxc/src/driver.rs | 76 +++++ .../tests/demo_examples.rs | 185 +++++++++++ .../tests/mxc_demo_configs.rs | 25 ++ 12 files changed, 1001 insertions(+) create mode 100644 crates/openshell-driver-mxc/examples/README.txt create mode 100644 crates/openshell-driver-mxc/examples/inference.yaml create mode 100644 crates/openshell-driver-mxc/examples/mxc-inference.toml create mode 100644 crates/openshell-driver-mxc/examples/mxc-ollama.toml create mode 100644 crates/openshell-driver-mxc/examples/ollama.yaml create mode 100644 crates/openshell-driver-mxc/examples/run-inference-test.ps1 create mode 100644 crates/openshell-driver-mxc/examples/run-ollama-test.ps1 create mode 100644 crates/openshell-driver-mxc/tests/demo_examples.rs create mode 100644 crates/openshell-gateway/tests/mxc_demo_configs.rs diff --git a/Cargo.lock b/Cargo.lock index 431b79335a..572d0742a6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4187,6 +4187,7 @@ dependencies = [ "tokio", "tokio-stream", "tokio-tungstenite 0.26.2", + "toml", "tonic", "tracing", "uuid", diff --git a/crates/openshell-driver-mxc/Cargo.toml b/crates/openshell-driver-mxc/Cargo.toml index a85e709d4e..bfead4f202 100644 --- a/crates/openshell-driver-mxc/Cargo.toml +++ b/crates/openshell-driver-mxc/Cargo.toml @@ -58,6 +58,8 @@ openshell-policy = { path = "../openshell-policy" } base64 = { workspace = true } rustls = { workspace = true } serde_json = { workspace = true } +# Parse the shipped gateway templates in cross-platform drift guards. +toml = { workspace = true } # Used by the drift guard test (handled_fields_inventory) to parse YAML into a # generic serde_json::Value for key enumeration. serde_yml = { workspace = true } diff --git a/crates/openshell-driver-mxc/examples/README.txt b/crates/openshell-driver-mxc/examples/README.txt new file mode 100644 index 0000000000..659124f9e5 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/README.txt @@ -0,0 +1,48 @@ +OpenShell MXC Windows inference demos +==================================== + +These two demos exercise the full gateway -> MXC processContainer path: + + Local inference (Hello World) + powershell -NoProfile -ExecutionPolicy Bypass -File .\run-ollama-test.ps1 + + Cloud inference (T1) + $env:NV_API_KEY = "nvapi-..." + powershell -NoProfile -ExecutionPolicy Bypass -File .\run-inference-test.ps1 + +Prerequisites +------------- + + - Windows 11 build 26300.8553 or newer with MXC processContainer support. + - openshell-gateway.exe and openshell.exe beside these files, or explicit + -GatewayPath and -CliPath arguments. + - wxc-exec.exe beside these files, on PATH, named by + OPENSHELL_WXC_EXEC_PATH, or passed with -WxcExecPath. + - Local demo: an Ollama-compatible service on 127.0.0.1:11434 by default. + Override -OllamaHost, -OllamaPort, and -Model when needed. + - Cloud demo: NV_API_KEY and outbound HTTPS to integrate.api.nvidia.com. + +The runners use a unique temporary share directory and an available loopback +gateway port for each run. They never require C:\mxc-kit, C:\work, a fixed +gateway port, or edits to the checked-in templates. Missing prerequisites fail +before sandbox creation with a diagnostic naming the parameter or environment +variable that can supply them. + +Security model +-------------- + +Both demos use the fail-closed process_container backend. Their rendered policy +grants only the per-run share and the one requested endpoint, and no broad +AppContainer network capability is enabled. Cloud traffic traverses OpenShell's +enforcing CONNECT proxy. For local Ollama only, curl bypasses proxy variables +for the requested loopback hostname because the proxy's SSRF defense rejects +all loopback destinations; MXC still limits direct traffic to host loopback. This +inherits the driver's documented limitation that governed MXC sandboxes can +reach other host-loopback ports and must not be treated as loopback-service +isolation. The cloud key is passed with `--env-from NV_API_KEY`, is not placed in +argv or written to the results directory, and is never printed. + +Each run leaves a results-* directory containing the rendered TOML and policy, +gateway logs, response artifacts, and summary.txt. The script exits 0 only when +the sandbox creates successfully and the expected inference response is +observed. Use -KeepArtifacts to retain the temporary share for debugging. diff --git a/crates/openshell-driver-mxc/examples/inference.yaml b/crates/openshell-driver-mxc/examples/inference.yaml new file mode 100644 index 0000000000..e20f4020b8 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/inference.yaml @@ -0,0 +1,24 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Template rendered by run-inference-test.ps1 into its per-run results directory. +version: 1 + +filesystem_policy: + include_workdir: false + read_only: [] + read_write: + - "__OPENSHELL_DEMO_SHARE__" + +network_policies: + nvidia_inference: + name: nvidia-inference + endpoints: + - host: integrate.api.nvidia.com + port: 443 + protocol: rest + # Chat completions use POST. + access: read-write + enforcement: enforce + binaries: + - path: '__CMD_EXE__' diff --git a/crates/openshell-driver-mxc/examples/mxc-inference.toml b/crates/openshell-driver-mxc/examples/mxc-inference.toml new file mode 100644 index 0000000000..1c0d0274fd --- /dev/null +++ b/crates/openshell-driver-mxc/examples/mxc-inference.toml @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Gateway template for the cloud-inference (T1) demo. The runner resolves +# wxc-exec and writes a disposable copy. NV_API_KEY is supplied at sandbox +# creation with --env-from and is never stored in this file. + +[openshell] +version = 2 + +[openshell.drivers.mxc] +wxc_exec_path = "wxc-exec.exe" +backend = "process_container" +default_configuration_id = "composable" +pc_least_privilege = false +pc_capabilities = [] +egress_proxy = true +egress_proxy_addr = "127.0.0.1:0" +debug = false diff --git a/crates/openshell-driver-mxc/examples/mxc-ollama.toml b/crates/openshell-driver-mxc/examples/mxc-ollama.toml new file mode 100644 index 0000000000..8b7a693dd5 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/mxc-ollama.toml @@ -0,0 +1,21 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Gateway template for the local-inference demo. run-ollama-test.ps1 resolves +# wxc-exec and writes a disposable copy; workload command, cwd, and environment +# are sandbox-scoped and passed to `openshell sandbox create`. + +[openshell] +version = 2 + +[openshell.drivers.mxc] +wxc_exec_path = "wxc-exec.exe" +backend = "process_container" +default_configuration_id = "composable" +pc_least_privilege = false +pc_capabilities = [] +egress_proxy = true +# The address is a seed. The driver reserves a distinct ephemeral port for each +# sandbox, so this does not claim a machine-specific port. +egress_proxy_addr = "127.0.0.1:0" +debug = false diff --git a/crates/openshell-driver-mxc/examples/ollama.yaml b/crates/openshell-driver-mxc/examples/ollama.yaml new file mode 100644 index 0000000000..d1668955df --- /dev/null +++ b/crates/openshell-driver-mxc/examples/ollama.yaml @@ -0,0 +1,24 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Template rendered by run-ollama-test.ps1 into its per-run results directory. +version: 1 + +filesystem_policy: + include_workdir: false + read_only: [] + read_write: + - "__OPENSHELL_DEMO_SHARE__" + +network_policies: + local_ollama: + name: local-ollama + endpoints: + - host: "__OLLAMA_HOST__" + port: __OLLAMA_PORT__ + protocol: rest + # Both /api/tags (GET) and /api/generate (POST) are required. + access: read-write + enforcement: enforce + binaries: + - path: '__CMD_EXE__' diff --git a/crates/openshell-driver-mxc/examples/run-inference-test.ps1 b/crates/openshell-driver-mxc/examples/run-inference-test.ps1 new file mode 100644 index 0000000000..1dc08f5a5f --- /dev/null +++ b/crates/openshell-driver-mxc/examples/run-inference-test.ps1 @@ -0,0 +1,294 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Cloud inference (T1) demo for OpenShell on MXC. PowerShell 5.1 compatible. + +[CmdletBinding()] +param( + [string] $WxcExecPath, + [string] $GatewayPath, + [string] $CliPath, + [string] $ShareDir, + [string] $Model = "nvidia/nemotron-3.5-lightning-30b-a3b", + [string] $Prompt = "Say hello in exactly five words.", + [ValidateRange(0, 65535)] [int] $Port = 0, + [string] $SandboxName, + [switch] $KeepArtifacts +) + +$ErrorActionPreference = "Stop" +$PSNativeCommandUseErrorActionPreference = $false +try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch {} +$OutputEncoding = [System.Text.Encoding]::UTF8 +$here = if ($PSScriptRoot) { $PSScriptRoot } else { (Get-Location).Path } +$utf8NoBom = New-Object System.Text.UTF8Encoding($false) +$stamp = Get-Date -Format "yyyyMMdd-HHmmss" +$resultDir = Join-Path $here "results-inference-$stamp-$PID" +New-Item -ItemType Directory -Path $resultDir | Out-Null + +function Info([string]$message) { Write-Host " $message" } +function Ok([string]$message) { Write-Host "[OK] $message" -ForegroundColor Green } +function Bad([string]$message) { Write-Host "[FAIL] $message" -ForegroundColor Red } + +function Resolve-Executable([string]$explicit, [string]$leaf, [string]$environmentName) { + $candidates = New-Object System.Collections.Generic.List[string] + if (-not [string]::IsNullOrWhiteSpace($explicit)) { [void]$candidates.Add($explicit) } + if (-not [string]::IsNullOrWhiteSpace($environmentName)) { + $fromEnvironment = [Environment]::GetEnvironmentVariable($environmentName) + if (-not [string]::IsNullOrWhiteSpace($fromEnvironment)) { [void]$candidates.Add($fromEnvironment) } + } + [void]$candidates.Add((Join-Path $here $leaf)) + [void]$candidates.Add((Join-Path (Join-Path $here "bin") $leaf)) + foreach ($candidate in $candidates) { + if (Test-Path -LiteralPath $candidate -PathType Leaf) { + return [System.IO.Path]::GetFullPath($candidate) + } + } + $command = Get-Command $leaf -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($command) { return $command.Source } + if ($environmentName) { + throw "$leaf was not found. Pass -WxcExecPath, set $environmentName, place $leaf beside this script, or add it to PATH." + } + throw "$leaf was not found. Pass its path explicitly or place it beside this script." +} + +function Get-AvailablePort { + $listener = New-Object System.Net.Sockets.TcpListener([System.Net.IPAddress]::Loopback, 0) + try { + $listener.Start() + return ([System.Net.IPEndPoint]$listener.LocalEndpoint).Port + } finally { + $listener.Stop() + } +} + +function Test-Port([int]$candidate) { + $client = New-Object System.Net.Sockets.TcpClient + try { + $pending = $client.BeginConnect("127.0.0.1", $candidate, $null, $null) + if (-not $pending.AsyncWaitHandle.WaitOne(250)) { return $false } + $client.EndConnect($pending) + return $true + } catch { + return $false + } finally { + $client.Dispose() + } +} + +function Quote-NativeArgument([string]$value) { + if ($value.Length -gt 0 -and $value -notmatch '[\s"]') { return $value } + $quoted = New-Object System.Text.StringBuilder + [void]$quoted.Append('"') + $backslashes = 0 + foreach ($ch in $value.ToCharArray()) { + if ($ch -eq '\') { $backslashes++; continue } + if ($ch -eq '"') { + [void]$quoted.Append(('\' * (2 * $backslashes + 1))) + [void]$quoted.Append('"') + } else { + if ($backslashes -gt 0) { [void]$quoted.Append(('\' * $backslashes)) } + [void]$quoted.Append($ch) + } + $backslashes = 0 + } + if ($backslashes -gt 0) { [void]$quoted.Append(('\' * (2 * $backslashes))) } + [void]$quoted.Append('"') + return $quoted.ToString() +} + +function Invoke-Cli([string[]]$CommandArgs, [switch]$AllowFailure) { + $allArgs = @("--gateway-endpoint", $endpoint) + $CommandArgs + $startInfo = New-Object System.Diagnostics.ProcessStartInfo + $startInfo.FileName = $cli + $startInfo.Arguments = (($allArgs | ForEach-Object { Quote-NativeArgument $_ }) -join ' ') + $startInfo.UseShellExecute = $false + $startInfo.CreateNoWindow = $true + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + $process = New-Object System.Diagnostics.Process + $process.StartInfo = $startInfo + if (-not $process.Start()) { throw "failed to start OpenShell CLI '$cli'" } + $stdout = $process.StandardOutput.ReadToEndAsync() + $stderr = $process.StandardError.ReadToEndAsync() + $process.WaitForExit() + $text = (@($stdout.Result, $stderr.Result) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) -join [Environment]::NewLine + $text = $text.Trim() + if (-not $AllowFailure -and $process.ExitCode -ne 0) { + throw "openshell $($CommandArgs -join ' ') failed (exit $($process.ExitCode)): $text" + } + return @{ ExitCode = $process.ExitCode; Text = $text } +} + +function Write-Utf8([string]$path, [string]$contents) { + [System.IO.File]::WriteAllText($path, $contents, $utf8NoBom) +} + +$gateway = $null +$cli = $null +$wxc = $null +$gatewayProcess = $null +$createdShare = $false +$success = $false +$failure = $null +$oldGatewayConfig = $env:OPENSHELL_GATEWAY_CONFIG +$oldComputeDriver = $env:OPENSHELL_COMPUTE_DRIVER +$oldApiKey = $env:NV_API_KEY +$apiKey = $env:NV_API_KEY + +try { + if ([string]::IsNullOrWhiteSpace($apiKey)) { + throw "NV_API_KEY is missing. Set `$env:NV_API_KEY; the key is forwarded with --env-from and is never written to disk or placed in argv." + } + $gateway = Resolve-Executable $GatewayPath "openshell-gateway.exe" "" + $cli = Resolve-Executable $CliPath "openshell.exe" "" + $wxc = Resolve-Executable $WxcExecPath "wxc-exec.exe" "OPENSHELL_WXC_EXEC_PATH" + foreach ($fixture in @("mxc-inference.toml", "inference.yaml")) { + if (-not (Test-Path -LiteralPath (Join-Path $here $fixture) -PathType Leaf)) { + throw "required demo fixture '$fixture' is missing beside the runner" + } + } + if ($Port -eq 0) { $Port = Get-AvailablePort } + $endpoint = "http://127.0.0.1:$Port" + if ([string]::IsNullOrWhiteSpace($SandboxName)) { $SandboxName = "inference-$PID" } + if ([string]::IsNullOrWhiteSpace($ShareDir)) { + $ShareDir = Join-Path ([System.IO.Path]::GetTempPath()) "openshell-mxc-inference-$PID-$([Guid]::NewGuid().ToString('N'))" + $createdShare = $true + } + $ShareDir = [System.IO.Path]::GetFullPath($ShareDir).TrimEnd('\', '/') + if ($ShareDir.Contains('"') -or $ShareDir.Contains("`n") -or $ShareDir.Contains("`r")) { + throw "ShareDir contains a quote or newline and cannot be rendered safely" + } + New-Item -ItemType Directory -Path $ShareDir -Force | Out-Null + + Info "gateway: $gateway" + Info "CLI: $cli" + Info "wxc-exec: $wxc" + Info "share: $ShareDir" + Info "NV_API_KEY: present (value redacted)" + + $cmdExe = Join-Path $env:SystemRoot "System32\cmd.exe" + $curlExe = Join-Path $env:SystemRoot "System32\curl.exe" + $findStrExe = Join-Path $env:SystemRoot "System32\findstr.exe" + foreach ($systemTool in @($cmdExe, $curlExe, $findStrExe)) { + if (-not (Test-Path -LiteralPath $systemTool -PathType Leaf)) { + throw "required Windows tool was not found at '$systemTool'" + } + } + + $tomlUsed = Join-Path $resultDir "mxc-inference.used.toml" + $tomlText = [System.IO.File]::ReadAllText((Join-Path $here "mxc-inference.toml")) + $escapedWxc = $wxc.Replace('\', '\\').Replace('"', '\"') + $tomlText = $tomlText.Replace('wxc_exec_path = "wxc-exec.exe"', "wxc_exec_path = `"$escapedWxc`"") + Write-Utf8 $tomlUsed $tomlText + + $policyUsed = Join-Path $resultDir "inference.used.yaml" + $sharePolicy = $ShareDir.Replace('\', '/') + $policyText = [System.IO.File]::ReadAllText((Join-Path $here "inference.yaml")) + $policyText = $policyText.Replace("__OPENSHELL_DEMO_SHARE__", $sharePolicy) + $policyText = $policyText.Replace("__CMD_EXE__", $cmdExe) + Write-Utf8 $policyUsed $policyText + + $requestPath = Join-Path $ShareDir "inference-request.json" + $responsePath = Join-Path $ShareDir "inference-response.json" + $headersPath = Join-Path $ShareDir "inference-headers.txt" + $donePath = Join-Path $ShareDir "inference-pass.txt" + $errorPath = Join-Path $ShareDir "inference-error.txt" + $requestJson = @{ model = $Model; messages = @(@{ role = "user"; content = $Prompt }); max_tokens = 128; reasoning_budget = 64; stream = $false } | ConvertTo-Json -Compress -Depth 8 + Write-Utf8 $requestPath $requestJson + + $probePath = Join-Path $ShareDir "inference-probe.cmd" + $probeLines = @( + "@echo off", + # Inbox curl uses Schannel and does not honor CURL_CA_BUNDLE by itself. + # Point --cacert at the public bundle staged by the governed proxy. + "`"$curlExe`" --silent --show-error --fail-with-body --ssl-no-revoke --cacert `"%CURL_CA_BUNDLE%`" --max-time 120 -D `"$headersPath`" -H `"Authorization: Bearer %NV_API_KEY%`" -H `"Content-Type: application/json`" --data-binary `"@$requestPath`" -o `"$responsePath`" `"https://integrate.api.nvidia.com/v1/chat/completions`" 2> `"$errorPath`" || exit /b 31", + "`"$findStrExe`" /C:`"choices`" `"$responsePath`" >nul || exit /b 32", + "echo PASS> `"$donePath`"" + ) + Write-Utf8 $probePath ($probeLines -join "`r`n") + + $driverConfig = @{ mxc = @{ command = @($cmdExe, "/d", "/s", "/c", $probePath); cwd = $ShareDir } } | ConvertTo-Json -Compress -Depth 8 + + $gwLog = Join-Path $resultDir "gateway.log" + $gwErrLog = Join-Path $resultDir "gateway.err.log" + $env:OPENSHELL_GATEWAY_CONFIG = $tomlUsed + $env:OPENSHELL_COMPUTE_DRIVER = "mxc" + # The credential belongs to sandbox creation, not gateway configuration. + Remove-Item Env:NV_API_KEY -ErrorAction SilentlyContinue + try { + $gatewayProcess = Start-Process -FilePath $gateway -ArgumentList @("--disable-tls", "--db-url", "sqlite::memory:", "--port", "$Port", "--log-level", "info") -WorkingDirectory $here -PassThru -WindowStyle Hidden -RedirectStandardOutput $gwLog -RedirectStandardError $gwErrLog + } finally { + $env:NV_API_KEY = $apiKey + } + $deadline = (Get-Date).AddSeconds(30) + while ((Get-Date) -lt $deadline -and -not (Test-Port $Port)) { + if ($gatewayProcess.HasExited) { + $details = (Get-Content $gwLog, $gwErrLog -ErrorAction SilentlyContinue) -join "`n" + throw "gateway exited before listening: $details" + } + Start-Sleep -Milliseconds 250 + } + if (-not (Test-Port $Port)) { throw "gateway did not listen on $endpoint within 30 seconds" } + Ok "gateway is listening at $endpoint" + + $create = Invoke-Cli @("sandbox", "create", "--name", $SandboxName, "--policy", $policyUsed, "--driver-config-json", $driverConfig, "--env-from", "NV_API_KEY", "--no-tty", "--output", "json") + Ok "sandbox '$SandboxName' created" + $deadline = (Get-Date).AddSeconds(150) + while ((Get-Date) -lt $deadline -and -not (Test-Path -LiteralPath $donePath)) { + $status = Invoke-Cli @("sandbox", "get", $SandboxName, "--output", "json") -AllowFailure + if ($status.ExitCode -eq 0 -and $status.Text -match '"phase"\s*:\s*"Error"') { + $httpStatus = "unknown" + if (Test-Path -LiteralPath $headersPath) { + $statusLines = @([System.IO.File]::ReadAllLines($headersPath) | Where-Object { $_ -match '^HTTP/\S+\s+\d{3}' }) + if ($statusLines.Count -gt 0 -and $statusLines[-1] -match '^HTTP/\S+\s+(\d{3})') { $httpStatus = $Matches[1] } + } + $apiDetail = "" + if (Test-Path -LiteralPath $responsePath) { + try { + $errorResponse = [System.IO.File]::ReadAllText($responsePath) | ConvertFrom-Json + if ($errorResponse.detail) { $apiDetail = ([string]$errorResponse.detail).Trim() } + } catch {} + } + $curlDetail = if (Test-Path -LiteralPath $errorPath) { ([System.IO.File]::ReadAllText($errorPath)).Trim() } else { "no curl diagnostic was produced" } + $diagnostic = "sandbox was created, but cloud inference failed (HTTP $httpStatus): $apiDetail $curlDetail".Trim() + throw $diagnostic.Replace($apiKey, "***REDACTED***") + } + Start-Sleep -Milliseconds 500 + } + if (-not (Test-Path -LiteralPath $donePath)) { throw "sandbox did not finish cloud inference within 150 seconds" } + $response = [System.IO.File]::ReadAllText($responsePath) + $parsed = $response | ConvertFrom-Json + if (-not $parsed.choices -or $parsed.choices.Count -eq 0) { throw "cloud response did not contain a completion in 'choices'" } + Copy-Item -LiteralPath $requestPath, $responsePath, $headersPath, $probePath -Destination $resultDir -Force + $success = $true + Ok "cloud inference returned a completion from inside the sandbox" +} catch { + $failure = $_.Exception.Message + Bad $failure + foreach ($artifact in @("inference-response.json", "inference-headers.txt", "inference-error.txt")) { + $source = if ($ShareDir) { Join-Path $ShareDir $artifact } else { $null } + if ($source -and (Test-Path -LiteralPath $source)) { Copy-Item -LiteralPath $source -Destination $resultDir -Force -ErrorAction SilentlyContinue } + } +} finally { + if ($cli -and $endpoint -and $SandboxName) { + try { [void](Invoke-Cli @("sandbox", "delete", $SandboxName) -AllowFailure) } catch {} + } + if ($gatewayProcess -and -not $gatewayProcess.HasExited) { + Stop-Process -Id $gatewayProcess.Id -Force -ErrorAction SilentlyContinue + try { [void]$gatewayProcess.WaitForExit(5000) } catch {} + } + $env:OPENSHELL_GATEWAY_CONFIG = $oldGatewayConfig + $env:OPENSHELL_COMPUTE_DRIVER = $oldComputeDriver + if ([string]::IsNullOrWhiteSpace($oldApiKey)) { Remove-Item Env:NV_API_KEY -ErrorAction SilentlyContinue } else { $env:NV_API_KEY = $oldApiKey } + if (-not $KeepArtifacts -and $createdShare -and $ShareDir -and (Test-Path -LiteralPath $ShareDir)) { + Remove-Item -LiteralPath $ShareDir -Recurse -Force -ErrorAction SilentlyContinue + } +} + +$verdict = if ($success) { "PASS" } else { "FAIL" } +$summary = "verdict=$verdict`r`nbase=cloud-inference`r`nsandbox=$SandboxName`r`ngateway=$endpoint`r`nbackend=process_container`r`nresult=$failure`r`n" +Write-Utf8 (Join-Path $resultDir "summary.txt") $summary +Write-Host "`n$summary" +Write-Host "Results: $resultDir" +if ($success) { exit 0 } else { exit 1 } diff --git a/crates/openshell-driver-mxc/examples/run-ollama-test.ps1 b/crates/openshell-driver-mxc/examples/run-ollama-test.ps1 new file mode 100644 index 0000000000..59c5bbcbb6 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/run-ollama-test.ps1 @@ -0,0 +1,282 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Hello World local-inference demo for OpenShell on MXC. PowerShell 5.1 compatible. + +[CmdletBinding()] +param( + [string] $WxcExecPath, + [string] $GatewayPath, + [string] $CliPath, + [string] $ShareDir, + [string] $OllamaHost = "127.0.0.1", + [ValidateRange(1, 65535)] [int] $OllamaPort = 11434, + [string] $Model = "qwen3.5:0.8b", + [string] $Prompt = "Say hello in exactly five words.", + [ValidateRange(0, 65535)] [int] $Port = 0, + [string] $SandboxName, + [switch] $KeepArtifacts +) + +$ErrorActionPreference = "Stop" +$PSNativeCommandUseErrorActionPreference = $false +try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch {} +$OutputEncoding = [System.Text.Encoding]::UTF8 +$here = if ($PSScriptRoot) { $PSScriptRoot } else { (Get-Location).Path } +$utf8NoBom = New-Object System.Text.UTF8Encoding($false) +$stamp = Get-Date -Format "yyyyMMdd-HHmmss" +$resultDir = Join-Path $here "results-ollama-$stamp-$PID" +New-Item -ItemType Directory -Path $resultDir | Out-Null + +function Info([string]$message) { Write-Host " $message" } +function Ok([string]$message) { Write-Host "[OK] $message" -ForegroundColor Green } +function Bad([string]$message) { Write-Host "[FAIL] $message" -ForegroundColor Red } + +function Resolve-Executable([string]$explicit, [string]$leaf, [string]$environmentName) { + $candidates = New-Object System.Collections.Generic.List[string] + if (-not [string]::IsNullOrWhiteSpace($explicit)) { [void]$candidates.Add($explicit) } + if (-not [string]::IsNullOrWhiteSpace($environmentName)) { + $fromEnvironment = [Environment]::GetEnvironmentVariable($environmentName) + if (-not [string]::IsNullOrWhiteSpace($fromEnvironment)) { [void]$candidates.Add($fromEnvironment) } + } + [void]$candidates.Add((Join-Path $here $leaf)) + [void]$candidates.Add((Join-Path (Join-Path $here "bin") $leaf)) + foreach ($candidate in $candidates) { + if (Test-Path -LiteralPath $candidate -PathType Leaf) { + return [System.IO.Path]::GetFullPath($candidate) + } + } + $command = Get-Command $leaf -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($command) { return $command.Source } + if ($environmentName) { + throw "$leaf was not found. Pass -WxcExecPath, set $environmentName, place $leaf beside this script, or add it to PATH." + } + throw "$leaf was not found. Pass its path explicitly or place it beside this script." +} + +function Get-AvailablePort { + $listener = New-Object System.Net.Sockets.TcpListener([System.Net.IPAddress]::Loopback, 0) + try { + $listener.Start() + return ([System.Net.IPEndPoint]$listener.LocalEndpoint).Port + } finally { + $listener.Stop() + } +} + +function Test-Port([int]$candidate) { + $client = New-Object System.Net.Sockets.TcpClient + try { + $pending = $client.BeginConnect("127.0.0.1", $candidate, $null, $null) + if (-not $pending.AsyncWaitHandle.WaitOne(250)) { return $false } + $client.EndConnect($pending) + return $true + } catch { + return $false + } finally { + $client.Dispose() + } +} + +function Quote-NativeArgument([string]$value) { + if ($value.Length -gt 0 -and $value -notmatch '[\s"]') { return $value } + $quoted = New-Object System.Text.StringBuilder + [void]$quoted.Append('"') + $backslashes = 0 + foreach ($ch in $value.ToCharArray()) { + if ($ch -eq '\') { $backslashes++; continue } + if ($ch -eq '"') { + [void]$quoted.Append(('\' * (2 * $backslashes + 1))) + [void]$quoted.Append('"') + } else { + if ($backslashes -gt 0) { [void]$quoted.Append(('\' * $backslashes)) } + [void]$quoted.Append($ch) + } + $backslashes = 0 + } + if ($backslashes -gt 0) { [void]$quoted.Append(('\' * (2 * $backslashes))) } + [void]$quoted.Append('"') + return $quoted.ToString() +} + +function Invoke-Cli([string[]]$CommandArgs, [switch]$AllowFailure) { + $allArgs = @("--gateway-endpoint", $endpoint) + $CommandArgs + $startInfo = New-Object System.Diagnostics.ProcessStartInfo + $startInfo.FileName = $cli + $startInfo.Arguments = (($allArgs | ForEach-Object { Quote-NativeArgument $_ }) -join ' ') + $startInfo.UseShellExecute = $false + $startInfo.CreateNoWindow = $true + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + $process = New-Object System.Diagnostics.Process + $process.StartInfo = $startInfo + if (-not $process.Start()) { throw "failed to start OpenShell CLI '$cli'" } + $stdout = $process.StandardOutput.ReadToEndAsync() + $stderr = $process.StandardError.ReadToEndAsync() + $process.WaitForExit() + $text = (@($stdout.Result, $stderr.Result) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) -join [Environment]::NewLine + $text = $text.Trim() + if (-not $AllowFailure -and $process.ExitCode -ne 0) { + throw "openshell $($CommandArgs -join ' ') failed (exit $($process.ExitCode)): $text" + } + return @{ ExitCode = $process.ExitCode; Text = $text } +} + +function Write-Utf8([string]$path, [string]$contents) { + [System.IO.File]::WriteAllText($path, $contents, $utf8NoBom) +} + +$gateway = $null +$cli = $null +$wxc = $null +$gatewayProcess = $null +$createdShare = $false +$success = $false +$failure = $null +$oldGatewayConfig = $env:OPENSHELL_GATEWAY_CONFIG +$oldComputeDriver = $env:OPENSHELL_COMPUTE_DRIVER + +try { + $gateway = Resolve-Executable $GatewayPath "openshell-gateway.exe" "" + $cli = Resolve-Executable $CliPath "openshell.exe" "" + $wxc = Resolve-Executable $WxcExecPath "wxc-exec.exe" "OPENSHELL_WXC_EXEC_PATH" + foreach ($fixture in @("mxc-ollama.toml", "ollama.yaml")) { + if (-not (Test-Path -LiteralPath (Join-Path $here $fixture) -PathType Leaf)) { + throw "required demo fixture '$fixture' is missing beside the runner" + } + } + if ($OllamaHost -notmatch '^[A-Za-z0-9.-]+$') { + throw "OllamaHost '$OllamaHost' is invalid; use an IPv4 address or DNS name" + } + if ($Port -eq 0) { $Port = Get-AvailablePort } + $endpoint = "http://127.0.0.1:$Port" + if ([string]::IsNullOrWhiteSpace($SandboxName)) { $SandboxName = "ollama-$PID" } + if ([string]::IsNullOrWhiteSpace($ShareDir)) { + $ShareDir = Join-Path ([System.IO.Path]::GetTempPath()) "openshell-mxc-ollama-$PID-$([Guid]::NewGuid().ToString('N'))" + $createdShare = $true + } + $ShareDir = [System.IO.Path]::GetFullPath($ShareDir).TrimEnd('\', '/') + if ($ShareDir.Contains('"') -or $ShareDir.Contains("`n") -or $ShareDir.Contains("`r")) { + throw "ShareDir contains a quote or newline and cannot be rendered safely" + } + New-Item -ItemType Directory -Path $ShareDir -Force | Out-Null + + Info "gateway: $gateway" + Info "CLI: $cli" + Info "wxc-exec: $wxc" + Info "share: $ShareDir" + Info "Ollama: http://${OllamaHost}:$OllamaPort" + + try { + $probe = Invoke-WebRequest -UseBasicParsing -Uri "http://${OllamaHost}:$OllamaPort/api/tags" -TimeoutSec 8 + if ([int]$probe.StatusCode -ne 200) { throw "HTTP $($probe.StatusCode)" } + } catch { + throw "Ollama prerequisite is unavailable at http://${OllamaHost}:$OllamaPort/api/tags. Start Ollama or pass -OllamaHost/-OllamaPort. $($_.Exception.Message)" + } + Ok "host-side Ollama prerequisite returned HTTP 200" + + $cmdExe = Join-Path $env:SystemRoot "System32\cmd.exe" + $curlExe = Join-Path $env:SystemRoot "System32\curl.exe" + $findStrExe = Join-Path $env:SystemRoot "System32\findstr.exe" + foreach ($systemTool in @($cmdExe, $curlExe, $findStrExe)) { + if (-not (Test-Path -LiteralPath $systemTool -PathType Leaf)) { + throw "required Windows tool was not found at '$systemTool'" + } + } + + $tomlUsed = Join-Path $resultDir "mxc-ollama.used.toml" + $tomlText = [System.IO.File]::ReadAllText((Join-Path $here "mxc-ollama.toml")) + $escapedWxc = $wxc.Replace('\', '\\').Replace('"', '\"') + $tomlText = $tomlText.Replace('wxc_exec_path = "wxc-exec.exe"', "wxc_exec_path = `"$escapedWxc`"") + Write-Utf8 $tomlUsed $tomlText + + $policyUsed = Join-Path $resultDir "ollama.used.yaml" + $sharePolicy = $ShareDir.Replace('\', '/') + $policyText = [System.IO.File]::ReadAllText((Join-Path $here "ollama.yaml")) + $policyText = $policyText.Replace("__OPENSHELL_DEMO_SHARE__", $sharePolicy) + $policyText = $policyText.Replace("__OLLAMA_HOST__", $OllamaHost) + $policyText = $policyText.Replace("__OLLAMA_PORT__", [string]$OllamaPort) + $policyText = $policyText.Replace("__CMD_EXE__", $cmdExe) + Write-Utf8 $policyUsed $policyText + + $requestPath = Join-Path $ShareDir "ollama-request.json" + $responsePath = Join-Path $ShareDir "ollama-response.json" + $tagsPath = Join-Path $ShareDir "ollama-tags.json" + $donePath = Join-Path $ShareDir "ollama-pass.txt" + $errorPath = Join-Path $ShareDir "ollama-error.txt" + $requestJson = @{ model = $Model; prompt = $Prompt; stream = $false } | ConvertTo-Json -Compress + Write-Utf8 $requestPath $requestJson + + $probePath = Join-Path $ShareDir "ollama-probe.cmd" + $probeLines = @( + "@echo off", + # MXC's governed path permits only host loopback at the OS boundary. The + # CONNECT proxy deliberately blocks loopback as SSRF, so this local-only + # demo bypasses proxy variables for precisely the requested Ollama host. + "`"$curlExe`" --noproxy `"$OllamaHost`" --silent --show-error --fail --max-time 15 -o `"$tagsPath`" `"http://${OllamaHost}:$OllamaPort/api/tags`" 2> `"$errorPath`" || exit /b 21", + "`"$curlExe`" --noproxy `"$OllamaHost`" --silent --show-error --fail --max-time 120 -H `"Content-Type: application/json`" --data-binary `"@$requestPath`" -o `"$responsePath`" `"http://${OllamaHost}:$OllamaPort/api/generate`" 2>> `"$errorPath`" || exit /b 22", + "`"$findStrExe`" /C:`"response`" `"$responsePath`" >nul || exit /b 23", + "echo PASS> `"$donePath`"" + ) + Write-Utf8 $probePath ($probeLines -join "`r`n") + + $driverConfig = @{ mxc = @{ command = @($cmdExe, "/d", "/s", "/c", $probePath); cwd = $ShareDir } } | ConvertTo-Json -Compress -Depth 8 + + $gwLog = Join-Path $resultDir "gateway.log" + $gwErrLog = Join-Path $resultDir "gateway.err.log" + $env:OPENSHELL_GATEWAY_CONFIG = $tomlUsed + $env:OPENSHELL_COMPUTE_DRIVER = "mxc" + $gatewayProcess = Start-Process -FilePath $gateway -ArgumentList @("--disable-tls", "--db-url", "sqlite::memory:", "--port", "$Port", "--log-level", "info") -WorkingDirectory $here -PassThru -WindowStyle Hidden -RedirectStandardOutput $gwLog -RedirectStandardError $gwErrLog + $deadline = (Get-Date).AddSeconds(30) + while ((Get-Date) -lt $deadline -and -not (Test-Port $Port)) { + if ($gatewayProcess.HasExited) { + $details = (Get-Content $gwLog, $gwErrLog -ErrorAction SilentlyContinue) -join "`n" + throw "gateway exited before listening: $details" + } + Start-Sleep -Milliseconds 250 + } + if (-not (Test-Port $Port)) { throw "gateway did not listen on $endpoint within 30 seconds" } + Ok "gateway is listening at $endpoint" + + $create = Invoke-Cli @("sandbox", "create", "--name", $SandboxName, "--policy", $policyUsed, "--driver-config-json", $driverConfig, "--no-tty", "--output", "json") + Ok "sandbox '$SandboxName' created" + $deadline = (Get-Date).AddSeconds(150) + while ((Get-Date) -lt $deadline -and -not (Test-Path -LiteralPath $donePath)) { + $status = Invoke-Cli @("sandbox", "get", $SandboxName, "--output", "json") -AllowFailure + if ($status.ExitCode -eq 0 -and $status.Text -match '"phase"\s*:\s*"Error"') { + $probeError = if (Test-Path -LiteralPath $errorPath) { ([System.IO.File]::ReadAllText($errorPath)).Trim() } else { "no curl diagnostic was produced" } + throw "sandbox was created, but local inference failed: $probeError" + } + Start-Sleep -Milliseconds 500 + } + if (-not (Test-Path -LiteralPath $donePath)) { throw "sandbox did not finish local inference within 150 seconds" } + $response = [System.IO.File]::ReadAllText($responsePath) + $parsed = $response | ConvertFrom-Json + if ([string]::IsNullOrWhiteSpace([string]$parsed.response)) { throw "Ollama response did not contain a nonempty 'response' field" } + Copy-Item -LiteralPath $requestPath, $responsePath, $tagsPath, $probePath -Destination $resultDir -Force + $success = $true + Ok "local inference returned a completion from inside the sandbox" +} catch { + $failure = $_.Exception.Message + Bad $failure +} finally { + if ($cli -and $endpoint -and $SandboxName) { + try { [void](Invoke-Cli @("sandbox", "delete", $SandboxName) -AllowFailure) } catch {} + } + if ($gatewayProcess -and -not $gatewayProcess.HasExited) { + Stop-Process -Id $gatewayProcess.Id -Force -ErrorAction SilentlyContinue + try { [void]$gatewayProcess.WaitForExit(5000) } catch {} + } + $env:OPENSHELL_GATEWAY_CONFIG = $oldGatewayConfig + $env:OPENSHELL_COMPUTE_DRIVER = $oldComputeDriver + if (-not $KeepArtifacts -and $createdShare -and $ShareDir -and (Test-Path -LiteralPath $ShareDir)) { + Remove-Item -LiteralPath $ShareDir -Recurse -Force -ErrorAction SilentlyContinue + } +} + +$verdict = if ($success) { "PASS" } else { "FAIL" } +$summary = "verdict=$verdict`r`nbase=local-ollama`r`nsandbox=$SandboxName`r`ngateway=$endpoint`r`nbackend=process_container`r`nresult=$failure`r`n" +Write-Utf8 (Join-Path $resultDir "summary.txt") $summary +Write-Host "`n$summary" +Write-Host "Results: $resultDir" +if ($success) { exit 0 } else { exit 1 } diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index 01a679eb4b..a2192d36ff 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -2550,6 +2550,8 @@ mod lifecycle_tests { NetworkMiddlewareConfig, NetworkPolicyRule, SandboxPolicy, StaticCredentialBinding, StaticCredentialEndpointBinding, UiClipboardAccess, UiPolicy, }; + use openshell_policy::parse_sandbox_policy; + use std::path::Path; use std::time::Duration; #[test] @@ -2706,6 +2708,80 @@ mod lifecycle_tests { None } + fn shipped_demo_config(name: &str) -> MxcComputeConfig { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("examples") + .join(name); + let source = std::fs::read_to_string(&path).expect("read shipped demo config"); + let document: toml::Value = toml::from_str(&source).expect("parse shipped demo config"); + document["openshell"]["drivers"]["mxc"] + .clone() + .try_into() + .expect("deserialize shipped MXC driver config") + } + + fn shipped_demo_policy(name: &str, share: &str) -> SandboxPolicy { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("examples") + .join(name); + let rendered = std::fs::read_to_string(&path) + .expect("read shipped demo policy") + .replace("__OPENSHELL_DEMO_SHARE__", share) + .replace("__OLLAMA_HOST__", "127.0.0.1") + .replace("__OLLAMA_PORT__", "11434") + .replace("__CMD_EXE__", r"C:\Windows\System32\cmd.exe"); + parse_sandbox_policy(&rendered).expect("parse rendered shipped demo policy") + } + + #[tokio::test] + async fn shipped_inference_examples_create_process_container_sandboxes() { + for (index, (config_name, policy_name)) in [ + ("mxc-ollama.toml", "ollama.yaml"), + ("mxc-inference.toml", "inference.yaml"), + ] + .into_iter() + .enumerate() + { + let tmp = tempfile::tempdir().unwrap(); + let share = tmp.path().to_string_lossy().replace('\\', "/"); + let proof = format!("{share}/demo-created.txt"); + let sandbox_name = format!("shipped-demo-{index}"); + let cmd = vec![ + r"C:\Windows\System32\cmd.exe".into(), + "/d".into(), + "/c".into(), + format!(r#"echo PASS>"{proof}""#), + ]; + let config = shipped_demo_config(config_name); + assert_eq!(config.backend, MxcBackend::ProcessContainer); + assert!(config.egress_proxy); + let backend = MxcComputeBackend::new_mocked(config); + let policy = shipped_demo_policy(policy_name, &share); + let sandbox = with_policy( + driver_sandbox_with_command(&sandbox_name, &share, cmd), + policy, + ); + + backend + .create_sandbox(&sandbox) + .await + .unwrap_or_else(|error| panic!("{config_name} create failed: {error}")); + let completed = wait_for(&backend, &sandbox_name, |sandbox| { + ready_condition(sandbox) + .is_some_and(|condition| condition.reason == "AgentCompleted") + }) + .await; + assert!( + completed.is_some(), + "{config_name} did not reach Ready/AgentCompleted" + ); + assert!( + tmp.path().join("demo-created.txt").is_file(), + "{config_name} did not run its in-policy workload" + ); + } + } + #[test] fn mxc_config_defaults_to_default_deny_process_container() { let config = MxcComputeConfig::default(); diff --git a/crates/openshell-driver-mxc/tests/demo_examples.rs b/crates/openshell-driver-mxc/tests/demo_examples.rs new file mode 100644 index 0000000000..11e72e688d --- /dev/null +++ b/crates/openshell-driver-mxc/tests/demo_examples.rs @@ -0,0 +1,185 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Drift guards for the two shipped Windows inference demos. + +use std::path::{Path, PathBuf}; + +use openshell_policy::{parse_sandbox_policy, validate_sandbox_policy}; +use toml::Value; + +fn examples_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("examples") +} + +#[test] +fn shipped_inference_demo_assets_are_present() { + let root = examples_root(); + for name in [ + "mxc-ollama.toml", + "ollama.yaml", + "run-ollama-test.ps1", + "mxc-inference.toml", + "inference.yaml", + "run-inference-test.ps1", + ] { + let path = root.join(name); + assert!( + path.is_file(), + "shipped demo asset is missing: {}", + path.display() + ); + } +} + +fn read_example(name: &str) -> String { + let path = examples_root().join(name); + std::fs::read_to_string(&path) + .unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display())) +} + +#[test] +fn shipped_inference_configs_declare_required_mxc_settings() { + for name in ["mxc-ollama.toml", "mxc-inference.toml"] { + let source = read_example(name); + let parsed: Value = toml::from_str(&source) + .unwrap_or_else(|error| panic!("failed to parse {name}: {error}")); + let openshell = parsed + .get("openshell") + .and_then(Value::as_table) + .unwrap_or_else(|| panic!("{name} is missing [openshell]")); + assert_eq!( + openshell.get("version").and_then(Value::as_integer), + Some(2), + "{name} must use schema version 2" + ); + let mxc = openshell + .get("drivers") + .and_then(Value::as_table) + .and_then(|drivers| drivers.get("mxc")) + .and_then(Value::as_table) + .unwrap_or_else(|| panic!("{name} is missing [openshell.drivers.mxc]")); + assert_eq!( + mxc.get("backend").and_then(Value::as_str), + Some("process_container"), + "{name} must use the filesystem-policy-capable backend" + ); + assert_eq!(mxc.get("egress_proxy").and_then(Value::as_bool), Some(true)); + assert!( + matches!( + mxc.get("egress_proxy_addr").and_then(Value::as_str), + Some(address) if address.starts_with("127.0.0.1:") + ), + "{name} must route network policy through governed loopback egress" + ); + for removed in ["share_dir", "agent_cwd", "agent_command", "agent_env"] { + assert!( + !mxc.contains_key(removed), + "{name} contains removed gateway field {removed}" + ); + } + } +} + +#[test] +fn shipped_inference_policies_are_narrow_and_valid_after_rendering() { + let share = if cfg!(windows) { + "C:/portable/demo" + } else { + "/portable/demo" + }; + let fixtures = [ + ("ollama.yaml", "local_ollama", "127.0.0.1", share), + ( + "inference.yaml", + "nvidia_inference", + "integrate.api.nvidia.com", + share, + ), + ]; + for (name, rule_name, endpoint, share) in fixtures { + let rendered = read_example(name) + .replace("__OPENSHELL_DEMO_SHARE__", share) + .replace("__OLLAMA_HOST__", "127.0.0.1") + .replace("__OLLAMA_PORT__", "11434") + .replace("__CMD_EXE__", r"C:\Windows\System32\cmd.exe"); + let policy = parse_sandbox_policy(&rendered) + .unwrap_or_else(|error| panic!("failed to parse rendered {name}: {error}")); + validate_sandbox_policy(&policy) + .unwrap_or_else(|error| panic!("rendered {name} is invalid: {error:?}")); + let filesystem = policy + .filesystem + .as_ref() + .unwrap_or_else(|| panic!("{name} must have a filesystem policy")); + assert_eq!(filesystem.read_write, vec![share]); + assert_eq!(policy.network_policies.len(), 1); + let rule = policy + .network_policies + .get(rule_name) + .unwrap_or_else(|| panic!("{name} is missing rule {rule_name}")); + assert_eq!(rule.endpoints.len(), 1); + assert_eq!(rule.endpoints[0].host, endpoint); + assert_eq!(rule.binaries.len(), 1); + assert_eq!( + rule.binaries[0].path.to_ascii_lowercase(), + r"c:\windows\system32\cmd.exe" + ); + } +} + +#[test] +fn shipped_runners_supply_sandbox_scoped_workload_configuration() { + for name in ["run-ollama-test.ps1", "run-inference-test.ps1"] { + let source = read_example(name); + assert!( + source.contains("--driver-config-json"), + "{name} must pass command/cwd at sandbox creation" + ); + assert!( + !source.contains("C:\\mxc-kit"), + "{name} must not pin wxc-exec to a machine path" + ); + assert!( + !source.contains("C:\\work"), + "{name} must not pin its share to a machine path" + ); + assert!( + !source.contains("17670"), + "{name} must not pin the gateway to the historical fixed port" + ); + assert!(!source.contains("isolation_session")); + } + let cloud = read_example("run-inference-test.ps1"); + assert!(cloud.contains("--env-from")); + assert!(cloud.contains("NV_API_KEY")); + assert!(!cloud.contains("[string] $ApiKey")); + assert!(!cloud.contains("pass -ApiKey")); + assert!(cloud.contains("nvidia/nemotron-3.5-lightning-30b-a3b")); + assert!(!cloud.contains("nvidia/nvidia-nemotron-nano-9b-v2")); +} + +#[cfg(target_os = "windows")] +#[test] +fn shipped_runners_parse_in_windows_powershell() { + for name in ["run-ollama-test.ps1", "run-inference-test.ps1"] { + let path = examples_root().join(name); + let script = r" +$errors = $null +[void][System.Management.Automation.Language.Parser]::ParseFile($env:OPENSHELL_DEMO_SCRIPT_TO_PARSE, [ref]$null, [ref]$errors) +if ($errors.Count -gt 0) { + $errors | ForEach-Object { [Console]::Error.WriteLine($_.Message) } + exit 1 +} +"; + let output = std::process::Command::new("powershell.exe") + .args(["-NoProfile", "-Command", script]) + .env("OPENSHELL_DEMO_SCRIPT_TO_PARSE", &path) + .output() + .unwrap_or_else(|error| panic!("failed to launch PowerShell for {name}: {error}")); + assert!( + output.status.success(), + "{name} has PowerShell syntax errors:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + } +} diff --git a/crates/openshell-gateway/tests/mxc_demo_configs.rs b/crates/openshell-gateway/tests/mxc_demo_configs.rs new file mode 100644 index 0000000000..a3124ddbaa --- /dev/null +++ b/crates/openshell-gateway/tests/mxc_demo_configs.rs @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#[cfg(target_os = "windows")] +#[test] +fn shipped_mxc_inference_configs_pass_gateway_preflight() { + let examples = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../openshell-driver-mxc/examples"); + + for name in ["mxc-ollama.toml", "mxc-inference.toml"] { + let path = examples.join(name); + let output = std::process::Command::new(env!("CARGO_BIN_EXE_openshell-gateway")) + .args(["config", "preflight", "--path"]) + .arg(&path) + .output() + .unwrap_or_else(|error| panic!("failed to run gateway preflight for {name}: {error}")); + + assert!( + output.status.success(), + "gateway preflight rejected {name}:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } +} From 3a4d4f98f7930bf99aa452138f2b33af227f1820 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas <34834085+prekshivyas@users.noreply.github.com> Date: Mon, 21 Sep 2026 20:35:15 -0700 Subject: [PATCH 07/42] test(mxc): parse OpenClaw config schema portably (#3536) Signed-off-by: Prekshi Vyas (cherry picked from commit 3a0efc2d630bc98da297f0fef54ef99f8125662a) # Conflicts: # crates/openshell-driver-mxc/Cargo.toml --- .../tests/openclaw_appcontainer_compat.rs | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/crates/openshell-driver-mxc/tests/openclaw_appcontainer_compat.rs b/crates/openshell-driver-mxc/tests/openclaw_appcontainer_compat.rs index 609de6ee05..c7152b7e4f 100644 --- a/crates/openshell-driver-mxc/tests/openclaw_appcontainer_compat.rs +++ b/crates/openshell-driver-mxc/tests/openclaw_appcontainer_compat.rs @@ -100,11 +100,21 @@ fn runner_uses_lifecycle_readiness_and_preserves_failure_diagnostics() { #[test] fn openclaw_gateway_configs_declare_the_current_schema() { - for config in [ - PROCESS_CONTAINER_CONFIG, - ISOLATION_CONFIG, - LOCAL_NETWORK_CONFIG, + for (name, config) in [ + ("process-container", PROCESS_CONTAINER_CONFIG), + ("isolation", ISOLATION_CONFIG), + ("local-network", LOCAL_NETWORK_CONFIG), ] { - assert!(config.contains("[openshell]\nversion = 2")); + let config = config + .parse::() + .unwrap_or_else(|error| panic!("{name} config must be valid TOML: {error}")); + assert_eq!( + config + .get("openshell") + .and_then(|openshell| openshell.get("version")) + .and_then(toml::Value::as_integer), + Some(2), + "{name} config must declare the current OpenShell schema", + ); } } From 207eb562422a3436d4e5dff9ecbdaf7a35a763b3 Mon Sep 17 00:00:00 2001 From: pkhodade-NV <84895267+pkhodade-NV@users.noreply.github.com> Date: Tue, 22 Sep 2026 09:07:11 +0530 Subject: [PATCH 08/42] fix(mxc): stage egress-proxy CA files regardless of env tier (#3496) * fix(mxc): stage egress-proxy CA files regardless of env tier stage_tls_ca_files was only invoked when pc_minimal_env was set, but a curated ProcessContainer can't read the host proxy's private temp folder under any env tier. With the default pc_minimal_env=false, the CA env vars pointed at a path the sandboxed process couldn't read at all, breaking TLS validation for allow-listed HTTPS requests through the egress proxy. Extract the staging decision into resolve_agent_proxy_ca_paths, which takes no env-tier argument, so the gap can't silently regress; existing tests never exercised this path since mocked invokers skip host-proxy startup entirely. Signed-off-by: Prashant Khodade (cherry picked from commit d0346af96aef9a93aca26a7e1825d8678a25567d) * fix(mxc): complete proxy CA staging isolation (#3535) * test(mxc): provide workload dir for CA staging Signed-off-by: Prekshi Vyas * fix(mxc): isolate staged proxy CA files Signed-off-by: Prekshi Vyas --------- Signed-off-by: Prekshi Vyas Signed-off-by: Prekshi Vyas --------- Signed-off-by: Prashant Khodade Signed-off-by: Prekshi Vyas Signed-off-by: Prekshi Vyas Co-authored-by: Prekshi Vyas <34834085+prekshivyas@users.noreply.github.com> --- crates/openshell-driver-mxc/README.md | 10 +- crates/openshell-driver-mxc/src/driver.rs | 214 +++++++++++++++------- 2 files changed, 149 insertions(+), 75 deletions(-) diff --git a/crates/openshell-driver-mxc/README.md b/crates/openshell-driver-mxc/README.md index 7f595bf19c..81511deabd 100644 --- a/crates/openshell-driver-mxc/README.md +++ b/crates/openshell-driver-mxc/README.md @@ -163,11 +163,11 @@ command as the static sandbox process identity because MXC does not expose Linux-style procfs socket ownership. For HTTPS L7 inspection, the host proxy generates a per-sandbox CA and injects `NODE_EXTRA_CA_CERTS`, `DENO_CERT`, `SSL_CERT_FILE`, `REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`, and `GIT_SSL_CAINFO` into the agent -process env. In curated-environment mode, the driver stages the public CA files -under the authorized `/.openshell-proxy/` directory. Other -environment modes grant the sandbox's unique public-CA directory as an internal -read-write share. The directory contains only public CA certificates; -the ephemeral CA private key remains in the host proxy's memory. The driver +process env. In every environment mode, the driver stages the public CA files +under the authorized `/.openshell-proxy/` directory. The host +proxy's private temporary directory is never added to the sandbox's read-write +shares. The staged directory contains only public CA certificates; the ephemeral +CA private key remains in the host proxy's memory. The driver seeds only `SYSTEMROOT`, `WINDIR`, `PATH`, `COMSPEC`, and `LOCALAPPDATA` from the gateway host before applying sandbox and TLS overrides, so required Windows bootstrap values remain available without exposing the gateway's full diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index a2192d36ff..8cf5e17a47 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -693,6 +693,25 @@ fn stage_tls_ca_files( Ok(Some((staged_ca, staged_bundle))) } +/// Resolve the CA paths to hand the sandboxed agent for TLS trust. +/// +/// A curated `ProcessContainer` cannot read the host proxy's private temp +/// folder, regardless of env tier -- stage only the public CA material +/// beneath `share_dir`, whose `AppContainer` DACL is already granted by the +/// policy, so HTTPS clients can authenticate the `OpenShell` inspection proxy +/// without broadening filesystem access. Staging must happen whenever a +/// host proxy CA exists at all, independent of `pc_minimal_env`. +fn resolve_agent_proxy_ca_paths( + host_proxy_ca_paths: Option<&(PathBuf, PathBuf)>, + share_dir: &str, + sandbox_id: &str, +) -> std::io::Result> { + if host_proxy_ca_paths.is_none() { + return Ok(None); + } + stage_tls_ca_files(host_proxy_ca_paths, share_dir, sandbox_id) +} + /// PROTOTYPE (2026-09-10): env-var-based governed egress, as an alternative /// to MXC's own `network.proxy`/`runtimeConfig.networkProxy` transparent /// redirect (both confirmed broken for this driver's use case -- see @@ -850,25 +869,6 @@ fn quote_windows_argument(arg: &str) -> String { quoted.push('"'); quoted } -fn append_tls_readwrite_grant( - readwrite_paths: &mut Vec, - ca_paths: Option<&(PathBuf, PathBuf)>, -) { - let Some((ca_cert_path, _)) = ca_paths else { - return; - }; - let Some(dir) = ca_cert_path.parent() else { - return; - }; - let dir = dir.display().to_string(); - if !readwrite_paths - .iter() - .any(|existing| existing.eq_ignore_ascii_case(&dir)) - { - readwrite_paths.push(dir); - } -} - impl MxcComputeBackend { pub fn new(config: MxcComputeConfig) -> Self { let invoker = WxcExecInvoker::new(&config.wxc_exec_path, config.debug); @@ -1608,31 +1608,23 @@ async fn run_lifecycle( let host_proxy_ca_paths = host_proxy .as_ref() .and_then(openshell_supervisor_network::host::HostProxyHandle::ca_file_paths); - // A curated ProcessContainer cannot read the host's private temp folder. - // Stage only the public CA material beneath the per-sandbox working directory, - // DACL is already granted by the policy, so HTTPS clients can authenticate - // the OpenShell inspection proxy without broadening filesystem access. - let agent_proxy_ca_paths = if config.pc_minimal_env && host_proxy_ca_paths.is_some() { - match stage_tls_ca_files( - host_proxy_ca_paths.as_ref(), - &sandbox_config.cwd, - &sandbox_id, - ) { - Ok(paths) => paths, - Err(error) => { - set_failed( - ®istry, - &watch_tx, - &sandbox, - &sandbox_id, - &format!("failed to stage MXC egress proxy CA files: {error}"), - ) - .await; - return; - } + let agent_proxy_ca_paths = match resolve_agent_proxy_ca_paths( + host_proxy_ca_paths.as_ref(), + &sandbox_config.cwd, + &sandbox_id, + ) { + Ok(paths) => paths, + Err(error) => { + set_failed( + ®istry, + &watch_tx, + &sandbox, + &sandbox_id, + &format!("failed to stage MXC egress proxy CA files: {error}"), + ) + .await; + return; } - } else { - host_proxy_ca_paths.clone() }; if let Some(addr) = proxy_addr { { @@ -1650,10 +1642,7 @@ async fn run_lifecycle( )); } - let mut readwrite_paths = mapped.readwrite_paths; - if !config.pc_minimal_env { - append_tls_readwrite_grant(&mut readwrite_paths, host_proxy_ca_paths.as_ref()); - } + let readwrite_paths = mapped.readwrite_paths; let readonly_paths = mapped.readonly_paths; let ui = mapped.ui; let filesystem = MxcFilesystem { @@ -1689,8 +1678,8 @@ async fn run_lifecycle( .map(|(k, v)| format!("{k}={v}")) .collect(); append_provider_child_env(&mut env, provider_credentials.as_ref()); - // Layer proxy configuration for every env tier. Curated ProcessContainers - // use the staged CA copies above; other tiers use the original paths. + // Layer proxy configuration for every env tier using the staged CA copies + // above. The host proxy's private temporary directory is never shared. append_tls_env_vars(&mut env, agent_proxy_ca_paths.as_ref()); append_proxy_env_vars(&mut env, proxy_addr, proxy_auth.as_ref()); env.sort(); // deterministic order for logging / debugging @@ -2951,6 +2940,8 @@ mod lifecycle_tests { let shell = std::env::var("COMSPEC").unwrap_or_else(|_| r"C:\Windows\System32\cmd.exe".to_string()); + let workload = tempfile::tempdir().expect("temporary workload directory"); + let workload_dir = workload.path().to_string_lossy().into_owned(); let mut policy = fs_policy(&[]); policy.network_policies.insert( "github".to_string(), @@ -2965,10 +2956,19 @@ mod lifecycle_tests { provider_credentialed: true, ..Default::default() }], - binaries: vec![NetworkBinary { path: shell }], + binaries: vec![NetworkBinary { + path: shell.clone(), + }], }, ); - let mut sandbox = with_policy(driver_sandbox("sb-provider-env"), policy); + let mut sandbox = with_policy( + driver_sandbox_with_command( + "sb-provider-env", + &workload_dir, + vec![shell, "/c".into(), "exit 0".into()], + ), + policy, + ); sandbox .spec .as_mut() @@ -3117,6 +3117,49 @@ mod lifecycle_tests { assert_eq!(stage_tls_ca_files(None, "", "").unwrap(), None); } + #[test] + fn resolve_agent_proxy_ca_paths_stages_regardless_of_env_tier() { + // Regression test for the bug where CA staging was gated behind + // `config.pc_minimal_env`, so the default env tier (`pc_minimal_env + // == false`) left the agent pointed at the host proxy's private, + // AppContainer-unreadable temp directory instead of a staged copy. + // `resolve_agent_proxy_ca_paths` takes no env-tier argument at all, + // so this can't regress silently. + let source = tempfile::tempdir().unwrap(); + let share = tempfile::tempdir().unwrap(); + let ca = source.path().join("source-ca.pem"); + let bundle = source.path().join("source-bundle.pem"); + std::fs::write(&ca, b"ca").unwrap(); + std::fs::write(&bundle, b"bundle").unwrap(); + let host_proxy_ca_paths = (ca, bundle); + + let resolved = resolve_agent_proxy_ca_paths( + Some(&host_proxy_ca_paths), + share.path().to_str().expect("UTF-8 test path"), + "sandbox-default-env-tier", + ) + .unwrap() + .expect("resolved paths"); + + assert_eq!( + resolved.0.parent().unwrap(), + share + .path() + .join(".openshell-proxy") + .join("sandbox-default-env-tier") + ); + assert_ne!(resolved.0, host_proxy_ca_paths.0); + assert_ne!(resolved.1, host_proxy_ca_paths.1); + } + + #[test] + fn resolve_agent_proxy_ca_paths_is_none_without_a_host_proxy() { + assert_eq!( + resolve_agent_proxy_ca_paths(None, "unused-share", "sandbox-a").unwrap(), + None + ); + } + #[test] fn proxy_env_replaces_inherited_values_and_clears_bypass_rules() { let mut env = vec![ @@ -3208,19 +3251,6 @@ mod lifecycle_tests { assert!(env.contains(&format!("GIT_SSL_CAINFO={bundle_path}"))); } - #[test] - fn tls_readwrite_grant_adds_ca_directory_once() { - let tls_dir = std::env::temp_dir().join("openshell-mxc-tls-test"); - let ca_cert = tls_dir.join("openshell-ca.pem"); - let bundle = tls_dir.join("ca-bundle.pem"); - let existing = tls_dir.display().to_string().to_ascii_lowercase(); - let mut readwrite = vec![existing.clone()]; - - append_tls_readwrite_grant(&mut readwrite, Some(&(ca_cert, bundle))); - - assert_eq!(readwrite, vec![existing]); - } - #[test] fn windows_command_line_preserves_argument_boundaries() { assert_eq!( @@ -3415,18 +3445,22 @@ mod lifecycle_tests { ); assert!(recorded["network"].get("proxy").is_none()); - let proxy_addr = { + let (proxy_addr, host_proxy_ca_paths) = { let registry = backend.registry.lock().await; let entry = registry.get(sandbox_id).expect("registry entry"); - assert!( - entry.host_proxy.is_some(), - "{sandbox_id}: governed egress must hold a live host proxy" - ); + let host_proxy = entry.host_proxy.as_ref().unwrap_or_else(|| { + panic!("{sandbox_id}: governed egress must hold a live host proxy") + }); assert_eq!( entry.trimmed_policy.as_ref().unwrap().network_policies, policy.network_policies ); - entry.proxy_addr.expect("proxy address") + ( + entry.proxy_addr.expect("proxy address"), + host_proxy + .ca_file_paths() + .expect("governed egress proxy must expose public CA paths"), + ) }; tokio::time::timeout( Duration::from_secs(2), @@ -3437,6 +3471,46 @@ mod lifecycle_tests { .expect("proxy listener must accept connections"); let child_env = recorded["process"]["env"].as_array().expect("child env"); + let tls_env = child_env + .iter() + .filter_map(serde_json::Value::as_str) + .filter_map(|entry| entry.split_once('=')) + .filter(|(key, _)| TLS_ENV_KEYS.contains(key)) + .collect::>(); + assert_eq!( + tls_env.len(), + TLS_ENV_KEYS.len(), + "{sandbox_id}: every TLS trust variable must be replaced" + ); + let staged_ca_dir = PathBuf::from(&share) + .join(".openshell-proxy") + .join(sandbox_id); + for key in TLS_ENV_KEYS { + let path = PathBuf::from( + tls_env + .get(key) + .unwrap_or_else(|| panic!("{sandbox_id}: missing {key}")), + ); + assert_eq!( + path.parent(), + Some(staged_ca_dir.as_path()), + "{sandbox_id}: {key} must use the staged CA directory" + ); + assert!(path.is_file(), "{sandbox_id}: staged {key} path must exist"); + } + let host_ca_dir = host_proxy_ca_paths + .0 + .parent() + .expect("host CA path must have a parent"); + assert!( + recorded["filesystem"]["readwritePaths"] + .as_array() + .expect("read-write paths") + .iter() + .filter_map(serde_json::Value::as_str) + .all(|path| Path::new(path) != host_ca_dir), + "{sandbox_id}: the host proxy CA directory must not be writable" + ); let proxy_env = child_env .iter() .filter_map(serde_json::Value::as_str) From a69d0319f22442866fb8a344bfaaa1e3085801e6 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas <34834085+prekshivyas@users.noreply.github.com> Date: Tue, 22 Sep 2026 10:52:21 -0700 Subject: [PATCH 09/42] test(windows): define GB300 MXC qualification contract (NVBug 6643699) (#3471) * test(windows): define GB300 MXC qualification contract Signed-off-by: Prekshi Vyas * test(mxc): harden GB300 qualification provenance Signed-off-by: Prekshi Vyas * test(windows): package portable GB300 qualification Signed-off-by: Prekshi Vyas * test(windows): honor external qualification checkout Signed-off-by: Prekshi Vyas * revert: remove portable GB300 packaging Signed-off-by: Prekshi Vyas * test(mxc): bind GB300 evidence to exact inputs Signed-off-by: Prekshi Vyas --------- Signed-off-by: Prekshi Vyas --- .../build-openshell-mxc-windows/SKILL.md | 12 + architecture/windows-msvc-build.md | 11 + crates/openshell-driver-mxc/README.md | 14 +- .../qualification/README.md | 86 +++ .../qualification/gb300-woa.json | 597 ++++++++++++++++++ .../qualification/run-gb300-woa.ps1 | 507 +++++++++++++++ .../qualification/validate.py | 524 +++++++++++++++ python/gb300_mxc_qualification_test.py | 307 +++++++++ tasks/scripts/windows-msvc.ps1 | 54 +- tasks/windows.toml | 14 + 10 files changed, 2120 insertions(+), 6 deletions(-) create mode 100644 crates/openshell-driver-mxc/qualification/README.md create mode 100644 crates/openshell-driver-mxc/qualification/gb300-woa.json create mode 100644 crates/openshell-driver-mxc/qualification/run-gb300-woa.ps1 create mode 100644 crates/openshell-driver-mxc/qualification/validate.py create mode 100644 python/gb300_mxc_qualification_test.py diff --git a/.agents/skills/build-openshell-mxc-windows/SKILL.md b/.agents/skills/build-openshell-mxc-windows/SKILL.md index 1120e6cfcc..0ee92ed6a7 100644 --- a/.agents/skills/build-openshell-mxc-windows/SKILL.md +++ b/.agents/skills/build-openshell-mxc-windows/SKILL.md @@ -168,6 +168,15 @@ neither. Both are skip-safe (they print a SKIP reason and exit 0 when arch-appropriate task is always safe even without real MXC hardware. Neither is part of `windows:ci`'s ordered contract, so invoke it explicitly. +For GB300 Windows ARM64 qualification, do not use the skip-safe developer task +as release evidence. `windows:test:mxc-gb300:arm64` runs the required +ProcessContainer subset and fails on any required `SKIP`. +`windows:qualify:mxc:gb300:contract` validates the static coverage matrix, and +`windows:qualify:mxc:gb300` runs the complete source, host, build/test, policy +E2E, and OpenClaw gate. Its inputs and evidence contract are documented in +`crates/openshell-driver-mxc/qualification/README.md`. Native-x64 NemoClaw and +x64 Windows results never satisfy this ARM64 contract. + For full validation, detect the Windows host architecture first and choose the native lane dynamically: @@ -257,6 +266,9 @@ crypto dependency builds. | `windows:test:unsupported:arm64` | Re-runs the same focused contracts natively on ARM64. Rejects non-ARM64 hosts. | | `windows:test:mxc-real:x64` | Runs the serial, ignored real-`wxc-exec` integration suite natively on x64 through the MSVC wrapper. Rejects non-x64 hosts. | | `windows:test:mxc-real:arm64` | Runs the same real-`wxc-exec` suite natively on ARM64. Rejects non-ARM64 hosts. | +| `windows:test:mxc-gb300:arm64` | Runs the required native ARM64 ProcessContainer subset and fails when a test skips. | +| `windows:qualify:mxc:gb300:contract` | Validates the required/optional/unsupported/architecture-constrained GB300 matrix. | +| `windows:qualify:mxc:gb300` | Runs the fail-closed GB300 ARM64 gate and validates hash-bound evidence. | | `windows:artifacts` | Reports size and SHA256 for release artifacts that exist. | | `windows:ci` | Runs the full ordered x64-host Windows CI lane, plus ARM64 check/build when not skipped. | diff --git a/architecture/windows-msvc-build.md b/architecture/windows-msvc-build.md index 8795e78776..cd5fbd0615 100644 --- a/architecture/windows-msvc-build.md +++ b/architecture/windows-msvc-build.md @@ -90,6 +90,10 @@ Windows validation is exposed through `tasks/windows.toml`: | `windows:test:arm64` | Run the same suite natively on ARM64. | | `windows:test:unsupported:x64` | Run focused gateway-composition tests for unsupported driver contracts. | | `windows:test:unsupported:arm64` | Run the same focused contracts natively on ARM64. | +| `windows:test:mxc-real:arm64` | Run the native real-MXC developer suite; missing hardware remains skip-safe. | +| `windows:test:mxc-gb300:arm64` | Run the required real-MXC subset on native ARM64 and fail on any required skip. | +| `windows:qualify:mxc:gb300:contract` | Validate the static NVBug 6643699 scope matrix on any development host. | +| `windows:qualify:mxc:gb300` | Execute the complete native GB300 ARM64 gate and produce hash-bound evidence. | | `windows:ci` | Run check, build, test, unsupported-contract tests, and artifact reporting. | The Windows tasks call `tasks/scripts/windows-msvc.ps1`. The wrapper discovers @@ -185,6 +189,13 @@ native rather than emulated coverage. ## Validation Contract +The generic real-MXC lanes are diagnostics and deliberately remain skip-safe. +They cannot establish hardware qualification when a prerequisite or backend is +absent. GB300 release evidence uses the separate fail-closed contract in +[`crates/openshell-driver-mxc/qualification/`](../crates/openshell-driver-mxc/qualification/README.md). +That matrix separates native Windows ARM64 evidence from x64-only NemoClaw and +Windows lanes, and records all hardware-dependent exclusions explicitly. + A successful Windows build report should include: - x64 and ARM64 `cargo check` status. diff --git a/crates/openshell-driver-mxc/README.md b/crates/openshell-driver-mxc/README.md index 81511deabd..469c7a4820 100644 --- a/crates/openshell-driver-mxc/README.md +++ b/crates/openshell-driver-mxc/README.md @@ -194,16 +194,24 @@ This example uses `process_container`. The `IsoSessionApp.dll` and ## Real-MXC test lane -Three tasks drive real `wxc-exec.exe` hardware; all are **skip-safe** — any test -or scenario that requires an absent binary or backend prints a SKIP reason and -exits 0 rather than failing. +The generic real-`wxc-exec.exe` tasks are **skip-safe**: a test or scenario that +requires an absent binary or backend prints a SKIP reason and exits 0. They are +useful developer diagnostics, but a skipped run is not qualification evidence. +The GB300 task is deliberately strict and fails on every required skip. | Task | What it runs | When to use | |---|---|---| | `windows:test:mxc-real:x64` | `tests/wxc_exec_real.rs` — Tier-2 invoker tests with `--ignored --test-threads=1`, including an HTTPS request through the host proxy | Pre-merge on any Windows host that has `wxc-exec`; dry-run tests always pass; enforcement tests probe-gate themselves | | `windows:test:mxc-real:arm64` | Native ARM64 `tests/wxc_exec_real.rs` with the same contract | Pre-merge on an ARM64 Windows host with `wxc-exec` | +| `windows:test:mxc-gb300:arm64` | Required ARM64 ProcessContainer cases from `tests/wxc_exec_real.rs`; rejects x64 and every required `SKIP` | GB300 qualification only; requires a live backend and all prerequisites | | `windows:e2e:mxc` | `examples/run-mxc-e2e.ps1` — Tier-3 scenario runner, real binary, probe-gated | Demo box / nightly; needs the gateway + CLI binaries in the script directory | | `windows:e2e:mxc:mock` | Same runner with `-Mock` — wiring-only, no real `wxc-exec` needed | Any Windows host (CI, dev machine); validates wiring and the network-reject scenario | +| `windows:qualify:mxc:gb300` | Complete source, host, ARM64 build/test, strict MXC, policy E2E, OpenClaw, and hash-bound evidence contract | Review/release evidence on a native GB300 Windows ARM64 host | + +The exact required, optional, unsupported, and architecture-constrained GB300 +matrix is documented and machine-validated in +[`qualification/`](qualification/README.md). Native-x64 NemoClaw and Windows x64 +lanes are explicitly separate and cannot receive GB300 ARM64 credit. **Probe script:** `examples/probe-mxc-host.ps1` is an operator/CI preflight that emits a JSON capability report (OS build, wxc-exec path/version, dry-run exit code, per-backend trial result, diff --git a/crates/openshell-driver-mxc/qualification/README.md b/crates/openshell-driver-mxc/qualification/README.md new file mode 100644 index 0000000000..7397fc2dc3 --- /dev/null +++ b/crates/openshell-driver-mxc/qualification/README.md @@ -0,0 +1,86 @@ +# GB300 Windows ARM64 MXC qualification + +This directory is the repository-owned qualification contract for NVBug 6643699. +It closes the test-plan ambiguity without treating it as a product defect. + +[`gb300-woa.json`](gb300-woa.json) is the authoritative coverage matrix. Every +row declares its disposition, owner, architecture, hardware dependency, +prerequisites, command, pass criteria, skip policy, and required artifact roles. +[`validate.py`](validate.py) rejects a contract that weakens the required rows +and validates retained evidence by SHA256. [`run-gb300-woa.ps1`](run-gb300-woa.ps1) +executes the required native lane and produces that evidence. + +## Scope decisions + +- The release target is a **native GB300 Windows ARM64 host** using the MXC + `process_container` backend. WSL and emulated/cross-built test execution do + not receive credit. +- OpenClaw is the one required agent application because this repository owns + its MXC launch and dynamic-forwarding harness. The caller must supply a + versioned ARM64 Node.js binary and OpenClaw package. +- OpenShell does not choose or ship inference models. Model qualification and + additional agent applications are optional, application-owned evidence and + cannot replace an OpenShell/MXC gate. +- NemoClaw's native-x64 qualification guard is a separate application lane. It + cannot satisfy any GB300 ARM64 row. The bundled Node/OpenClaw install helper + is also x64-only; GB300 runs must not use its Node.js output. +- `isolation_session` is optional because it depends on the OS build, + `IsoSessionApp.dll`, and the `wxc-exec` build. Its absence does not weaken the + required ProcessContainer gate. +- MXC process-policy mapping, GPU passthrough, and interactive exec/connect are + unsupported. Windows Docker, Kubernetes, Podman, and VM drivers remain + unsupported. Host GPU presence is not evidence of MXC GPU passthrough. + +## Coverage summary + +The JSON manifest contains the full prerequisites, pass/fail criteria, and +artifact contract. This compact view makes the ownership boundary reviewable. + +| Disposition | Coverage IDs | Owner boundary | +|---|---|---| +| Required | `contract-integrity`, `source-provenance`, `gb300-arm64-host`, `arm64-msvc-check`, `arm64-release-build`, `arm64-native-tests`, `arm64-unsupported-driver-contracts`, `processcontainer-real-mxc`, `processcontainer-policy-e2e`, `openclaw-arm64-forward` | OpenShell development owns repository tests; GB300 QA owns host execution and evidence; the agent-app owner supplies versioned ARM64 OpenClaw inputs | +| Optional | `isolation-session`, `provider-credential-injection`, `etw-ocsf-audit`, `local-inference-models`, `additional-agent-apps` | The named component or product owner decides whether to attach extra evidence | +| Unsupported | `wsl`, `windows-non-mxc-drivers`, `mxc-process-policy`, `mxc-gpu-passthrough`, `mxc-interactive-exec` | No qualification claim is permitted | +| Architecture-constrained | `nemoclaw-native-x64`, `node-openclaw-x64-installer`, `windows-x64-lanes` | Native x64 lanes stay separate and receive no GB300 ARM64 credit | + +Required coverage is fail-closed: a missing prerequisite, `SKIP`, non-zero exit, +non-ARM64 result, missing artifact role, empty artifact, path outside the evidence +root, or SHA256 mismatch fails validation. Generic real-MXC developer tasks remain +skip-safe and are not qualification evidence. + +## Validate the contract + +Static validation and regression tests do not require Windows or MXC hardware: + +```text +uv run python crates/openshell-driver-mxc/qualification/validate.py contract +uv run pytest python/gb300_mxc_qualification_test.py +``` + +## Execute on GB300 + +Start from a clean review commit based directly on the freshly fetched +`origin/windows` tip. Run from elevated native ARM64 PowerShell: + +```powershell +$env:OPENSHELL_GB300_BASE_SHA = '' +$env:OPENSHELL_GB300_HARDWARE_ATTESTATION = 'GB300' +$env:OPENSHELL_WXC_EXEC_PATH = 'C:\mxc-kit\bin\wxc-exec.exe' +$env:OPENSHELL_GB300_NODE_PATH = 'C:\path\to\arm64\node.exe' +$env:OPENSHELL_GB300_OPENCLAW_DIR = 'C:\path\to\node_modules\openclaw' +$env:OPENSHELL_GB300_EVIDENCE_DIR = 'D:\evidence\openshell-gb300-' + +mise run --skip-tools windows:qualify:mxc:gb300 +``` + +The runner fetches `origin/windows`, rejects a moved or unrelated base, requires +a clean worktree, verifies the PE machine for `wxc-exec.exe` and `node.exe`, and +stages only tracked examples plus freshly built ARM64 binaries. It never treats +an existing source tree or an earlier result bundle as current evidence. + +The evidence directory contains environment/source provenance, the MXC host +probe, command logs with timings, ARM64 binary hashes, the complete MXC policy +and OpenClaw bundles, `evidence.json`, and the final validation log. Do not put +provider credentials in command lines or retained files. Optional credential +coverage must use a scoped non-production credential and prove that retained +artifacts contain no secret value. diff --git a/crates/openshell-driver-mxc/qualification/gb300-woa.json b/crates/openshell-driver-mxc/qualification/gb300-woa.json new file mode 100644 index 0000000000..f6d4e6c72e --- /dev/null +++ b/crates/openshell-driver-mxc/qualification/gb300-woa.json @@ -0,0 +1,597 @@ +{ + "schema_version": 1, + "contract_id": "nvbug-6643699-gb300-woa-mxc", + "nvbug": 6643699, + "title": "GB300 native Windows ARM64 OpenShell MXC qualification", + "target": { + "hardware": "GB300", + "operating_system": "windows", + "architecture": "arm64", + "rust_target": "aarch64-pc-windows-msvc", + "execution": "native", + "wsl": false, + "base_ref": "origin/windows" + }, + "decisions": { + "required_agent_apps": [ + "OpenClaw" + ], + "target_models": [], + "model_scope": "agent_application_owned", + "model_release_gate": false, + "model_rationale": "OpenShell provides the MXC sandbox and governed transport; it does not select or ship local-inference models. Product Management and the agent-app owner may attach optional model evidence without replacing an OpenShell/MXC gate.", + "required_skip_policy": "fail", + "nemoclaw_credit": "Native-x64 NemoClaw qualification is a separate gate and cannot satisfy native Windows ARM64 OpenShell/MXC coverage." + }, + "owners": { + "openshell_dev": { + "name": "Shailendra Singh", + "responsibility": "OpenShell MXC implementation, build, and repository-owned tests" + }, + "gb300_qa": { + "name": "Rohit Varma Kalidindi", + "responsibility": "GB300 host execution, evidence custody, and release verdict" + }, + "sw_program": { + "name": "Eric Tam", + "responsibility": "Cross-team schedule and unresolved dependency coordination" + }, + "product": { + "name": "Abhishek Gore", + "responsibility": "Agent-app and model product-scope changes" + }, + "agent_app": { + "name": "Agent application owner", + "responsibility": "Versioned ARM64 application/runtime inputs and application-level acceptance" + }, + "mxc_platform": { + "name": "MXC platform owner", + "responsibility": "GB300 wxc-exec build, enabled backend, and platform prerequisites" + } + }, + "matrix": [ + { + "id": "contract-integrity", + "title": "Qualification contract integrity", + "disposition": "required", + "owner": "openshell_dev", + "architecture": "any", + "hardware_dependent": false, + "credits_gb300_arm64": true, + "allow_skip": false, + "prerequisites": [ + "Python 3.11 or newer", + "Repository checkout containing this manifest and validator" + ], + "commands": [ + "uv run python crates/openshell-driver-mxc/qualification/validate.py contract" + ], + "pass_criteria": [ + "Validator exits 0", + "Every matrix row has a valid disposition, owner, prerequisites, pass criteria, and artifact contract", + "Required ARM64 rows are non-skippable and x64-only rows cannot receive ARM64 credit" + ], + "artifact_roles": [ + "contract_validation_log" + ] + }, + { + "id": "source-provenance", + "title": "Exact windows-tip source provenance", + "disposition": "required", + "owner": "gb300_qa", + "architecture": "any", + "hardware_dependent": false, + "credits_gb300_arm64": true, + "allow_skip": false, + "prerequisites": [ + "Fresh fetch of the NVIDIA/OpenShell origin remote", + "Expected base SHA recorded before qualification", + "Clean worktree based directly on that origin/windows SHA" + ], + "commands": [ + "git fetch --prune origin windows", + "git rev-parse refs/remotes/origin/windows", + "git merge-base HEAD refs/remotes/origin/windows", + "git status --porcelain=v1" + ], + "pass_criteria": [ + "Fetched origin/windows equals the expected 40-character base SHA", + "HEAD descends directly from that base SHA", + "The worktree is clean before evidence collection" + ], + "artifact_roles": [ + "environment_and_source_provenance" + ] + }, + { + "id": "gb300-arm64-host", + "title": "Native GB300 Windows ARM64 host and MXC preflight", + "disposition": "required", + "owner": "gb300_qa", + "architecture": "native_arm64", + "hardware_dependent": true, + "credits_gb300_arm64": true, + "allow_skip": false, + "prerequisites": [ + "GB300 Windows host", + "Native ARM64 process architecture", + "wxc-exec.exe built for ARM64", + "ProcessContainer backend enabled", + "Elevated PowerShell for MXC loopback and AppContainer setup" + ], + "commands": [ + "powershell -NoProfile -ExecutionPolicy Bypass -File crates/openshell-driver-mxc/examples/probe-mxc-host.ps1 -WxcExecPath -OutFile /mxc-host-capabilities.json -Full" + ], + "pass_criteria": [ + "Host OS is Windows and RuntimeInformation.OSArchitecture is Arm64", + "Execution is not WSL or emulation", + "Operator hardware attestation identifies GB300", + "wxc-exec PE machine is ARM64", + "Probe verdicts processcontainer=live and dryRun=ok" + ], + "artifact_roles": [ + "environment_and_source_provenance", + "mxc_host_capabilities", + "mxc_host_probe_log" + ] + }, + { + "id": "arm64-msvc-check", + "title": "ARM64 MSVC workspace check", + "disposition": "required", + "owner": "openshell_dev", + "architecture": "native_arm64", + "hardware_dependent": false, + "credits_gb300_arm64": true, + "allow_skip": false, + "prerequisites": [ + "Visual Studio ARM64 compiler, Spectre libraries, Clang, CMake, and Windows SDK", + "Rust target aarch64-pc-windows-msvc" + ], + "commands": [ + "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 check aarch64-pc-windows-msvc -LogDir " + ], + "pass_criteria": [ + "Command exits 0", + "Windows-supported workspace and gateway dependency-boundary check both pass" + ], + "artifact_roles": [ + "arm64_check_log" + ] + }, + { + "id": "arm64-release-build", + "title": "ARM64 release artifacts", + "disposition": "required", + "owner": "openshell_dev", + "architecture": "native_arm64", + "hardware_dependent": false, + "credits_gb300_arm64": true, + "allow_skip": false, + "prerequisites": [ + "ARM64 MSVC check prerequisites", + "Pinned prebuilt Z3 runtime or a complete approved override" + ], + "commands": [ + "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 build aarch64-pc-windows-msvc -LogDir ", + "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 artifacts aarch64-pc-windows-msvc" + ], + "pass_criteria": [ + "Release build exits 0", + "openshell-gateway.exe, openshell.exe, openshell-supervisor-relay.exe, and libz3.dll exist for aarch64-pc-windows-msvc", + "Size and SHA256 are recorded for every artifact" + ], + "artifact_roles": [ + "arm64_build_log", + "arm64_binary_manifest" + ] + }, + { + "id": "arm64-native-tests", + "title": "Native ARM64 Windows workspace tests", + "disposition": "required", + "owner": "openshell_dev", + "architecture": "native_arm64", + "hardware_dependent": false, + "credits_gb300_arm64": true, + "allow_skip": false, + "prerequisites": [ + "ARM64 release-build prerequisites", + "Native ARM64 test runner" + ], + "commands": [ + "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 test-precommit aarch64-pc-windows-msvc -LogDir " + ], + "pass_criteria": [ + "Command exits 0", + "The CI nextest profile and server test-support suite pass natively" + ], + "artifact_roles": [ + "arm64_test_log" + ] + }, + { + "id": "arm64-unsupported-driver-contracts", + "title": "Unsupported Windows compute-driver boundaries", + "disposition": "required", + "owner": "openshell_dev", + "architecture": "native_arm64", + "hardware_dependent": false, + "credits_gb300_arm64": true, + "allow_skip": false, + "prerequisites": [ + "Native ARM64 test runner" + ], + "commands": [ + "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 test-unsupported aarch64-pc-windows-msvc -LogDir " + ], + "pass_criteria": [ + "Focused unsupported-driver tests exit 0", + "Protocol-only, MXC-only, Docker-stub-only, and MXC-plus-Docker-stub compositions pass" + ], + "artifact_roles": [ + "unsupported_driver_contract_log" + ] + }, + { + "id": "processcontainer-real-mxc", + "title": "Strict live ProcessContainer integration", + "disposition": "required", + "owner": "openshell_dev", + "architecture": "native_arm64", + "hardware_dependent": true, + "credits_gb300_arm64": true, + "allow_skip": false, + "prerequisites": [ + "Live ARM64 wxc-exec ProcessContainer backend", + "Windows curl.exe", + "Network path to the allowlisted HTTPS fixture" + ], + "commands": [ + "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 test-mxc-gb300 aarch64-pc-windows-msvc -LogDir " + ], + "pass_criteria": [ + "Every required dry-run and ProcessContainer test passes", + "No required test emits SKIP", + "In-policy write succeeds, out-of-policy write is denied, and governed HTTPS reads the injected CA bundle" + ], + "artifact_roles": [ + "strict_real_mxc_log" + ] + }, + { + "id": "processcontainer-policy-e2e", + "title": "Tier-3 ProcessContainer policy scenarios", + "disposition": "required", + "owner": "gb300_qa", + "architecture": "native_arm64", + "hardware_dependent": true, + "credits_gb300_arm64": true, + "allow_skip": false, + "prerequisites": [ + "Live ProcessContainer backend", + "Fresh ARM64 gateway, CLI, relay, and Z3 artifacts staged with tracked fixtures" + ], + "commands": [ + "powershell -NoProfile -ExecutionPolicy Bypass -File run-mxc-e2e.ps1 -WxcExecPath -Backend process_container" + ], + "pass_criteria": [ + "fs-rw, fs-readonly, fs-default-deny, and network-reject all PASS", + "Summary is PASS=4 FAIL=0 SKIP=0", + "The exact rendered TOML, policies, gateway logs, transcript, and summary are retained" + ], + "artifact_roles": [ + "mxc_policy_e2e_log", + "mxc_policy_e2e_bundle" + ] + }, + { + "id": "openclaw-arm64-forward", + "title": "OpenClaw ARM64 MXC launch and dynamic forwarding", + "disposition": "required", + "owner": "agent_app", + "architecture": "native_arm64", + "hardware_dependent": true, + "credits_gb300_arm64": true, + "allow_skip": false, + "prerequisites": [ + "Caller-supplied ARM64 node.exe", + "Versioned OpenClaw package containing openclaw.mjs", + "Live ProcessContainer backend", + "Elevated PowerShell", + "Allowed HTTPS endpoint available to the governed-egress proof" + ], + "commands": [ + "powershell -NoProfile -ExecutionPolicy Bypass -File run-openclaw-forward-test.ps1 -Backend process_container -WxcExecPath -NodeExePath -OpenClawInstallDir " + ], + "pass_criteria": [ + "node.exe PE machine is ARM64", + "OpenClaw package version is captured", + "Sandbox reaches ready and the authenticated host-side OpenClaw client returns ok=true through openshell forward service", + "Allowed HTTPS succeeds while denied host and direct Internet bypass remain blocked" + ], + "artifact_roles": [ + "openclaw_forward_log", + "openclaw_forward_bundle" + ] + }, + { + "id": "isolation-session", + "title": "MXC isolation_session backend", + "disposition": "optional", + "owner": "mxc_platform", + "architecture": "native_arm64", + "hardware_dependent": true, + "credits_gb300_arm64": false, + "allow_skip": true, + "prerequisites": [ + "Windows build 26300.8553 or newer", + "IsoSessionApp.dll and isolation_session support in wxc-exec" + ], + "commands": [ + "mise run --skip-tools windows:test:mxc-real:arm64", + "powershell -NoProfile -ExecutionPolicy Bypass -File run-openclaw-forward-test.ps1 -Backend isolation_session " + ], + "pass_criteria": [ + "If executed, provision-start-exec-stop-deprovision completes and cleanup leaves no orphan", + "Absence is recorded with the probe reason and does not fail the GB300 ProcessContainer gate" + ], + "artifact_roles": [ + "optional_isolation_session_log" + ] + }, + { + "id": "provider-credential-injection", + "title": "MXC provider credential substitution", + "disposition": "optional", + "owner": "openshell_dev", + "architecture": "native_arm64", + "hardware_dependent": true, + "credits_gb300_arm64": false, + "allow_skip": true, + "prerequisites": [ + "Non-production scoped provider credential", + "Live ProcessContainer governed-egress backend" + ], + "commands": [ + "powershell -NoProfile -ExecutionPolicy Bypass -File run-provider-credential-test.ps1" + ], + "pass_criteria": [ + "If executed, placeholder substitution succeeds only at the bound endpoint", + "No credential or bearer value appears in retained artifacts" + ], + "artifact_roles": [ + "optional_provider_credential_bundle" + ] + }, + { + "id": "etw-ocsf-audit", + "title": "MXC ETW to OCSF audit", + "disposition": "optional", + "owner": "openshell_dev", + "architecture": "native_arm64", + "hardware_dependent": true, + "credits_gb300_arm64": false, + "allow_skip": true, + "prerequisites": [ + "Elevated ETW session access", + "Windows Sandboxing event providers enabled" + ], + "commands": [ + "powershell -NoProfile -ExecutionPolicy Bypass -File run-ocsf-audit.ps1" + ], + "pass_criteria": [ + "If executed, events are attributed to the owned sandbox and secret-free OCSF JSONL is retained" + ], + "artifact_roles": [ + "optional_ocsf_bundle" + ] + }, + { + "id": "local-inference-models", + "title": "Agent-app local inference models", + "disposition": "optional", + "owner": "product", + "architecture": "native_arm64", + "hardware_dependent": true, + "credits_gb300_arm64": false, + "allow_skip": true, + "prerequisites": [ + "Product-selected model and runtime build", + "Agent-app-owned inference acceptance criteria" + ], + "commands": [], + "pass_criteria": [ + "No model is implicitly claimed by the OpenShell contract", + "If supplied, optional evidence records exact model ID, model hash or version, inference runtime, prompt outcome, and latency" + ], + "artifact_roles": [ + "optional_model_evidence" + ] + }, + { + "id": "additional-agent-apps", + "title": "Agent applications other than OpenClaw", + "disposition": "optional", + "owner": "sw_program", + "architecture": "native_arm64", + "hardware_dependent": true, + "credits_gb300_arm64": false, + "allow_skip": true, + "prerequisites": [ + "Named application owner", + "Versioned native ARM64 build", + "Application-specific pass/fail oracle" + ], + "commands": [], + "pass_criteria": [ + "Applications are added explicitly rather than inferred from generic OpenShell coverage", + "Optional app evidence cannot replace the required OpenClaw or MXC rows" + ], + "artifact_roles": [ + "optional_agent_app_evidence" + ] + }, + { + "id": "wsl", + "title": "WSL execution", + "disposition": "unsupported", + "owner": "product", + "architecture": "none", + "hardware_dependent": false, + "credits_gb300_arm64": false, + "allow_skip": true, + "prerequisites": [ + "None" + ], + "commands": [], + "pass_criteria": [ + "No WSL result is accepted as native WoA evidence" + ], + "artifact_roles": [], + "reason": "NVBug 6643699 explicitly scopes GB300 coverage to native Windows-on-Arm and states WSL: No." + }, + { + "id": "windows-non-mxc-drivers", + "title": "Docker, Kubernetes, Podman, and VM compute drivers on Windows", + "disposition": "unsupported", + "owner": "openshell_dev", + "architecture": "none", + "hardware_dependent": false, + "credits_gb300_arm64": false, + "allow_skip": true, + "prerequisites": [ + "None" + ], + "commands": [], + "pass_criteria": [ + "Focused rejection contracts pass; no unsupported driver is presented as GB300 runtime coverage" + ], + "artifact_roles": [], + "reason": "The Windows gateway retains explicit unsupported stubs; only the in-process MXC compute driver is a native Windows runtime." + }, + { + "id": "mxc-process-policy", + "title": "Portable process policy enforcement in MXC", + "disposition": "unsupported", + "owner": "openshell_dev", + "architecture": "none", + "hardware_dependent": false, + "credits_gb300_arm64": false, + "allow_skip": true, + "prerequisites": [ + "None" + ], + "commands": [], + "pass_criteria": [ + "Explicit process policy is rejected rather than silently weakened" + ], + "artifact_roles": [], + "reason": "MXC supplies OS isolation but has no supported mapping for OpenShell portable process policy." + }, + { + "id": "mxc-gpu-passthrough", + "title": "GPU passthrough through the MXC driver", + "disposition": "unsupported", + "owner": "product", + "architecture": "none", + "hardware_dependent": true, + "credits_gb300_arm64": false, + "allow_skip": true, + "prerequisites": [ + "None" + ], + "commands": [], + "pass_criteria": [ + "No GPU passthrough claim is made from host GPU presence" + ], + "artifact_roles": [], + "reason": "GPU passthrough is not pursued by the host-side-governance MXC design." + }, + { + "id": "mxc-interactive-exec", + "title": "Interactive exec and connect", + "disposition": "unsupported", + "owner": "openshell_dev", + "architecture": "none", + "hardware_dependent": false, + "credits_gb300_arm64": false, + "allow_skip": true, + "prerequisites": [ + "None" + ], + "commands": [], + "pass_criteria": [ + "Dynamic service forwarding is tested without claiming interactive exec/connect support" + ], + "artifact_roles": [], + "reason": "The supervisor relay supports launch, shutdown, and dynamic forwarding but not the Linux ConnectSupervisor protocol." + }, + { + "id": "nemoclaw-native-x64", + "title": "NemoClaw native-x64 qualification", + "disposition": "architecture_constrained", + "owner": "agent_app", + "architecture": "native_x64", + "hardware_dependent": false, + "credits_gb300_arm64": false, + "allow_skip": true, + "prerequisites": [ + "Native x64 host required by the NemoClaw qualification guard" + ], + "commands": [], + "pass_criteria": [ + "Run only in the separately owned native-x64 lane", + "Never use its result to satisfy a GB300 Windows ARM64 row" + ], + "artifact_roles": [], + "reason": "The current NemoClaw qualification guard requires native x64; this contract qualifies OpenShell/MXC on native ARM64 independently." + }, + { + "id": "node-openclaw-x64-installer", + "title": "Bundled win-x64 Node.js installer helper", + "disposition": "architecture_constrained", + "owner": "agent_app", + "architecture": "native_x64", + "hardware_dependent": false, + "credits_gb300_arm64": false, + "allow_skip": true, + "prerequisites": [ + "x64-compatible Windows execution" + ], + "commands": [ + "powershell -NoProfile -ExecutionPolicy Bypass -File install-nodejs-openclaw.ps1" + ], + "pass_criteria": [ + "The helper remains usable for x64 development", + "GB300 qualification instead supplies an independently versioned ARM64 node.exe and verifies its PE machine" + ], + "artifact_roles": [], + "reason": "The helper downloads node-v-win-x64.zip and therefore cannot establish native ARM64 application coverage." + }, + { + "id": "windows-x64-lanes", + "title": "Windows x64 build and test lanes", + "disposition": "architecture_constrained", + "owner": "openshell_dev", + "architecture": "native_x64", + "hardware_dependent": false, + "credits_gb300_arm64": false, + "allow_skip": true, + "prerequisites": [ + "Native x64 Windows host" + ], + "commands": [ + "mise run --skip-tools windows:ci", + "mise run --skip-tools windows:test:mxc-real:x64" + ], + "pass_criteria": [ + "Results remain x64 regression evidence only", + "Cross-builds and emulated execution cannot satisfy native ARM64 rows" + ], + "artifact_roles": [], + "reason": "GB300 WoA qualification requires native ARM64 execution." + } + ] +} diff --git a/crates/openshell-driver-mxc/qualification/run-gb300-woa.ps1 b/crates/openshell-driver-mxc/qualification/run-gb300-woa.ps1 new file mode 100644 index 0000000000..8823490f1c --- /dev/null +++ b/crates/openshell-driver-mxc/qualification/run-gb300-woa.ps1 @@ -0,0 +1,507 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Execute the NVBug 6643699 GB300 native Windows ARM64 qualification contract. +# Evidence is written outside the checkout and hash-bound by validate.py. + +[CmdletBinding()] +param( + [string] $ExpectedBaseSha = $env:OPENSHELL_GB300_BASE_SHA, + [string] $WxcExecPath = $env:OPENSHELL_WXC_EXEC_PATH, + [string] $NodeExePath = $env:OPENSHELL_GB300_NODE_PATH, + [string] $OpenClawInstallDir = $env:OPENSHELL_GB300_OPENCLAW_DIR, + [string] $EvidenceDir = $env:OPENSHELL_GB300_EVIDENCE_DIR, + [ValidateSet("GB300")] + [string] $Hardware = $env:OPENSHELL_GB300_HARDWARE_ATTESTATION +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" +$PSNativeCommandUseErrorActionPreference = $false + +$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..\..")).Path +$ManifestPath = Join-Path $PSScriptRoot "gb300-woa.json" +$ValidatorPath = Join-Path $PSScriptRoot "validate.py" +$WindowsWrapper = Join-Path $RepoRoot "tasks\scripts\windows-msvc.ps1" +$ExampleSource = Join-Path $RepoRoot "crates\openshell-driver-mxc\examples" +$RustTarget = "aarch64-pc-windows-msvc" +$PowerShellExe = (Get-Process -Id $PID).Path +$Durations = @{} + +if ([string]::IsNullOrWhiteSpace($EvidenceDir)) { + $stamp = Get-Date -Format "yyyyMMdd-HHmmss" + $EvidenceDir = Join-Path $env:TEMP "openshell-gb300-qualification-$stamp" +} +$EvidenceDir = [System.IO.Path]::GetFullPath($EvidenceDir) +$repoPrefix = $RepoRoot.TrimEnd('\') + '\' +if ($EvidenceDir.StartsWith($repoPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "EvidenceDir must be outside the source checkout: $RepoRoot" +} +if (Test-Path $EvidenceDir) { + if (@(Get-ChildItem -LiteralPath $EvidenceDir -Force).Count -gt 0) { + throw "EvidenceDir must be new or empty: $EvidenceDir" + } +} else { + New-Item -ItemType Directory -Path $EvidenceDir | Out-Null +} +$EvidenceDir = (Resolve-Path $EvidenceDir).Path + +function Invoke-LoggedProcess( + [string] $CoverageId, + [string] $LogName, + [string] $FilePath, + [string[]] $Arguments +) { + $logPath = Join-Path $EvidenceDir $LogName + $timer = [System.Diagnostics.Stopwatch]::StartNew() + Write-Host "==> $CoverageId" + Write-Host " log: $logPath" + $savedErrorActionPreference = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { + $output = & $FilePath @Arguments 2>&1 + $exitCode = $LASTEXITCODE + } finally { + $ErrorActionPreference = $savedErrorActionPreference + } + $timer.Stop() + $Durations[$CoverageId] = [Math]::Round($timer.Elapsed.TotalSeconds, 3) + @($output | ForEach-Object { $_.ToString() }) | Out-File -FilePath $logPath -Encoding utf8 + @($output) | ForEach-Object { Write-Host $_ } + if ($exitCode -ne 0) { + throw "$CoverageId failed with exit code $exitCode. See $logPath" + } +} + +function Invoke-Git([string[]] $Arguments) { + $savedErrorActionPreference = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { + $output = & git -C $RepoRoot @Arguments 2>&1 + $exitCode = $LASTEXITCODE + } finally { + $ErrorActionPreference = $savedErrorActionPreference + } + if ($exitCode -ne 0) { + throw "git $($Arguments -join ' ') failed: $($output -join [Environment]::NewLine)" + } + return @($output | ForEach-Object { $_.ToString() }) +} + +function Assert-Arm64Pe([string] $Path, [string] $Label) { + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { + throw "$Label was not found: $Path" + } + $stream = [System.IO.File]::OpenRead((Resolve-Path -LiteralPath $Path).Path) + try { + $reader = New-Object System.IO.BinaryReader($stream) + try { + $stream.Position = 0x3c + $peOffset = $reader.ReadInt32() + $stream.Position = $peOffset + 4 + $machine = $reader.ReadUInt16() + } finally { + $reader.Dispose() + } + } finally { + $stream.Dispose() + } + if ($machine -ne 0xaa64) { + throw "$Label must be an ARM64 PE (machine 0xAA64); found 0x$($machine.ToString('X4')) at $Path" + } +} + +function Write-Json([object] $Value, [string] $Path) { + $Value | ConvertTo-Json -Depth 20 | Out-File -FilePath $Path -Encoding utf8 +} + +function Get-Sha256([string] $Path) { + $stream = [System.IO.File]::OpenRead($Path) + try { + $sha256 = [System.Security.Cryptography.SHA256]::Create() + try { + return [BitConverter]::ToString($sha256.ComputeHash($stream)).Replace("-", "").ToLowerInvariant() + } finally { + $sha256.Dispose() + } + } finally { + $stream.Dispose() + } +} + +function Get-DirectorySha256([string] $Path) { + $root = (Resolve-Path -LiteralPath $Path).Path.TrimEnd('\') + '\' + $entries = @( + Get-ChildItem -LiteralPath $Path -File -Recurse -Force | ForEach-Object { + [pscustomobject]@{ + RelativePath = $_.FullName.Substring($root.Length).Replace('\', '/') + Sha256 = Get-Sha256 $_.FullName + } + } | Sort-Object RelativePath + ) + if ($entries.Count -eq 0) { + throw "Package directory contains no files: $Path" + } + $manifest = (($entries | ForEach-Object { "$($_.RelativePath)`0$($_.Sha256)" }) -join "`n") + "`n" + $sha256 = [System.Security.Cryptography.SHA256]::Create() + try { + $bytes = [System.Text.Encoding]::UTF8.GetBytes($manifest) + return [BitConverter]::ToString($sha256.ComputeHash($bytes)).Replace("-", "").ToLowerInvariant() + } finally { + $sha256.Dispose() + } +} + +function New-EvidenceArtifact([string] $Role, [string] $Path) { + $resolved = (Resolve-Path -LiteralPath $Path).Path + $evidencePrefix = $EvidenceDir.TrimEnd('\') + '\' + if (-not $resolved.StartsWith($evidencePrefix, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "Artifact is outside EvidenceDir: $resolved" + } + $relative = $resolved.Substring($evidencePrefix.Length).Replace('\', '/') + return [ordered]@{ + role = $Role + path = $relative + sha256 = Get-Sha256 $resolved + } +} + +if (-not [System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform( + [System.Runtime.InteropServices.OSPlatform]::Windows +)) { + throw "GB300 qualification requires Windows." +} +$osArchitecture = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() +$processArchitecture = [System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture.ToString() +if ($osArchitecture -ne "Arm64" -or $processArchitecture -ne "Arm64") { + throw "GB300 qualification requires native ARM64; OS=$osArchitecture process=$processArchitecture" +} +if ($env:WSL_DISTRO_NAME -or $env:WSL_INTEROP) { + throw "WSL cannot satisfy the native GB300 Windows ARM64 contract." +} +if ($Hardware -ne "GB300") { + throw "Set OPENSHELL_GB300_HARDWARE_ATTESTATION=GB300 or pass -Hardware GB300 after verifying the host." +} +$identity = [Security.Principal.WindowsIdentity]::GetCurrent() +$principal = New-Object Security.Principal.WindowsPrincipal($identity) +if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + throw "Run the qualification from an elevated PowerShell session." +} +if ([string]::IsNullOrWhiteSpace($WxcExecPath)) { + throw "Set OPENSHELL_WXC_EXEC_PATH or pass -WxcExecPath." +} +if ([string]::IsNullOrWhiteSpace($NodeExePath)) { + throw "Set OPENSHELL_GB300_NODE_PATH to a native ARM64 node.exe or pass -NodeExePath." +} +if ([string]::IsNullOrWhiteSpace($OpenClawInstallDir)) { + throw "Set OPENSHELL_GB300_OPENCLAW_DIR or pass -OpenClawInstallDir." +} +$WxcExecPath = (Resolve-Path -LiteralPath $WxcExecPath).Path +$NodeExePath = (Resolve-Path -LiteralPath $NodeExePath).Path +$OpenClawInstallDir = (Resolve-Path -LiteralPath $OpenClawInstallDir).Path +if (-not (Test-Path -LiteralPath (Join-Path $OpenClawInstallDir "openclaw.mjs") -PathType Leaf)) { + throw "OpenClawInstallDir must directly contain openclaw.mjs: $OpenClawInstallDir" +} +Assert-Arm64Pe $WxcExecPath "wxc-exec" +Assert-Arm64Pe $NodeExePath "Node.js" + +$sourceTimer = [System.Diagnostics.Stopwatch]::StartNew() +if ([string]::IsNullOrWhiteSpace($ExpectedBaseSha)) { + throw "ExpectedBaseSha is required. Set OPENSHELL_GB300_BASE_SHA to the reviewed origin/windows commit." +} +if ($ExpectedBaseSha -notmatch '^[0-9a-f]{40}$') { + throw "ExpectedBaseSha must be a full lowercase Git SHA." +} +$originUrl = (@(Invoke-Git @("remote", "get-url", "origin")))[0].Trim() +$normalizedOriginUrl = $originUrl.TrimEnd('/').ToLowerInvariant() +$canonicalOriginUrls = @( + "https://github.com/nvidia/openshell.git", + "https://github.com/nvidia/openshell", + "git@github.com:nvidia/openshell.git", + "ssh://git@github.com/nvidia/openshell.git" +) +if ($canonicalOriginUrls -notcontains $normalizedOriginUrl) { + throw "origin must be the canonical NVIDIA/OpenShell GitHub repository; found: $originUrl" +} +Invoke-Git @("fetch", "--prune", "origin", "windows") | ForEach-Object { Write-Host $_ } +$fetchedBaseSha = (@(Invoke-Git @("rev-parse", "refs/remotes/origin/windows")))[0].Trim() +if ($fetchedBaseSha -ne $ExpectedBaseSha) { + throw "origin/windows moved: expected $ExpectedBaseSha, fetched $fetchedBaseSha" +} +$headSha = (@(Invoke-Git @("rev-parse", "HEAD")))[0].Trim() +$mergeBase = (@(Invoke-Git @("merge-base", "HEAD", "refs/remotes/origin/windows")))[0].Trim() +if ($mergeBase -ne $ExpectedBaseSha) { + throw "HEAD is not based directly on the expected origin/windows tip: merge-base=$mergeBase expected=$ExpectedBaseSha" +} +$worktreeStatus = @(Invoke-Git @("status", "--porcelain=v1")) +if ($worktreeStatus.Count -gt 0 -and ($worktreeStatus -join '').Trim()) { + throw "Qualification requires a clean worktree. Commit or preserve changes first." +} +$sourceTimer.Stop() +$Durations["source-provenance"] = [Math]::Round($sourceTimer.Elapsed.TotalSeconds, 3) + +$uv = Get-Command uv -ErrorAction Stop +Invoke-LoggedProcess "contract-integrity" "contract-validation.log" $uv.Source @( + "run", "python", $ValidatorPath, "contract" +) + +$probePath = Join-Path $ExampleSource "probe-mxc-host.ps1" +$probeReport = Join-Path $EvidenceDir "mxc-host-capabilities.json" +Invoke-LoggedProcess "gb300-arm64-host" "mxc-host-probe.log" $PowerShellExe @( + "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", $probePath, + "-WxcExecPath", $WxcExecPath, "-OutFile", $probeReport, "-Full" +) +$probe = Get-Content -LiteralPath $probeReport -Raw | ConvertFrom-Json +if ($probe.verdicts.processcontainer -ne "live" -or $probe.verdicts.dryRun -ne "ok") { + throw "MXC preflight must report processcontainer=live and dryRun=ok. See $probeReport" +} + +$openClawSnapshotDir = Join-Path $EvidenceDir "openclaw-package" +New-Item -ItemType Directory -Path $openClawSnapshotDir | Out-Null +Get-ChildItem -LiteralPath $OpenClawInstallDir -Force | ForEach-Object { + Copy-Item -LiteralPath $_.FullName -Destination $openClawSnapshotDir -Recurse -Force +} +$OpenClawInstallDir = (Resolve-Path -LiteralPath $openClawSnapshotDir).Path +if (-not (Test-Path -LiteralPath (Join-Path $OpenClawInstallDir "openclaw.mjs") -PathType Leaf)) { + throw "The staged OpenClaw package must directly contain openclaw.mjs: $OpenClawInstallDir" +} + +$packageJsonPath = Join-Path $OpenClawInstallDir "package.json" +if (-not (Test-Path -LiteralPath $packageJsonPath -PathType Leaf)) { + throw "OpenClawInstallDir must contain a versioned package.json: $OpenClawInstallDir" +} +try { + $openClawPackage = Get-Content -LiteralPath $packageJsonPath -Raw | ConvertFrom-Json + $openClawVersion = [string]$openClawPackage.version +} catch { + throw "OpenClaw package.json is invalid or has no version: $packageJsonPath" +} +if ([string]::IsNullOrWhiteSpace($openClawVersion)) { + throw "OpenClaw package.json must declare a non-empty version: $packageJsonPath" +} +$openClawPackageSha256 = Get-DirectorySha256 $OpenClawInstallDir +$environmentPath = Join-Path $EvidenceDir "environment.json" +$environment = [ordered]@{ + contract_id = "nvbug-6643699-gb300-woa-mxc" + captured_at = (Get-Date).ToUniversalTime().ToString("o") + hardware = $Hardware + operating_system = "windows" + os_version = [System.Environment]::OSVersion.Version.ToString() + os_architecture = $osArchitecture + process_architecture = $processArchitecture + native = $true + wsl = $false + elevated = $true + origin_url = $originUrl + base_ref = "refs/remotes/origin/windows" + base_sha = $ExpectedBaseSha + head_sha = $headSha + merge_base = $mergeBase + worktree_clean = $true + rust_version = (& rustc --version).ToString() + node_version = (& $NodeExePath --version).ToString() + openclaw_version = $openClawVersion + openclaw_package_sha256 = $openClawPackageSha256 + openclaw_package_json_sha256 = Get-Sha256 $packageJsonPath + wxc_exec_path = $WxcExecPath + wxc_exec_sha256 = Get-Sha256 $WxcExecPath + node_path = $NodeExePath + node_sha256 = Get-Sha256 $NodeExePath +} +Write-Json $environment $environmentPath + +$env:OPENSHELL_WXC_EXEC_PATH = $WxcExecPath +$commonWrapperArgs = @("-NoProfile", "-ExecutionPolicy", "Bypass", "-File", $WindowsWrapper) +Invoke-LoggedProcess "arm64-msvc-check" "arm64-check-command.log" $PowerShellExe @( + $commonWrapperArgs + @("check", $RustTarget, "-LogDir", $EvidenceDir) +) +Invoke-LoggedProcess "arm64-release-build" "arm64-build-command.log" $PowerShellExe @( + $commonWrapperArgs + @("build", $RustTarget, "-LogDir", $EvidenceDir) +) +Invoke-LoggedProcess "arm64-native-tests" "arm64-tests-command.log" $PowerShellExe @( + $commonWrapperArgs + @("test-precommit", $RustTarget, "-LogDir", $EvidenceDir) +) +Invoke-LoggedProcess "arm64-unsupported-driver-contracts" "arm64-unsupported.log" $PowerShellExe @( + $commonWrapperArgs + @("test-unsupported", $RustTarget, "-LogDir", $EvidenceDir) +) +Invoke-LoggedProcess "processcontainer-real-mxc" "strict-real-mxc.log" $PowerShellExe @( + $commonWrapperArgs + @("test-mxc-gb300", $RustTarget, "-LogDir", $EvidenceDir) +) + +$targetRoot = $env:CARGO_TARGET_DIR +if ([string]::IsNullOrWhiteSpace($targetRoot)) { + $targetRoot = Join-Path $RepoRoot "target" +} elseif (-not [System.IO.Path]::IsPathRooted($targetRoot)) { + $targetRoot = Join-Path $RepoRoot $targetRoot +} +$releaseDir = Join-Path $targetRoot "$RustTarget\release" +$binaryRows = @() +foreach ($binary in @("openshell-gateway.exe", "openshell.exe", "openshell-supervisor-relay.exe", "libz3.dll")) { + $path = Join-Path $releaseDir $binary + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { + throw "Required ARM64 release artifact is missing: $path" + } + $item = Get-Item -LiteralPath $path + $binaryRows += [ordered]@{ + name = $binary + path = $item.FullName + size = $item.Length + sha256 = Get-Sha256 $path + } +} +$binaryManifestPath = Join-Path $EvidenceDir "arm64-binary-manifest.json" +Write-Json $binaryRows $binaryManifestPath + +$packageDir = Join-Path $EvidenceDir "package" +New-Item -ItemType Directory -Path $packageDir | Out-Null +$examplePrefix = "crates/openshell-driver-mxc/examples/" +$trackedExamples = @(Invoke-Git @("ls-files", "--", "$examplePrefix*")) +foreach ($relative in $trackedExamples) { + $packageRelative = $relative.Substring($examplePrefix.Length) + $destination = Join-Path $packageDir $packageRelative + $destinationParent = Split-Path -Parent $destination + if (-not (Test-Path $destinationParent)) { + New-Item -ItemType Directory -Force -Path $destinationParent | Out-Null + } + Copy-Item -LiteralPath (Join-Path $RepoRoot $relative) -Destination $destination +} +foreach ($binary in $binaryRows) { + Copy-Item -LiteralPath $binary.path -Destination (Join-Path $packageDir $binary.name) +} + +$runStamp = Get-Date -Format "yyyyMMddHHmmss" +$mxcWorkDir = "C:\osq-mxc-$runStamp-$PID" +$openClawShareDir = "C:\osq-claw-$runStamp-$PID" +$mxcRunner = Join-Path $packageDir "run-mxc-e2e.ps1" +Invoke-LoggedProcess "processcontainer-policy-e2e" "mxc-policy-e2e.log" $PowerShellExe @( + "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", $mxcRunner, + "-WxcExecPath", $WxcExecPath, "-Backend", "process_container", "-DemoDir", $mxcWorkDir +) +$mxcLog = Get-Content -LiteralPath (Join-Path $EvidenceDir "mxc-policy-e2e.log") -Raw +if ($mxcLog -notmatch 'PASS=4\s+FAIL=0\s+SKIP=0') { + throw "The required MXC E2E scenarios did not report PASS=4 FAIL=0 SKIP=0." +} +$mxcBundle = Get-ChildItem -LiteralPath $packageDir -Filter "results-e2e-*.zip" -File | + Sort-Object LastWriteTimeUtc -Descending | Select-Object -First 1 +if (-not $mxcBundle) { + throw "The MXC E2E runner did not produce a results bundle." +} +$mxcBundlePath = Join-Path $EvidenceDir "mxc-policy-e2e.zip" +Copy-Item -LiteralPath $mxcBundle.FullName -Destination $mxcBundlePath + +$openClawRunner = Join-Path $packageDir "run-openclaw-forward-test.ps1" +$openClawPreRunSha256 = Get-DirectorySha256 $OpenClawInstallDir +if ($openClawPreRunSha256 -ne $openClawPackageSha256) { + throw "The staged OpenClaw package changed before execution: expected $openClawPackageSha256, found $openClawPreRunSha256" +} +Invoke-LoggedProcess "openclaw-arm64-forward" "openclaw-forward.log" $PowerShellExe @( + "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", $openClawRunner, + "-Backend", "process_container", "-WxcExecPath", $WxcExecPath, + "-NodeExePath", $NodeExePath, "-OpenClawInstallDir", $OpenClawInstallDir, + "-ShareDir", $openClawShareDir +) +$openClawPostRunSha256 = Get-DirectorySha256 $OpenClawInstallDir +if ($openClawPostRunSha256 -ne $openClawPackageSha256) { + throw "The staged OpenClaw package changed during execution: expected $openClawPackageSha256, found $openClawPostRunSha256" +} +$openClawBundle = Get-ChildItem -LiteralPath $packageDir -Filter "results-openclaw-forward-*.zip" -File | + Sort-Object LastWriteTimeUtc -Descending | Select-Object -First 1 +if (-not $openClawBundle) { + throw "The OpenClaw runner did not produce a results bundle." +} +$openClawBundlePath = Join-Path $EvidenceDir "openclaw-forward.zip" +Copy-Item -LiteralPath $openClawBundle.FullName -Destination $openClawBundlePath + +$requiredArtifacts = @{ + "contract-integrity" = @( + New-EvidenceArtifact "contract_validation_log" (Join-Path $EvidenceDir "contract-validation.log") + ) + "source-provenance" = @( + New-EvidenceArtifact "environment_and_source_provenance" $environmentPath + ) + "gb300-arm64-host" = @( + New-EvidenceArtifact "environment_and_source_provenance" $environmentPath + New-EvidenceArtifact "mxc_host_capabilities" $probeReport + New-EvidenceArtifact "mxc_host_probe_log" (Join-Path $EvidenceDir "mxc-host-probe.log") + ) + "arm64-msvc-check" = @( + New-EvidenceArtifact "arm64_check_log" (Join-Path $EvidenceDir "build-$RustTarget-check.log") + ) + "arm64-release-build" = @( + New-EvidenceArtifact "arm64_build_log" (Join-Path $EvidenceDir "build-$RustTarget-release.log") + New-EvidenceArtifact "arm64_binary_manifest" $binaryManifestPath + ) + "arm64-native-tests" = @( + New-EvidenceArtifact "arm64_test_log" (Join-Path $EvidenceDir "test-$RustTarget-precommit.log") + ) + "arm64-unsupported-driver-contracts" = @( + New-EvidenceArtifact "unsupported_driver_contract_log" (Join-Path $EvidenceDir "arm64-unsupported.log") + ) + "processcontainer-real-mxc" = @( + New-EvidenceArtifact "strict_real_mxc_log" (Join-Path $EvidenceDir "strict-real-mxc.log") + ) + "processcontainer-policy-e2e" = @( + New-EvidenceArtifact "mxc_policy_e2e_log" (Join-Path $EvidenceDir "mxc-policy-e2e.log") + New-EvidenceArtifact "mxc_policy_e2e_bundle" $mxcBundlePath + ) + "openclaw-arm64-forward" = @( + New-EvidenceArtifact "openclaw_forward_log" (Join-Path $EvidenceDir "openclaw-forward.log") + New-EvidenceArtifact "openclaw_forward_bundle" $openClawBundlePath + ) +} +$contract = Get-Content -LiteralPath $ManifestPath -Raw | ConvertFrom-Json +$results = @() +foreach ($row in $contract.matrix) { + if ($row.disposition -eq "required") { + $duration = $Durations[$row.id] + if ($null -eq $duration) { $duration = 0 } + $results += [ordered]@{ + coverage_id = $row.id + status = "pass" + architecture = "arm64" + command = ($row.commands -join " ; ") + exit_code = 0 + skip_count = 0 + duration_seconds = $duration + artifacts = @($requiredArtifacts[$row.id]) + } + } elseif ($row.disposition -eq "optional") { + $results += [ordered]@{ + coverage_id = $row.id + status = "not_run" + reason = "Optional coverage was not selected for this required GB300 ProcessContainer gate." + artifacts = @() + } + } else { + $results += [ordered]@{ + coverage_id = $row.id + status = "not_applicable" + reason = $row.reason + artifacts = @() + } + } +} +$recordPath = Join-Path $EvidenceDir "evidence.json" +$record = [ordered]@{ + schema_version = 1 + contract_id = "nvbug-6643699-gb300-woa-mxc" + base_sha = $ExpectedBaseSha + head_sha = $headSha + generated_at = (Get-Date).ToUniversalTime().ToString("o") + environment = [ordered]@{ + operating_system = "windows" + architecture = "arm64" + native = $true + wsl = $false + hardware = $Hardware + } + results = $results +} +Write-Json $record $recordPath +Invoke-LoggedProcess "evidence-validation" "evidence-validation.log" $uv.Source @( + "run", "python", $ValidatorPath, "evidence", "--record", $recordPath, + "--artifact-root", $EvidenceDir, "--expected-base-sha", $ExpectedBaseSha +) + +Write-Host "PASS: complete GB300 Windows ARM64 MXC evidence is valid." +Write-Host "Evidence: $EvidenceDir" diff --git a/crates/openshell-driver-mxc/qualification/validate.py b/crates/openshell-driver-mxc/qualification/validate.py new file mode 100644 index 0000000000..203dc555aa --- /dev/null +++ b/crates/openshell-driver-mxc/qualification/validate.py @@ -0,0 +1,524 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Validate the GB300 Windows ARM64 MXC contract and retained evidence.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import sys +from pathlib import Path +from typing import Any + +CONTRACT_ID = "nvbug-6643699-gb300-woa-mxc" +DISPOSITIONS = { + "required", + "optional", + "unsupported", + "architecture_constrained", +} +ARCHITECTURES = {"any", "native_arm64", "native_x64", "none"} +REQUIRED_IDS = { + "contract-integrity", + "source-provenance", + "gb300-arm64-host", + "arm64-msvc-check", + "arm64-release-build", + "arm64-native-tests", + "arm64-unsupported-driver-contracts", + "processcontainer-real-mxc", + "processcontainer-policy-e2e", + "openclaw-arm64-forward", +} +REQUIRED_REPOSITORY_PATHS = { + "crates/openshell-driver-mxc/examples/probe-mxc-host.ps1", + "crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1", + "crates/openshell-driver-mxc/examples/run-openclaw-forward-test.ps1", + "crates/openshell-driver-mxc/qualification/gb300-woa.json", + "tasks/scripts/windows-msvc.ps1", + "tasks/windows.toml", +} +SHA_PATTERN = re.compile(r"^[0-9a-f]{40}$") + + +def load_json(path: Path) -> dict[str, Any]: + """Load JSON written by either PowerShell 5.1 or a UTF-8-native tool.""" + with path.open(encoding="utf-8-sig") as stream: + value = json.load(stream) + if not isinstance(value, dict): + raise ValueError(f"{path} must contain a JSON object") + return value + + +def _nonempty_strings(value: object) -> bool: + return ( + isinstance(value, list) + and bool(value) + and all(isinstance(item, str) and item.strip() for item in value) + ) + + +def validate_contract( + contract: dict[str, Any], repo_root: Path | None = None +) -> list[str]: + """Return every static contract violation without stopping at the first.""" + errors: list[str] = [] + if contract.get("schema_version") != 1: + errors.append("schema_version must be 1") + if contract.get("contract_id") != CONTRACT_ID: + errors.append(f"contract_id must be {CONTRACT_ID}") + if contract.get("nvbug") != 6643699: + errors.append("nvbug must be 6643699") + + target = contract.get("target") + if not isinstance(target, dict): + errors.append("target must be an object") + target = {} + expected_target = { + "hardware": "GB300", + "operating_system": "windows", + "architecture": "arm64", + "rust_target": "aarch64-pc-windows-msvc", + "execution": "native", + "wsl": False, + "base_ref": "origin/windows", + } + for key, expected in expected_target.items(): + if target.get(key) != expected: + errors.append(f"target.{key} must be {expected!r}") + + decisions = contract.get("decisions") + if not isinstance(decisions, dict): + errors.append("decisions must be an object") + decisions = {} + if decisions.get("required_agent_apps") != ["OpenClaw"]: + errors.append("OpenClaw must be the sole repository-owned required agent app") + if decisions.get("target_models") != []: + errors.append("target_models must be empty; model selection is app-owned") + if decisions.get("model_scope") != "agent_application_owned": + errors.append("model_scope must remain agent_application_owned") + if decisions.get("model_release_gate") is not False: + errors.append("model_release_gate must remain false") + if decisions.get("required_skip_policy") != "fail": + errors.append("required_skip_policy must be fail") + + owners = contract.get("owners") + if not isinstance(owners, dict) or not owners: + errors.append("owners must be a non-empty object") + owners = {} + for owner_id, owner in owners.items(): + if not isinstance(owner, dict): + errors.append(f"owner {owner_id} must be an object") + continue + if not isinstance(owner.get("name"), str) or not owner["name"].strip(): + errors.append(f"owner {owner_id} needs a name") + if ( + not isinstance(owner.get("responsibility"), str) + or not owner["responsibility"].strip() + ): + errors.append(f"owner {owner_id} needs a responsibility") + + matrix = contract.get("matrix") + if not isinstance(matrix, list) or not matrix: + errors.append("matrix must be a non-empty array") + return errors + + rows: dict[str, dict[str, Any]] = {} + disposition_counts = dict.fromkeys(DISPOSITIONS, 0) + for index, row in enumerate(matrix): + label = f"matrix[{index}]" + if not isinstance(row, dict): + errors.append(f"{label} must be an object") + continue + row_id = row.get("id") + if not isinstance(row_id, str) or not row_id.strip(): + errors.append(f"{label}.id must be a non-empty string") + continue + label = row_id + if row_id in rows: + errors.append(f"duplicate matrix id: {row_id}") + continue + rows[row_id] = row + + disposition = row.get("disposition") + if disposition not in DISPOSITIONS: + errors.append(f"{label}.disposition is invalid") + else: + disposition_counts[disposition] += 1 + if row.get("owner") not in owners: + errors.append(f"{label}.owner must reference owners") + if not _nonempty_strings(row.get("prerequisites")): + errors.append(f"{label}.prerequisites must be non-empty strings") + if not _nonempty_strings(row.get("pass_criteria")): + errors.append(f"{label}.pass_criteria must be non-empty strings") + if not isinstance(row.get("artifact_roles"), list) or not all( + isinstance(role, str) and role.strip() + for role in row.get("artifact_roles", []) + ): + errors.append(f"{label}.artifact_roles must be a string array") + if not isinstance(row.get("commands"), list) or not all( + isinstance(command, str) and command.strip() + for command in row.get("commands", []) + ): + errors.append(f"{label}.commands must be a string array") + if not isinstance(row.get("hardware_dependent"), bool): + errors.append(f"{label}.hardware_dependent must be boolean") + if not isinstance(row.get("credits_gb300_arm64"), bool): + errors.append(f"{label}.credits_gb300_arm64 must be boolean") + if not isinstance(row.get("allow_skip"), bool): + errors.append(f"{label}.allow_skip must be boolean") + if row.get("architecture") not in ARCHITECTURES: + errors.append(f"{label}.architecture is invalid") + + if disposition == "required": + if row.get("allow_skip") is not False: + errors.append(f"{label} is required and cannot allow skip") + if row.get("credits_gb300_arm64") is not True: + errors.append(f"{label} is required and must credit GB300 ARM64") + if row.get("architecture") not in {"any", "native_arm64"}: + errors.append(f"{label} has an invalid required architecture") + if not row.get("commands"): + errors.append(f"{label} is required and needs a command") + if not row.get("artifact_roles"): + errors.append(f"{label} is required and needs artifact roles") + command_text = " ".join(row.get("commands", [])).lower() + if "x86_64" in command_text or "nemoclaw" in command_text: + errors.append(f"{label} cannot use an x64/NemoClaw command") + elif disposition in {"unsupported", "architecture_constrained"}: + if row.get("credits_gb300_arm64") is not False: + errors.append(f"{label} cannot credit GB300 ARM64") + if not isinstance(row.get("reason"), str) or not row["reason"].strip(): + errors.append(f"{label} needs an explicit reason") + elif disposition == "optional" and row.get("credits_gb300_arm64") is not False: + errors.append(f"{label} is optional and cannot credit GB300 ARM64") + + for disposition, count in disposition_counts.items(): + if count == 0: + errors.append(f"matrix needs at least one {disposition} row") + actual_required = { + row_id for row_id, row in rows.items() if row.get("disposition") == "required" + } + if actual_required != REQUIRED_IDS: + missing = sorted(REQUIRED_IDS - actual_required) + extra = sorted(actual_required - REQUIRED_IDS) + errors.append(f"required row set mismatch: missing={missing}, extra={extra}") + + _require_row(errors, rows, "wsl", "unsupported", "none", False) + _require_row( + errors, + rows, + "nemoclaw-native-x64", + "architecture_constrained", + "native_x64", + False, + ) + _require_row( + errors, + rows, + "node-openclaw-x64-installer", + "architecture_constrained", + "native_x64", + False, + ) + _require_row( + errors, + rows, + "local-inference-models", + "optional", + "native_arm64", + False, + ) + openclaw = rows.get("openclaw-arm64-forward", {}) + if not any( + "run-openclaw-forward-test.ps1" in command + for command in openclaw.get("commands", []) + ): + errors.append("openclaw-arm64-forward must invoke the repository harness") + + if repo_root is not None: + for relative in sorted(REQUIRED_REPOSITORY_PATHS): + if not (repo_root / relative).is_file(): + errors.append(f"referenced repository path is missing: {relative}") + + return errors + + +def _require_row( + errors: list[str], + rows: dict[str, dict[str, Any]], + row_id: str, + disposition: str, + architecture: str, + credit: bool, +) -> None: + row = rows.get(row_id) + if row is None: + errors.append(f"matrix row is missing: {row_id}") + return + if row.get("disposition") != disposition: + errors.append(f"{row_id}.disposition must be {disposition}") + if row.get("architecture") != architecture: + errors.append(f"{row_id}.architecture must be {architecture}") + if row.get("credits_gb300_arm64") is not credit: + errors.append(f"{row_id}.credits_gb300_arm64 must be {credit}") + + +def validate_evidence( + contract: dict[str, Any], + record: dict[str, Any], + artifact_root: Path, + expected_base_sha: str, +) -> list[str]: + """Validate one retained evidence record against the static matrix.""" + errors = validate_contract(contract, repo_root=None) + if not SHA_PATTERN.fullmatch(expected_base_sha): + errors.append("expected base SHA must be 40 lowercase hexadecimal characters") + if record.get("schema_version") != 1: + errors.append("evidence schema_version must be 1") + if record.get("contract_id") != CONTRACT_ID: + errors.append(f"evidence contract_id must be {CONTRACT_ID}") + if record.get("base_sha") != expected_base_sha: + errors.append( + "evidence base_sha does not match the expected origin/windows SHA" + ) + if not isinstance(record.get("head_sha"), str) or not SHA_PATTERN.fullmatch( + record["head_sha"] + ): + errors.append("evidence head_sha must be a full lowercase Git SHA") + if ( + not isinstance(record.get("generated_at"), str) + or not record["generated_at"].strip() + ): + errors.append("evidence generated_at is required") + + environment = record.get("environment") + if not isinstance(environment, dict): + errors.append("evidence environment must be an object") + environment = {} + expected_environment = { + "operating_system": "windows", + "architecture": "arm64", + "native": True, + "wsl": False, + "hardware": "GB300", + } + for key, expected in expected_environment.items(): + if environment.get(key) != expected: + errors.append(f"evidence environment.{key} must be {expected!r}") + + results = record.get("results") + if not isinstance(results, list): + errors.append("evidence results must be an array") + return errors + by_id: dict[str, dict[str, Any]] = {} + for index, result in enumerate(results): + if not isinstance(result, dict): + errors.append(f"evidence results[{index}] must be an object") + continue + coverage_id = result.get("coverage_id") + if not isinstance(coverage_id, str) or not coverage_id: + errors.append(f"evidence results[{index}] needs coverage_id") + continue + if coverage_id in by_id: + errors.append(f"duplicate evidence result: {coverage_id}") + continue + by_id[coverage_id] = result + + rows = {row["id"]: row for row in contract["matrix"]} + if set(by_id) != set(rows): + errors.append( + "evidence result IDs must exactly match the contract matrix: " + f"missing={sorted(set(rows) - set(by_id))}, " + f"extra={sorted(set(by_id) - set(rows))}" + ) + + artifact_root = artifact_root.resolve() + for row_id, row in rows.items(): + result = by_id.get(row_id) + if result is None: + continue + disposition = row["disposition"] + status = result.get("status") + artifacts = result.get("artifacts") + if not isinstance(artifacts, list): + errors.append(f"{row_id}: artifacts must be an array") + artifacts = [] + + if disposition == "required": + if status != "pass": + errors.append(f"{row_id}: required result must be pass, got {status}") + if result.get("exit_code") != 0: + errors.append(f"{row_id}: required exit_code must be 0") + if result.get("skip_count") != 0: + errors.append(f"{row_id}: required skip_count must be 0") + if result.get("architecture") != "arm64": + errors.append(f"{row_id}: required evidence must be ARM64") + duration = result.get("duration_seconds") + if not isinstance(duration, (int, float)) or duration < 0: + errors.append(f"{row_id}: duration_seconds must be non-negative") + if ( + not isinstance(result.get("command"), str) + or not result["command"].strip() + ): + errors.append(f"{row_id}: required evidence needs the command") + actual_roles = { + item.get("role") for item in artifacts if isinstance(item, dict) + } + expected_roles = set(row["artifact_roles"]) + if actual_roles != expected_roles: + errors.append( + f"{row_id}: artifact roles mismatch; " + f"expected={sorted(expected_roles)}, actual={sorted(actual_roles)}" + ) + elif disposition == "optional": + if status not in {"pass", "not_run"}: + errors.append(f"{row_id}: optional status must be pass or not_run") + if status == "not_run" and not _has_reason(result): + errors.append(f"{row_id}: not_run requires a reason") + if status == "pass": + if result.get("exit_code") != 0: + errors.append(f"{row_id}: optional pass exit_code must be 0") + if result.get("skip_count") != 0: + errors.append(f"{row_id}: optional pass skip_count must be 0") + if result.get("architecture") != "arm64": + errors.append(f"{row_id}: optional pass evidence must be ARM64") + duration = result.get("duration_seconds") + if not isinstance(duration, (int, float)) or duration < 0: + errors.append( + f"{row_id}: optional pass duration_seconds must be non-negative" + ) + if ( + not isinstance(result.get("command"), str) + or not result["command"].strip() + ): + errors.append(f"{row_id}: optional pass needs the command") + actual_roles = { + item.get("role") for item in artifacts if isinstance(item, dict) + } + expected_roles = set(row["artifact_roles"]) + if actual_roles != expected_roles: + errors.append( + f"{row_id}: optional artifact roles mismatch; " + f"expected={sorted(expected_roles)}, " + f"actual={sorted(actual_roles)}" + ) + else: + if status != "not_applicable": + errors.append(f"{row_id}: excluded status must be not_applicable") + if not _has_reason(result): + errors.append(f"{row_id}: not_applicable requires a reason") + if artifacts: + errors.append(f"{row_id}: excluded rows must not claim artifacts") + + if status == "pass": + errors.extend(_validate_artifacts(row_id, artifacts, artifact_root)) + + return errors + + +def _has_reason(result: dict[str, Any]) -> bool: + return isinstance(result.get("reason"), str) and bool(result["reason"].strip()) + + +def _validate_artifacts( + row_id: str, artifacts: list[object], artifact_root: Path +) -> list[str]: + errors: list[str] = [] + seen_roles: set[str] = set() + for index, artifact in enumerate(artifacts): + label = f"{row_id}.artifacts[{index}]" + if not isinstance(artifact, dict): + errors.append(f"{label} must be an object") + continue + role = artifact.get("role") + relative = artifact.get("path") + expected_hash = artifact.get("sha256") + if not isinstance(role, str) or not role: + errors.append(f"{label}.role is required") + continue + if role in seen_roles: + errors.append(f"{row_id}: duplicate artifact role {role}") + seen_roles.add(role) + if not isinstance(relative, str) or not relative: + errors.append(f"{label}.path is required") + continue + relative_path = Path(relative) + if relative_path.is_absolute() or ".." in relative_path.parts: + errors.append(f"{label}.path must stay under the artifact root") + continue + path = (artifact_root / relative_path).resolve() + if not path.is_relative_to(artifact_root): + errors.append(f"{label}.path resolves outside the artifact root") + continue + if not path.is_file() or path.stat().st_size == 0: + errors.append(f"{label}.path must be a non-empty file: {relative}") + continue + if not isinstance(expected_hash, str) or not re.fullmatch( + r"[0-9a-f]{64}", expected_hash + ): + errors.append(f"{label}.sha256 must be lowercase hexadecimal") + continue + actual_hash = hashlib.sha256(path.read_bytes()).hexdigest() + if actual_hash != expected_hash: + errors.append(f"{label}.sha256 does not match {relative}") + return errors + + +def _print_errors(errors: list[str]) -> int: + if not errors: + return 0 + for error in errors: + print(f"ERROR: {error}", file=sys.stderr) + return 1 + + +def main(argv: list[str] | None = None) -> int: + script_dir = Path(__file__).resolve().parent + repo_root = script_dir.parents[2] + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--manifest", + type=Path, + default=script_dir / "gb300-woa.json", + help="Path to the qualification contract", + ) + subparsers = parser.add_subparsers(dest="mode", required=True) + subparsers.add_parser("contract", help="Validate the static scope contract") + evidence = subparsers.add_parser( + "evidence", help="Validate retained qualification evidence" + ) + evidence.add_argument("--record", type=Path, required=True) + evidence.add_argument("--artifact-root", type=Path, required=True) + evidence.add_argument("--expected-base-sha", required=True) + args = parser.parse_args(argv) + + try: + contract = load_json(args.manifest) + if args.mode == "contract": + errors = validate_contract(contract, repo_root=repo_root) + else: + record = load_json(args.record) + errors = validate_evidence( + contract, + record, + args.artifact_root, + args.expected_base_sha, + ) + except (OSError, ValueError, json.JSONDecodeError) as error: + print(f"ERROR: {error}", file=sys.stderr) + return 1 + + if errors: + return _print_errors(errors) + if args.mode == "contract": + print(f"PASS: {CONTRACT_ID} contract is valid") + else: + print(f"PASS: {CONTRACT_ID} evidence is complete and hash-bound") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/python/gb300_mxc_qualification_test.py b/python/gb300_mxc_qualification_test.py new file mode 100644 index 0000000000..5a1aeeb117 --- /dev/null +++ b/python/gb300_mxc_qualification_test.py @@ -0,0 +1,307 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Regression tests for the GB300 Windows ARM64 MXC qualification contract.""" + +from __future__ import annotations + +import copy +import hashlib +import importlib.util +from pathlib import Path +from typing import TYPE_CHECKING, TypedDict, cast + +if TYPE_CHECKING: + from types import ModuleType + + +REPO_ROOT = Path(__file__).resolve().parents[1] +QUALIFICATION_DIR = REPO_ROOT / "crates" / "openshell-driver-mxc" / "qualification" + + +class ContractRow(TypedDict, total=False): + id: str + disposition: str + allow_skip: bool + architecture: str + commands: list[str] + artifact_roles: list[str] + reason: str + + +class Contract(TypedDict): + matrix: list[ContractRow] + + +class EvidenceArtifact(TypedDict): + role: str + path: str + sha256: str + + +class EvidenceResult(TypedDict, total=False): + coverage_id: str + status: str + architecture: str + command: str + exit_code: int + skip_count: int + duration_seconds: float + artifacts: list[EvidenceArtifact] + reason: str + + +class EvidenceRecord(TypedDict): + schema_version: int + contract_id: str + base_sha: str + head_sha: str + generated_at: str + environment: dict[str, object] + results: list[EvidenceResult] + + +def _load_validator() -> ModuleType: + path = QUALIFICATION_DIR / "validate.py" + spec = importlib.util.spec_from_file_location("gb300_mxc_validator", path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +VALIDATOR = _load_validator() + + +def _load_contract() -> Contract: + return cast("Contract", VALIDATOR.load_json(QUALIFICATION_DIR / "gb300-woa.json")) + + +def _valid_evidence(contract: Contract, artifact_root: Path) -> EvidenceRecord: + results: list[EvidenceResult] = [] + for row in contract["matrix"]: + row_id = row["id"] + disposition = row["disposition"] + if disposition == "required": + artifacts: list[EvidenceArtifact] = [] + for role in row["artifact_roles"]: + artifact_path = artifact_root / f"{row_id}-{role}.txt" + artifact_path.write_text(f"evidence for {row_id}\n", encoding="utf-8") + artifacts.append( + { + "role": role, + "path": artifact_path.name, + "sha256": hashlib.sha256( + artifact_path.read_bytes() + ).hexdigest(), + } + ) + results.append( + { + "coverage_id": row_id, + "status": "pass", + "architecture": "arm64", + "command": row["commands"][0], + "exit_code": 0, + "skip_count": 0, + "duration_seconds": 0.1, + "artifacts": artifacts, + } + ) + elif disposition == "optional": + results.append( + { + "coverage_id": row_id, + "status": "not_run", + "reason": "Not selected for the required qualification gate.", + "artifacts": [], + } + ) + else: + results.append( + { + "coverage_id": row_id, + "status": "not_applicable", + "reason": row["reason"], + "artifacts": [], + } + ) + + return { + "schema_version": 1, + "contract_id": "nvbug-6643699-gb300-woa-mxc", + "base_sha": "a" * 40, + "head_sha": "b" * 40, + "generated_at": "2026-09-18T12:00:00Z", + "environment": { + "operating_system": "windows", + "architecture": "arm64", + "native": True, + "wsl": False, + "hardware": "GB300", + }, + "results": results, + } + + +def test_runner_requires_explicit_canonical_source_provenance() -> None: + source = (QUALIFICATION_DIR / "run-gb300-woa.ps1").read_text(encoding="utf-8") + + assert "ExpectedBaseSha is required" in source + assert "$ExpectedBaseSha = $fetchedBaseSha" not in source + assert "https://github.com/nvidia/openshell.git" in source.lower() + assert "origin must be the canonical NVIDIA/OpenShell" in source + + +def test_runner_hash_binds_a_versioned_openclaw_package() -> None: + source = (QUALIFICATION_DIR / "run-gb300-woa.ps1").read_text(encoding="utf-8") + + assert "package.json must declare a non-empty version" in source + assert "openclaw_package_sha256" in source + assert "openclaw_package_json_sha256" in source + assert "Get-ChildItem -LiteralPath $OpenClawInstallDir -Force" in source + assert "Copy-Item -LiteralPath $_.FullName" in source + assert "$openClawPreRunSha256 = Get-DirectorySha256 $OpenClawInstallDir" in source + assert "$openClawPostRunSha256 = Get-DirectorySha256 $OpenClawInstallDir" in source + + +def test_gb300_gate_invokes_only_required_real_mxc_tests() -> None: + source = (REPO_ROOT / "tasks" / "scripts" / "windows-msvc.ps1").read_text( + encoding="utf-8" + ) + + gb300_function = source.split("function Invoke-MxcGb300Tests", 1)[1].split( + "function Get-Sha256", 1 + )[0] + + assert "$test -- --ignored --exact --test-threads=1 --nocapture" in gb300_function + assert ( + "--target $RustTarget -- --ignored --test-threads=1 --nocapture" + not in gb300_function + ) + + +def test_repository_contract_is_valid() -> None: + errors = VALIDATOR.validate_contract(_load_contract(), repo_root=REPO_ROOT) + assert errors == [] + + +def test_required_coverage_cannot_be_skip_safe() -> None: + contract = copy.deepcopy(_load_contract()) + required = next( + row for row in contract["matrix"] if row["id"] == "processcontainer-real-mxc" + ) + required["allow_skip"] = True + + errors = VALIDATOR.validate_contract(contract) + + assert "processcontainer-real-mxc is required and cannot allow skip" in errors + + +def test_required_coverage_cannot_be_x64_or_nemoclaw() -> None: + contract = copy.deepcopy(_load_contract()) + required = next( + row for row in contract["matrix"] if row["id"] == "openclaw-arm64-forward" + ) + required["architecture"] = "native_x64" + required["commands"] = ["run-nemoclaw-qualification.ps1"] + + errors = VALIDATOR.validate_contract(contract) + + assert "openclaw-arm64-forward has an invalid required architecture" in errors + assert "openclaw-arm64-forward cannot use an x64/NemoClaw command" in errors + + +def test_nemoclaw_exclusion_is_mandatory() -> None: + contract = copy.deepcopy(_load_contract()) + contract["matrix"] = [ + row for row in contract["matrix"] if row["id"] != "nemoclaw-native-x64" + ] + + errors = VALIDATOR.validate_contract(contract) + + assert "matrix row is missing: nemoclaw-native-x64" in errors + + +def test_complete_hash_bound_evidence_passes(tmp_path: Path) -> None: + contract = _load_contract() + record = _valid_evidence(contract, tmp_path) + + errors = VALIDATOR.validate_evidence( + contract, + record, + artifact_root=tmp_path, + expected_base_sha="a" * 40, + ) + + assert errors == [] + + +def test_required_skip_fails_evidence_validation(tmp_path: Path) -> None: + contract = _load_contract() + record = _valid_evidence(contract, tmp_path) + result = next( + item + for item in record["results"] + if item["coverage_id"] == "processcontainer-real-mxc" + ) + result["status"] = "skip" + result["skip_count"] = 1 + + errors = VALIDATOR.validate_evidence( + contract, + record, + artifact_root=tmp_path, + expected_base_sha="a" * 40, + ) + + assert "processcontainer-real-mxc: required result must be pass, got skip" in errors + assert "processcontainer-real-mxc: required skip_count must be 0" in errors + + +def test_optional_pass_must_carry_declared_arm64_artifacts(tmp_path: Path) -> None: + contract = _load_contract() + record = _valid_evidence(contract, tmp_path) + result = next( + item + for item in record["results"] + if item["coverage_id"] == "local-inference-models" + ) + result.update( + { + "status": "pass", + "architecture": "native_x64", + "command": "run-model-evaluation.ps1", + "exit_code": 0, + "skip_count": 0, + "duration_seconds": 1.0, + "artifacts": [], + } + ) + result.pop("reason") + + errors = VALIDATOR.validate_evidence( + contract, + record, + artifact_root=tmp_path, + expected_base_sha="a" * 40, + ) + + assert "local-inference-models: optional pass evidence must be ARM64" in errors + assert any("optional artifact roles mismatch" in error for error in errors) + + +def test_artifact_hash_mismatch_fails_evidence_validation(tmp_path: Path) -> None: + contract = _load_contract() + record = _valid_evidence(contract, tmp_path) + artifact = record["results"][0]["artifacts"][0] + (tmp_path / artifact["path"]).write_text("tampered\n", encoding="utf-8") + + errors = VALIDATOR.validate_evidence( + contract, + record, + artifact_root=tmp_path, + expected_base_sha="a" * 40, + ) + + assert any("sha256 does not match" in error for error in errors) diff --git a/tasks/scripts/windows-msvc.ps1 b/tasks/scripts/windows-msvc.ps1 index 92569c57fd..54bb721eac 100644 --- a/tasks/scripts/windows-msvc.ps1 +++ b/tasks/scripts/windows-msvc.ps1 @@ -6,7 +6,7 @@ [CmdletBinding()] param( [Parameter(Mandatory = $true, Position = 0)] - [ValidateSet("check", "lint", "build", "test", "test-precommit", "test-unsupported", "test-mxc-real", "artifacts", "ci")] + [ValidateSet("check", "lint", "build", "test", "test-precommit", "test-unsupported", "test-mxc-real", "test-mxc-gb300", "artifacts", "ci")] [string] $Action, [Parameter(Position = 1)] @@ -591,6 +591,49 @@ function Invoke-MxcRealTests([string] $RustTarget) { -LogName "test-$RustTarget-mxc-real.log" } +function Invoke-MxcGb300Tests([string] $RustTarget) { + Assert-NativeTestTarget $RustTarget + if ($RustTarget -ne "aarch64-pc-windows-msvc") { + throw "The GB300 qualification contract requires native ARM64; target $RustTarget cannot receive credit." + } + + $tests = @( + "dryrun_accepts_minimal_processcontainer_config", + "dryrun_accepts_processcontainer_ui_policy_matrix", + "dryrun_accepts_network_block_without_proxy", + "dryrun_accepts_localhost_proxy_shape", + "dryrun_rejects_host_port_proxy_shape", + "dryrun_rejects_unknown_containment", + "dryrun_accepts_split_policy_output", + "pc_oneshot_in_policy_write_succeeds", + "pc_https_egress_reads_injected_ca_bundle", + "pc_oneshot_out_of_policy_write_denied" + ) + $logName = "test-$RustTarget-mxc-gb300.log" + $logPath = Join-Path $LogDir $logName + $combinedLog = @() + foreach ($test in $tests) { + $testLogName = "test-$RustTarget-mxc-gb300-$test.log" + Invoke-VsCargo ` + -RustTarget $RustTarget ` + -CargoArgs "cargo test -p openshell-driver-mxc --test wxc_exec_real --target $RustTarget $test -- --ignored --exact --test-threads=1 --nocapture" ` + -LogName $testLogName + + $testLogPath = Join-Path $LogDir $testLogName + $testLog = @(Get-Content -LiteralPath $testLogPath) + $combinedLog += "===== $test =====" + $combinedLog += $testLog + if ($testLog -match 'SKIP:') { + throw "Required GB300 MXC test $test skipped. See $testLogPath" + } + $escapedTest = [Regex]::Escape($test) + if (-not ($testLog -match "^test $escapedTest \.\.\. ok$")) { + throw "Required GB300 MXC test $test did not report a passing assertion. See $testLogPath" + } + } + $combinedLog | Out-File -LiteralPath $logPath -Encoding utf8 +} + function Get-Sha256([string] $Path) { $stream = [System.IO.File]::OpenRead($Path) try { @@ -635,13 +678,13 @@ if ($Action -eq "ci" -and (Get-HostArch) -ne "amd64") { } $targets = Get-SelectedTargets $Target -if ($Action -in @("test", "test-precommit", "test-unsupported", "test-mxc-real")) { +if ($Action -in @("test", "test-precommit", "test-unsupported", "test-mxc-real", "test-mxc-gb300")) { foreach ($rustTarget in $targets) { Assert-NativeTestTarget $rustTarget } } -if ($Action -in @("check", "lint", "build", "test", "test-precommit", "test-unsupported", "test-mxc-real", "ci")) { +if ($Action -in @("check", "lint", "build", "test", "test-precommit", "test-unsupported", "test-mxc-real", "test-mxc-gb300", "ci")) { $z3Features = Configure-Z3 $Z3WorkspaceFeatures = $z3Features.WorkspaceFeatures $Z3ServerFeatures = $z3Features.ServerFeatures @@ -689,6 +732,11 @@ switch ($Action) { Invoke-MxcRealTests $rustTarget } } + "test-mxc-gb300" { + foreach ($rustTarget in $targets) { + Invoke-MxcGb300Tests $rustTarget + } + } "artifacts" { Show-Artifacts $targets } diff --git a/tasks/windows.toml b/tasks/windows.toml index 7509a934c6..fb8e64d52f 100644 --- a/tasks/windows.toml +++ b/tasks/windows.toml @@ -85,6 +85,20 @@ description = "Run native ARM64 real-wxc-exec Tier-2 integration tests (skip-saf run = "echo 'windows:* tasks require a Windows MSVC host' && exit 1" run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 test-mxc-real aarch64-pc-windows-msvc" +["windows:test:mxc-gb300:arm64"] +description = "Run native ARM64 GB300 real-MXC tests (strict: any required missing prerequisite or SKIP fails)" +run = "echo 'windows:* tasks require a Windows MSVC host' && exit 1" +run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 test-mxc-gb300 aarch64-pc-windows-msvc" + +["windows:qualify:mxc:gb300:contract"] +description = "Validate the NVBug 6643699 GB300 Windows ARM64 MXC scope contract" +run = "uv run python crates/openshell-driver-mxc/qualification/validate.py contract" + +["windows:qualify:mxc:gb300"] +description = "Execute and validate the complete GB300 Windows ARM64 MXC qualification contract" +run = "echo 'GB300 qualification requires a native Windows ARM64 host' && exit 1" +run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File crates/openshell-driver-mxc/qualification/run-gb300-woa.ps1" + ["windows:e2e:mxc"] description = "Run MXC Tier-3 e2e scenario runner against real wxc-exec (probe-gated; skip-safe on hosts without the binary)" run = "echo 'windows:* tasks require a Windows MSVC host' && exit 1" From 69d73a7eaf4bb8326da7964651fca3294ba6a2ec Mon Sep 17 00:00:00 2001 From: Prekshi Vyas <34834085+prekshivyas@users.noreply.github.com> Date: Tue, 22 Sep 2026 10:55:34 -0700 Subject: [PATCH 10/42] fix(ocsf): attribute MXC proxy events to sandboxes (#3434) * fix(ocsf): attribute MXC proxy events to sandboxes NVBug 6783086 Signed-off-by: Prekshi Vyas * fix(ocsf): preserve sandbox context across proxy denials Signed-off-by: Prekshi Vyas --------- Signed-off-by: Prekshi Vyas --- architecture/security-policy.md | 7 + .../openshell-supervisor-network/src/host.rs | 76 ++- .../src/l7/mod.rs | 29 +- .../src/l7/relay.rs | 1 + .../src/l7/websocket.rs | 8 +- .../openshell-supervisor-network/src/proxy.rs | 444 +++++++++++++++--- .../src/proxy/relay.rs | 86 +++- .../src/proxy/tests/compatibility.rs | 6 + 8 files changed, 555 insertions(+), 102 deletions(-) diff --git a/architecture/security-policy.md b/architecture/security-policy.md index 655886d87c..7503532ed8 100644 --- a/architecture/security-policy.md +++ b/architecture/security-policy.md @@ -418,6 +418,13 @@ middleware, token grants, credential rewriting, policy-generation checks, and the HTTP relay have succeeded so a later denial cannot coexist with an allowed record for the same request. +Each Windows MXC sandbox owns a separate host proxy. That proxy carries an +immutable per-sandbox OCSF context so proxy lifecycle events and top-level +CONNECT/forward decisions use the correct `container.uid` and `container.name` +even when one gateway serves multiple sandboxes concurrently. The process-wide +sandbox context is only suitable for the one-supervisor-per-sandbox runtime +model. + Never log secrets, credentials, bearer tokens, or query parameters in OCSF messages. OCSF JSONL output may be shipped to external systems. The gateway-local OCSF JSONL file sink is restricted to the Windows/MXC path diff --git a/crates/openshell-supervisor-network/src/host.rs b/crates/openshell-supervisor-network/src/host.rs index eb09634772..ea4082b9fa 100644 --- a/crates/openshell-supervisor-network/src/host.rs +++ b/crates/openshell-supervisor-network/src/host.rs @@ -22,7 +22,7 @@ use openshell_core::proposals::AgentProposals; use openshell_core::proto::SandboxPolicy as ProtoSandboxPolicy; use openshell_core::provider_credentials::ProviderCredentialState; use openshell_ocsf::{ - ConfigStateChangeBuilder, SeverityId, StateId, StatusId, ctx::ctx as ocsf_ctx, ocsf_emit, + ConfigStateChangeBuilder, EventContext, SeverityId, StateId, StatusId, ocsf_emit, }; use tokio::sync::mpsc::UnboundedSender; @@ -68,7 +68,11 @@ pub struct HostProxyConfig { /// Per-sandbox client authentication. Host-side MXC proxies must set this /// so another sandbox cannot borrow this proxy's identity and policy. pub client_auth: HostProxyClientAuth, + /// Stable sandbox identifier used to attribute host-proxy OCSF events. + /// Required and non-empty for every host-side proxy. pub sandbox_id: Option, + /// Sandbox display name used to attribute host-proxy OCSF events. + /// Required and non-empty for every host-side proxy. pub sandbox_name: Option, pub openshell_endpoint: Option, pub provider_credentials: Option, @@ -99,6 +103,36 @@ impl HostProxyHandle { } } +fn host_proxy_event_context(config: &HostProxyConfig) -> Result { + let sandbox_id = config + .sandbox_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| miette::miette!("host proxy requires a non-empty sandbox_id"))?; + let sandbox_name = config + .sandbox_name + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| miette::miette!("host proxy requires a non-empty sandbox_name"))?; + + Ok(EventContext { + sandbox_id: sandbox_id.to_string(), + sandbox_name: sandbox_name.to_string(), + container_image: String::new(), + hostname: std::env::var("COMPUTERNAME") + .or_else(|_| std::env::var("HOSTNAME")) + .ok() + .map(|hostname| hostname.trim().to_string()) + .filter(|hostname| !hostname.is_empty()) + .unwrap_or_else(|| "openshell-gateway".to_string()), + product_version: env!("CARGO_PKG_VERSION").to_string(), + proxy_ip: config.bind_addr.ip(), + proxy_port: config.bind_addr.port(), + }) +} + /// Start a host-side proxy for one sandbox. /// /// Linux supervisor mode should continue to use `run::run_networking`; this API @@ -117,6 +151,7 @@ pub async fn start_host_proxy(config: HostProxyConfig) -> Result Result Result { ocsf_emit!( - ConfigStateChangeBuilder::new(ocsf_ctx()) + ConfigStateChangeBuilder::new(&event_context) .severity(SeverityId::High) .status(StatusId::Failure) .state(StateId::Disabled, "disabled") @@ -174,7 +209,7 @@ pub async fn start_host_proxy(config: HostProxyConfig) -> Result { ocsf_emit!( - ConfigStateChangeBuilder::new(ocsf_ctx()) + ConfigStateChangeBuilder::new(&event_context) .severity(SeverityId::High) .status(StatusId::Failure) .state(StateId::Disabled, "disabled") @@ -189,7 +224,7 @@ pub async fn start_host_proxy(config: HostProxyConfig) -> Result { ocsf_emit!( - ConfigStateChangeBuilder::new(ocsf_ctx()) + ConfigStateChangeBuilder::new(&event_context) .severity(SeverityId::High) .status(StatusId::Failure) .state(StateId::Disabled, "disabled") @@ -203,7 +238,7 @@ pub async fn start_host_proxy(config: HostProxyConfig) -> Result { ocsf_emit!( - ConfigStateChangeBuilder::new(ocsf_ctx()) + ConfigStateChangeBuilder::new(&event_context) .severity(SeverityId::High) .status(StatusId::Failure) .state(StateId::Disabled, "disabled") @@ -218,7 +253,8 @@ pub async fn start_host_proxy(config: HostProxyConfig) -> Result String { let mut request = String::from( "GET http://policy.local/v1/policy/current HTTP/1.1\r\nHost: policy.local\r\n", diff --git a/crates/openshell-supervisor-network/src/l7/mod.rs b/crates/openshell-supervisor-network/src/l7/mod.rs index 2085b46681..d0ecb1e18a 100644 --- a/crates/openshell-supervisor-network/src/l7/mod.rs +++ b/crates/openshell-supervisor-network/src/l7/mod.rs @@ -35,6 +35,7 @@ use std::sync::{Arc, Mutex}; use crate::opa::PolicyGenerationGuard; pub(crate) fn build_credential_endpoint_mismatch_finding( + event_context: &openshell_ocsf::EventContext, policy_name: &str, host: &str, protocol: Option<&str>, @@ -50,7 +51,7 @@ pub(crate) fn build_credential_endpoint_mismatch_finding( } evidence.push(("disposition", "denied")); - DetectionFindingBuilder::new(openshell_ocsf::ctx::ctx()) + DetectionFindingBuilder::new(event_context) .activity(ActivityId::Open) .action(ActionId::Denied) .disposition(DispositionId::Blocked) @@ -413,8 +414,13 @@ pub fn parse_l7_config(val: ®orus::Value) -> Option { }) } -pub(crate) fn emit_uninspected_credential_finding(host: &str, policy_name: &str, surface: &str) { - let event = openshell_ocsf::DetectionFindingBuilder::new(openshell_ocsf::ctx::ctx()) +pub(crate) fn build_uninspected_credential_finding( + event_context: &openshell_ocsf::EventContext, + host: &str, + policy_name: &str, + surface: &str, +) -> openshell_ocsf::OcsfEvent { + openshell_ocsf::DetectionFindingBuilder::new(event_context) .severity(openshell_ocsf::SeverityId::High) .finding_info(openshell_ocsf::FindingInfo::new( "openshell.credentials.traffic_uninspectable", @@ -427,8 +433,21 @@ pub(crate) fn emit_uninspected_credential_finding(host: &str, policy_name: &str, ("disposition", "denied"), ]) .message("Uninspected credential-bearing traffic denied") - .build(); - openshell_ocsf::ocsf_emit!(event); + .build() +} + +pub(crate) fn emit_uninspected_credential_finding( + event_context: &openshell_ocsf::EventContext, + host: &str, + policy_name: &str, + surface: &str, +) { + openshell_ocsf::ocsf_emit!(build_uninspected_credential_finding( + event_context, + host, + policy_name, + surface, + )); } impl L7EndpointConfig { diff --git a/crates/openshell-supervisor-network/src/l7/relay.rs b/crates/openshell-supervisor-network/src/l7/relay.rs index 31acd3a7fd..b319c54112 100644 --- a/crates/openshell-supervisor-network/src/l7/relay.rs +++ b/crates/openshell-supervisor-network/src/l7/relay.rs @@ -371,6 +371,7 @@ fn build_credential_resolution_event( fn build_credential_endpoint_mismatch_finding(ctx: &L7EvalContext) -> openshell_ocsf::OcsfEvent { crate::l7::build_credential_endpoint_mismatch_finding( + openshell_ocsf::ctx::ctx(), &ctx.policy_name, &ctx.host, None, diff --git a/crates/openshell-supervisor-network/src/l7/websocket.rs b/crates/openshell-supervisor-network/src/l7/websocket.rs index dab11a1112..60f2b1c535 100644 --- a/crates/openshell-supervisor-network/src/l7/websocket.rs +++ b/crates/openshell-supervisor-network/src/l7/websocket.rs @@ -636,6 +636,7 @@ fn emit_credential_endpoint_mismatch(host: &str, port: u16, policy_name: &str) { .build() ); ocsf_emit!(crate::l7::build_credential_endpoint_mismatch_finding( + openshell_ocsf::ctx::ctx(), policy_name, host, Some("websocket"), @@ -1387,7 +1388,12 @@ fn emit_uninspected_credential_denial(host: &str, port: u16, policy_name: &str, )) .build(); ocsf_emit!(event); - crate::l7::emit_uninspected_credential_finding(host, policy_name, surface); + crate::l7::emit_uninspected_credential_finding( + openshell_ocsf::ctx::ctx(), + host, + policy_name, + surface, + ); } fn inspect_websocket_text_message( diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index c6dc767d95..0bfea5731d 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -31,8 +31,8 @@ use openshell_core::policy::ProxyPolicy; use openshell_core::provider_credentials::{ProviderCredentialSnapshot, ProviderCredentialState}; use openshell_core::secrets::{self, SecretResolver, rewrite_header_line_checked}; use openshell_ocsf::{ - ActionId, ActivityId, DispositionId, Endpoint, HttpActivityBuilder, HttpRequest, HttpResponse, - NetworkActivityBuilder, Process, SeverityId, StatusId, Url as OcsfUrl, ocsf_emit, + ActionId, ActivityId, DispositionId, Endpoint, EventContext, HttpActivityBuilder, HttpRequest, + HttpResponse, NetworkActivityBuilder, Process, SeverityId, StatusId, Url as OcsfUrl, ocsf_emit, }; #[cfg(target_os = "linux")] use std::mem::size_of; @@ -74,12 +74,13 @@ const FORWARD_ENCODED_SLASH_REJECTION_DETAIL: &str = const SIDECAR_SUPERVISOR_TOPOLOGY: &str = "sidecar"; fn build_credential_endpoint_mismatch_event( + event_context: &EventContext, method: &str, host: &str, port: u16, policy_name: &str, ) -> openshell_ocsf::OcsfEvent { - HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) + HttpActivityBuilder::new(event_context) .activity(ActivityId::for_http_method(method)) .http_request(HttpRequest { http_method: method.parse().expect("HTTP method parsing is infallible"), @@ -99,10 +100,18 @@ fn build_credential_endpoint_mismatch_event( .build() } -fn emit_credential_endpoint_mismatch(method: &str, host: &str, port: u16, policy_name: &str) { - let event = build_credential_endpoint_mismatch_event(method, host, port, policy_name); +fn emit_credential_endpoint_mismatch( + event_context: &EventContext, + method: &str, + host: &str, + port: u16, + policy_name: &str, +) { + let event = + build_credential_endpoint_mismatch_event(event_context, method, host, port, policy_name); ocsf_emit!(event); let finding = crate::l7::build_credential_endpoint_mismatch_finding( + event_context, policy_name, host, None, @@ -175,6 +184,9 @@ pub(crate) enum ProxyIdentityMode { binary_path: PathBuf, binary_sha256: String, required_proxy_authorization: Option>, + /// Per-sandbox context for host-side proxies. The process-wide OCSF + /// context cannot identify one sandbox when a gateway hosts many. + event_context: Option>, }, } @@ -206,9 +218,33 @@ impl ProxyIdentityMode { binary_path, binary_sha256, required_proxy_authorization, + event_context: None, }) } + #[cfg(target_os = "windows")] + pub(super) fn with_event_context(mut self, context: EventContext) -> Self { + match &mut self { + #[cfg(target_os = "linux")] + Self::Procfs { .. } => {} + Self::Static { event_context, .. } => { + *event_context = Some(Arc::new(context)); + } + } + self + } + + fn event_context(&self) -> &EventContext { + match self { + #[cfg(any(not(target_os = "linux"), test))] + Self::Static { + event_context: Some(context), + .. + } => context, + _ => openshell_ocsf::ctx::ctx(), + } + } + fn required_proxy_authorization(&self) -> Option<&str> { match self { #[cfg(target_os = "linux")] @@ -269,8 +305,9 @@ impl ProxyHandle { let listener = TcpListener::bind(http_addr).await.into_diagnostic()?; let local_addr = listener.local_addr().into_diagnostic()?; + let event_context = Arc::new(identity_mode.event_context().clone()); { - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + let event = NetworkActivityBuilder::new(&event_context) .activity(ActivityId::Listen) .severity(SeverityId::Informational) .status(StatusId::Success) @@ -307,22 +344,21 @@ impl ProxyHandle { // access. let upstream_proxy: Arc> = Arc::new( UpstreamProxyConfig::from_args(upstream_proxy_args).map_err(|err| { - let event = - openshell_ocsf::ConfigStateChangeBuilder::new(openshell_ocsf::ctx::ctx()) - .severity(SeverityId::High) - .status(StatusId::Failure) - .state(openshell_ocsf::StateId::Disabled, "invalid") - .message(format!( - "Upstream corporate proxy configuration invalid; \ + let event = openshell_ocsf::ConfigStateChangeBuilder::new(&event_context) + .severity(SeverityId::High) + .status(StatusId::Failure) + .state(openshell_ocsf::StateId::Disabled, "invalid") + .message(format!( + "Upstream corporate proxy configuration invalid; \ refusing to start: {err}" - )) - .build(); + )) + .build(); ocsf_emit!(event); miette::miette!("invalid upstream corporate proxy configuration: {err}") })?, ); if let Some(cfg) = upstream_proxy.as_ref() { - let event = openshell_ocsf::ConfigStateChangeBuilder::new(openshell_ocsf::ctx::ctx()) + let event = openshell_ocsf::ConfigStateChangeBuilder::new(&event_context) .severity(SeverityId::Informational) .status(StatusId::Success) .state(openshell_ocsf::StateId::Enabled, "enabled") @@ -392,6 +428,7 @@ impl ProxyHandle { let dtx = denial_tx.clone(); let atx = activity_tx.clone(); let endpoint_observations = endpoint_observation_tx.clone(); + let event_context = event_context.clone(); tokio::spawn(async move { #[allow(clippy::large_futures)] if let Err(err) = handle_tcp_connection( @@ -412,7 +449,7 @@ impl ProxyHandle { ) .await { - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + let event = NetworkActivityBuilder::new(&event_context) .activity(ActivityId::Fail) .severity(SeverityId::Low) .status(StatusId::Failure) @@ -429,7 +466,7 @@ impl ProxyHandle { &mut consecutive_unknown_errors, ) { AcceptAction::Terminal => { - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + let event = NetworkActivityBuilder::new(&event_context) .activity(ActivityId::Fail) .severity(SeverityId::High) .status(StatusId::Failure) @@ -441,7 +478,7 @@ impl ProxyHandle { break; } AcceptAction::Retry { backoff, severity } => { - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + let event = NetworkActivityBuilder::new(&event_context) .activity(ActivityId::Fail) .severity(severity) .status(StatusId::Failure) @@ -790,7 +827,14 @@ async fn handle_transparent_tcp_connection( } )); emit_activity(&activity_tx, false, "transparent_tcp"); - relay::relay_tcp(&mut client, &mut upstream, &generation_guard, &ctx).await + relay::relay_tcp( + &mut client, + &mut upstream, + &generation_guard, + &ctx, + openshell_ocsf::ctx::ctx(), + ) + .await } #[cfg(any(target_os = "linux", test))] @@ -1366,6 +1410,7 @@ fn emit_denial_simple( #[allow(clippy::too_many_arguments)] fn build_connect_allow_ocsf_event( + event_context: &EventContext, peer_addr: SocketAddr, host: &str, port: u16, @@ -1381,7 +1426,7 @@ fn build_connect_allow_ocsf_event( } else { "CONNECT" }; - NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + NetworkActivityBuilder::new(event_context) .activity(ActivityId::Open) .action(ActionId::Allowed) .disposition(DispositionId::Allowed) @@ -1397,6 +1442,7 @@ fn build_connect_allow_ocsf_event( #[allow(clippy::too_many_arguments)] fn build_forward_allow_ocsf_event( + event_context: &EventContext, peer_addr: SocketAddr, method: &str, host: &str, @@ -1408,7 +1454,7 @@ fn build_forward_allow_ocsf_event( cmdline: &str, policy: &str, ) -> openshell_ocsf::OcsfEvent { - HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) + HttpActivityBuilder::new(event_context) .activity(ActivityId::for_http_method(method)) .action(ActionId::Allowed) .disposition(DispositionId::Allowed) @@ -1426,8 +1472,11 @@ fn build_forward_allow_ocsf_event( .build() } -fn build_forward_parse_error_ocsf_event(path: &str) -> openshell_ocsf::OcsfEvent { - HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) +fn build_forward_parse_error_ocsf_event( + event_context: &EventContext, + path: &str, +) -> openshell_ocsf::OcsfEvent { + HttpActivityBuilder::new(event_context) .activity(ActivityId::Other) .http_response(HttpResponse { code: StatusCode::BAD_REQUEST.as_u16(), @@ -1443,12 +1492,13 @@ fn build_forward_parse_error_ocsf_event(path: &str) -> openshell_ocsf::OcsfEvent /// contain credentials; the method and generated response provide the HTTP /// context required by OCSF 1.8. fn build_forward_unsupported_scheme_ocsf_event( + event_context: &EventContext, method: &str, scheme: &str, host: &str, port: u16, ) -> openshell_ocsf::OcsfEvent { - HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) + HttpActivityBuilder::new(event_context) .activity(ActivityId::for_http_method(method)) .http_request(HttpRequest { http_method: method.parse().expect("HTTP method parsing is infallible"), @@ -1470,6 +1520,7 @@ fn build_forward_unsupported_scheme_ocsf_event( #[allow(clippy::too_many_arguments)] fn build_forward_l7_parse_rejection_ocsf_event( + event_context: &EventContext, peer_addr: SocketAddr, method: &str, host: &str, @@ -1482,7 +1533,7 @@ fn build_forward_l7_parse_rejection_ocsf_event( policy: &str, detail: &str, ) -> openshell_ocsf::OcsfEvent { - HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) + HttpActivityBuilder::new(event_context) .activity(ActivityId::for_http_method(method)) .action(ActionId::Denied) .disposition(DispositionId::Blocked) @@ -1505,6 +1556,7 @@ fn build_forward_l7_parse_rejection_ocsf_event( #[allow(clippy::too_many_arguments)] fn build_forward_policy_deny_ocsf_event( + event_context: &EventContext, peer_addr: SocketAddr, method: &str, host: &str, @@ -1516,7 +1568,7 @@ fn build_forward_policy_deny_ocsf_event( cmdline: &str, reason: &str, ) -> openshell_ocsf::OcsfEvent { - HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) + HttpActivityBuilder::new(event_context) .activity(ActivityId::Other) .action(ActionId::Denied) .disposition(DispositionId::Blocked) @@ -1556,6 +1608,7 @@ fn endpoint_result_for_destination_failure(kind: DestinationDenialKind) -> Endpo #[allow(clippy::too_many_arguments)] fn build_connect_destination_deny_ocsf_event( + event_context: &EventContext, denial: &DestinationDenial, peer_addr: SocketAddr, host: &str, @@ -1572,7 +1625,7 @@ fn build_connect_destination_deny_ocsf_event( format!("CONNECT blocked: {detail} for {host}:{port}") }; - NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + NetworkActivityBuilder::new(event_context) .activity(ActivityId::Open) .action(ActionId::Denied) .disposition(DispositionId::Blocked) @@ -1589,6 +1642,7 @@ fn build_connect_destination_deny_ocsf_event( #[allow(clippy::too_many_arguments)] fn build_forward_destination_deny_ocsf_event( + event_context: &EventContext, denial: &DestinationDenial, peer_addr: SocketAddr, method: &str, @@ -1608,7 +1662,7 @@ fn build_forward_destination_deny_ocsf_event( detail }; - HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) + HttpActivityBuilder::new(event_context) .activity(ActivityId::for_http_method(method)) .action(ActionId::Denied) .disposition(DispositionId::Blocked) @@ -1629,6 +1683,7 @@ fn build_forward_destination_deny_ocsf_event( #[allow(clippy::too_many_arguments)] async fn deny_connect_destination( + event_context: &EventContext, client: &mut TcpStream, denial: &DestinationDenial, peer_addr: SocketAddr, @@ -1644,7 +1699,15 @@ async fn deny_connect_destination( ) -> Result<()> { let detail = destination_denial_detail(denial.kind); ocsf_emit!(build_connect_destination_deny_ocsf_event( - denial, peer_addr, host, port, binary, pid, ancestors, cmdline, + event_context, + denial, + peer_addr, + host, + port, + binary, + pid, + ancestors, + cmdline, )); emit_denial( @@ -1675,6 +1738,7 @@ async fn deny_connect_destination( #[allow(clippy::too_many_arguments)] async fn deny_forward_destination( + event_context: &EventContext, client: &mut TcpStream, denial: &DestinationDenial, peer_addr: SocketAddr, @@ -1693,7 +1757,18 @@ async fn deny_forward_destination( ) -> Result<()> { let detail = destination_denial_detail(denial.kind); ocsf_emit!(build_forward_destination_deny_ocsf_event( - denial, peer_addr, method, host, port, path, binary, pid, ancestors, cmdline, policy, + event_context, + denial, + peer_addr, + method, + host, + port, + path, + binary, + pid, + ancestors, + cmdline, + policy, )); emit_denial_simple( @@ -1782,6 +1857,7 @@ async fn handle_tcp_connection( activity_tx: Option, endpoint_observation_tx: Option, ) -> Result<()> { + let event_context = identity_mode.event_context().clone(); // Capture authority before request parsing or policy selection can yield. // A later inventory installation cannot acquire this connection's result. let endpoint_observation_context = endpoint_observation_tx @@ -1935,7 +2011,7 @@ async fn handle_tcp_connection( // Allowed connections are logged after the L7 config check (below) // so we can distinguish CONNECT (L4-only) from CONNECT_L7 (L7 follows). if matches!(decision.action, NetworkAction::Deny { .. }) { - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + let event = NetworkActivityBuilder::new(&event_context) .activity(ActivityId::Open) .action(ActionId::Denied) .disposition(DispositionId::Blocked) @@ -1981,6 +2057,7 @@ async fn handle_tcp_connection( Ok(guard) => guard, Err(error) => { reject_stale_connect_policy( + &event_context, &mut client, &host_lc, port, @@ -2021,6 +2098,7 @@ async fn handle_tcp_connection( observer.observe(endpoint_result_for_destination_failure(denial.kind)); } deny_connect_destination( + &event_context, &mut client, &denial, workload_addr, @@ -2062,6 +2140,7 @@ async fn handle_tcp_connection( observer.observe(endpoint_result_for_destination_failure(denial.kind)); } deny_connect_destination( + &event_context, &mut client, &denial, workload_addr, @@ -2093,7 +2172,7 @@ async fn handle_tcp_connection( if let Some(observer) = connect_endpoint_observer.as_ref() { observer.observe(EndpointResult::TlsFailed); } - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + let event = NetworkActivityBuilder::new(&event_context) .activity(ActivityId::Open) .action(ActionId::Denied) .disposition(DispositionId::Blocked) @@ -2131,7 +2210,7 @@ async fn handle_tcp_connection( if let Some(observer) = connect_endpoint_observer.as_ref() { observer.observe(EndpointResult::PolicyDenied); } - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + let event = NetworkActivityBuilder::new(&event_context) .activity(ActivityId::Open) .action(ActionId::Denied) .disposition(DispositionId::Blocked) @@ -2151,6 +2230,7 @@ async fn handle_tcp_connection( .build(); ocsf_emit!(event); crate::l7::emit_uninspected_credential_finding( + &event_context, &host_lc, policy_str, if effective_tls_skip { "tls-skip" } else { "l4" }, @@ -2179,8 +2259,15 @@ async fn handle_tcp_connection( if let Err(error) = relay::validate_route_generation(l7_route, connect_generation_guard.captured_generation()) { - reject_stale_connect_policy(&mut client, &host_lc, port, activity_tx.as_ref(), error) - .await?; + reject_stale_connect_policy( + &event_context, + &mut client, + &host_lc, + port, + activity_tx.as_ref(), + error, + ) + .await?; return Ok(()); } @@ -2190,6 +2277,7 @@ async fn handle_tcp_connection( }; let Some(upstream_result) = upstream_result else { reject_stale_connect_policy( + &event_context, &mut client, &host_lc, port, @@ -2214,8 +2302,15 @@ async fn handle_tcp_connection( } }; if let Err(error) = connect_generation_guard.ensure_current() { - reject_stale_connect_policy(&mut client, &host_lc, port, activity_tx.as_ref(), error) - .await?; + reject_stale_connect_policy( + &event_context, + &mut client, + &host_lc, + port, + activity_tx.as_ref(), + error, + ) + .await?; return Ok(()); } @@ -2231,6 +2326,7 @@ async fn handle_tcp_connection( // Log the allowed CONNECT — use CONNECT_L7 when L7 inspection follows, // so log consumers can distinguish L4-only decisions from tunnel lifecycle events. ocsf_emit!(build_connect_allow_ocsf_event( + &event_context, workload_addr, &host_lc, port, @@ -2297,11 +2393,19 @@ async fn handle_tcp_connection( port = port, "tls: skip — bypassing TLS auto-detection, raw tunnel" ); - let Some(generation_guard) = relay::prepare_raw_relay(l7_route, &opa_engine, &decision) + let Some(generation_guard) = + relay::prepare_raw_relay(l7_route, &opa_engine, &decision, &event_context) else { return Ok(()); }; - relay::relay_tcp(&mut client, &mut upstream, &generation_guard, &ctx).await?; + relay::relay_tcp( + &mut client, + &mut upstream, + &generation_guard, + &ctx, + &event_context, + ) + .await?; return Ok(()); } @@ -2339,7 +2443,7 @@ async fn handle_tcp_connection( if let Some(observer) = connect_endpoint_observer.as_ref() { observer.observe(EndpointResult::TlsFailed); } - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + let event = NetworkActivityBuilder::new(&event_context) .activity(ActivityId::Fail) .severity(SeverityId::Low) .status(StatusId::Failure) @@ -2351,9 +2455,13 @@ async fn handle_tcp_connection( } }; let tls_result = async { - let Some(relay_context) = - relay::prepare_http_relay(l7_route, &opa_engine, &decision, &ctx) - else { + let Some(relay_context) = relay::prepare_http_relay( + l7_route, + &opa_engine, + &decision, + &ctx, + &event_context, + ) else { return Ok(()); }; @@ -2368,7 +2476,7 @@ async fn handle_tcp_connection( "TLS connection closed" ); } else { - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + let event = NetworkActivityBuilder::new(&event_context) .activity(ActivityId::Fail) .severity(SeverityId::Low) .status(StatusId::Failure) @@ -2391,7 +2499,7 @@ async fn handle_tcp_connection( // placeholder verbatim). const DETAIL: &str = "TLS termination unavailable after tunnel establishment; \ closing connection - credential rewrite would be bypassed"; - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + let event = NetworkActivityBuilder::new(&event_context) .activity(ActivityId::Open) .action(ActionId::Denied) .disposition(DispositionId::Blocked) @@ -2426,7 +2534,8 @@ async fn handle_tcp_connection( // Plaintext HTTP detected. ctx.request_default_port = Some(80); let is_l7_relay = l7_route.is_some_and(|route| !route.configs.is_empty()); - let Some(relay_context) = relay::prepare_http_relay(l7_route, &opa_engine, &decision, &ctx) + let Some(relay_context) = + relay::prepare_http_relay(l7_route, &opa_engine, &decision, &ctx, &event_context) else { return Ok(()); }; @@ -2443,7 +2552,7 @@ async fn handle_tcp_connection( } else { format!("HTTP relay error: {e}") }; - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + let event = NetworkActivityBuilder::new(&event_context) .activity(ActivityId::Fail) .severity(SeverityId::Low) .status(StatusId::Failure) @@ -2462,7 +2571,7 @@ async fn handle_tcp_connection( if requirement == InspectionRequirement::RequiredMiddleware { crate::l7::middleware::emit_middleware_uninspectable(&ctx, protocol_detail, true); } - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + let event = NetworkActivityBuilder::new(&event_context) .activity(ActivityId::Open) .action(ActionId::Denied) .disposition(DispositionId::Blocked) @@ -2513,11 +2622,19 @@ async fn handle_tcp_connection( port = port, "Non-TLS non-HTTP traffic detected, raw tunnel" ); - let Some(generation_guard) = relay::prepare_raw_relay(l7_route, &opa_engine, &decision) + let Some(generation_guard) = + relay::prepare_raw_relay(l7_route, &opa_engine, &decision, &event_context) else { return Ok(()); }; - relay::relay_tcp(&mut client, &mut upstream, &generation_guard, &ctx).await?; + relay::relay_tcp( + &mut client, + &mut upstream, + &generation_guard, + &ctx, + &event_context, + ) + .await?; } Ok(()) @@ -2981,8 +3098,13 @@ fn authorize_egress_intent( } } -fn emit_l7_tunnel_close_after_policy_change(host: &str, port: u16, error: miette::Report) { - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) +fn build_l7_tunnel_close_after_policy_change_event( + event_context: &EventContext, + host: &str, + port: u16, + error: &miette::Report, +) -> openshell_ocsf::OcsfEvent { + NetworkActivityBuilder::new(event_context) .activity(ActivityId::Open) .action(ActionId::Denied) .disposition(DispositionId::Blocked) @@ -2992,11 +3114,21 @@ fn emit_l7_tunnel_close_after_policy_change(host: &str, port: u16, error: miette .message(format!( "L7 tunnel closed before inspection because policy changed: {error}" )) - .build(); + .build() +} + +fn emit_l7_tunnel_close_after_policy_change( + event_context: &EventContext, + host: &str, + port: u16, + error: miette::Report, +) { + let event = build_l7_tunnel_close_after_policy_change_event(event_context, host, port, &error); ocsf_emit!(event); } async fn reject_stale_connect_policy( + event_context: &EventContext, client: &mut TcpStream, host: &str, port: u16, @@ -3009,7 +3141,7 @@ async fn reject_stale_connect_policy( error = %error, "CONNECT rejected because policy changed after L4 authorization" ); - emit_l7_tunnel_close_after_policy_change(host, port, error); + emit_l7_tunnel_close_after_policy_change(event_context, host, port, error); emit_activity_simple(activity_tx, true, "policy_stale"); respond( client, @@ -4491,6 +4623,7 @@ async fn handle_forward_proxy( activity_tx: Option<&ActivitySender>, endpoint_observation_tx: Option, ) -> Result<()> { + let event_context = identity_mode.event_context().clone(); // Capture authority before asynchronous authorization or credential selection. // Policy and provider snapshots must belong to this same installation. let endpoint_observation_context = endpoint_observation_tx @@ -4501,7 +4634,10 @@ async fn handle_forward_proxy( // canonicalized below before credential binding, policy-path evaluation, // upstream bytes, or telemetry consume it. let Ok((scheme, host, port, mut path)) = parse_proxy_uri(target_uri) else { - ocsf_emit!(build_forward_parse_error_ocsf_event(&telemetry_path)); + ocsf_emit!(build_forward_parse_error_ocsf_event( + &event_context, + &telemetry_path + )); respond(client, b"HTTP/1.1 400 Bad Request\r\n\r\n").await?; return Ok(()); }; @@ -4543,7 +4679,13 @@ async fn handle_forward_proxy( } if scheme != "http" { - let event = build_forward_unsupported_scheme_ocsf_event(method, &scheme, &host_lc, port); + let event = build_forward_unsupported_scheme_ocsf_event( + &event_context, + method, + &scheme, + &host_lc, + port, + ); ocsf_emit!(event); if scheme == "https" { respond( @@ -4624,6 +4766,7 @@ async fn handle_forward_proxy( NetworkAction::Allow { matched_policy } => matched_policy.clone(), NetworkAction::Deny { reason } => { ocsf_emit!(build_forward_policy_deny_ocsf_event( + &event_context, workload_addr, method, &host_lc, @@ -4685,7 +4828,7 @@ async fn handle_forward_proxy( error = %e, "Forward proxy rejected request because policy generation changed after L4 decision" ); - emit_l7_tunnel_close_after_policy_change(&host_lc, port, e); + emit_l7_tunnel_close_after_policy_change(&event_context, &host_lc, port, e); emit_activity_simple(activity_tx, true, "policy_stale"); respond( client, @@ -4735,7 +4878,7 @@ async fn handle_forward_proxy( let prepared_target = match prepare_forward_target(&path, canonicalize_options) { Ok(prepared) => prepared, Err(error) => { - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + let event = NetworkActivityBuilder::new(&event_context) .activity(ActivityId::Fail) .severity(SeverityId::Medium) .status(StatusId::Failure) @@ -4806,6 +4949,7 @@ async fn handle_forward_proxy( "Forward proxy rejected request because L7 route lookup used a different policy generation" ); emit_l7_tunnel_close_after_policy_change( + &event_context, &host_lc, port, miette::miette!( @@ -4838,7 +4982,7 @@ async fn handle_forward_proxy( error = %e, "Forward proxy rejected request because L7 tunnel engine could not be cloned" ); - emit_l7_tunnel_close_after_policy_change(&host_lc, port, e); + emit_l7_tunnel_close_after_policy_change(&event_context, &host_lc, port, e); emit_activity_simple(activity_tx, true, "policy_stale"); respond( client, @@ -4906,6 +5050,7 @@ async fn handle_forward_proxy( observer.observe(EndpointResult::PolicyDenied); } ocsf_emit!(build_forward_l7_parse_rejection_ocsf_event( + &event_context, workload_addr, method, &host_lc, @@ -4935,7 +5080,7 @@ async fn handle_forward_proxy( if let Some(observer) = endpoint_observer.as_ref() { observer.observe(EndpointResult::PolicyDenied); } - let event = HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) + let event = HttpActivityBuilder::new(&event_context) .activity(ActivityId::Other) .action(ActionId::Denied) .disposition(DispositionId::Blocked) @@ -5015,7 +5160,7 @@ async fn handle_forward_proxy( { Ok(info) => info, Err(e) => { - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + let event = NetworkActivityBuilder::new(&event_context) .activity(ActivityId::Fail) .severity(SeverityId::Medium) .status(StatusId::Failure) @@ -5069,7 +5214,7 @@ async fn handle_forward_proxy( { Ok(body) => body, Err(e) => { - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + let event = NetworkActivityBuilder::new(&event_context) .activity(ActivityId::Fail) .severity(SeverityId::Medium) .status(StatusId::Failure) @@ -5132,7 +5277,7 @@ async fn handle_forward_proxy( || { crate::l7::relay::evaluate_l7_request(&tunnel_engine, &l7_ctx, &request_info) .unwrap_or_else(|e| { - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + let event = NetworkActivityBuilder::new(&event_context) .activity(ActivityId::Fail) .severity(SeverityId::Low) .status(StatusId::Failure) @@ -5197,7 +5342,7 @@ async fn handle_forward_proxy( ) }, ); - let event = HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) + let event = HttpActivityBuilder::new(&event_context) .activity(ActivityId::Other) .action(action_id) .disposition(disposition_id) @@ -5271,6 +5416,7 @@ async fn handle_forward_proxy( observer.observe(EndpointResult::PolicyDenied); } deny_forward_destination( + &event_context, client, &denial, workload_addr, @@ -5311,6 +5457,7 @@ async fn handle_forward_proxy( observer.observe(endpoint_result_for_destination_failure(denial.kind)); } deny_forward_destination( + &event_context, client, &denial, workload_addr, @@ -5344,7 +5491,7 @@ async fn handle_forward_proxy( error = %e, "Forward proxy rejected request because policy changed before upstream connect" ); - emit_l7_tunnel_close_after_policy_change(&host_lc, port, e); + emit_l7_tunnel_close_after_policy_change(&event_context, &host_lc, port, e); emit_activity_simple(activity_tx, true, "policy_stale"); respond( client, @@ -5375,6 +5522,7 @@ async fn handle_forward_proxy( observer.observe(EndpointResult::PolicyDenied); } emit_l7_tunnel_close_after_policy_change( + &event_context, &host_lc, port, miette::miette!( @@ -5597,7 +5745,13 @@ async fn handle_forward_proxy( if let Some(observer) = endpoint_observer.as_ref() { observer.observe_credential_failure(true); } - emit_credential_endpoint_mismatch(method, &host_lc, port, policy_str); + emit_credential_endpoint_mismatch( + &event_context, + method, + &host_lc, + port, + policy_str, + ); respond( client, &build_json_error_response( @@ -5672,7 +5826,7 @@ async fn handle_forward_proxy( error = %e, "Forward proxy rejected request because policy changed before relay" ); - emit_l7_tunnel_close_after_policy_change(&host_lc, port, e); + emit_l7_tunnel_close_after_policy_change(&event_context, &host_lc, port, e); if let Some(session) = middleware_session.take() { session .end(openshell_core::proto::MiddlewareSessionEndReason::PolicyReload) @@ -5702,7 +5856,7 @@ async fn handle_forward_proxy( if let Some(observer) = endpoint_observer.as_ref() { observer.observe(EndpointResult::TransportFailed); } - let event = HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) + let event = HttpActivityBuilder::new(&event_context) .activity(ActivityId::Fail) .severity(SeverityId::Low) .status(StatusId::Failure) @@ -5752,7 +5906,7 @@ async fn handle_forward_proxy( error = %e, "Forward proxy rejected request because policy changed during upstream connect" ); - emit_l7_tunnel_close_after_policy_change(&host_lc, port, e); + emit_l7_tunnel_close_after_policy_change(&event_context, &host_lc, port, e); if let Some(session) = middleware_session.take() { session .end(openshell_core::proto::MiddlewareSessionEndReason::PolicyReload) @@ -5860,6 +6014,7 @@ async fn handle_forward_proxy( // rewriting, generation checks, and the HTTP relay. Only now record the // final allowed outcome. ocsf_emit!(build_forward_allow_ocsf_event( + &event_context, workload_addr, method, &host_lc, @@ -6154,6 +6309,18 @@ mod tests { use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::{TcpListener, TcpStream}; + fn proxy_event_context(sandbox_id: &str, sandbox_name: &str) -> EventContext { + EventContext { + sandbox_id: sandbox_id.to_string(), + sandbox_name: sandbox_name.to_string(), + container_image: String::new(), + hostname: "windows-gateway".to_string(), + product_version: "test".to_string(), + proxy_ip: Ipv4Addr::LOCALHOST.into(), + proxy_port: 3128, + } + } + #[test] fn endpoint_result_distinguishes_resolution_from_policy() { assert_eq!( @@ -7172,6 +7339,7 @@ network_policies: fn forward_policy_denial_ocsf_includes_validation_rationale() { let reason = "policy validation failed; fail-closed quarantine is active; candidate version 7 rejected: conflicting tls metadata"; let event = build_forward_policy_deny_ocsf_event( + openshell_ocsf::ctx::ctx(), "127.0.0.1:45123".parse().unwrap(), "GET", "api.example.com", @@ -7190,9 +7358,45 @@ network_policies: assert_eq!(json["disposition"], "Blocked"); } + #[test] + fn forward_policy_denial_ocsf_keeps_per_proxy_sandbox_attribution() { + use openshell_ocsf::validation::{load_class_schema, validate_required_fields}; + + let peer = "127.0.0.1:45123".parse().unwrap(); + let build_event = |context: &EventContext| { + build_forward_policy_deny_ocsf_event( + context, + peer, + "GET", + "api.example.com", + 80, + "/v1/models", + r"C:\agent.exe", + "-", + "-", + r"C:\agent.exe", + "endpoint is not allowed by any policy", + ) + .to_json() + .unwrap() + }; + + let sandbox_a = build_event(&proxy_event_context("sandbox-a-id", "sandbox-a")); + let sandbox_b = build_event(&proxy_event_context("sandbox-b-id", "sandbox-b")); + + assert_eq!(sandbox_a["container"]["uid"], "sandbox-a-id"); + assert_eq!(sandbox_a["container"]["name"], "sandbox-a"); + assert_eq!(sandbox_b["container"]["uid"], "sandbox-b-id"); + assert_eq!(sandbox_b["container"]["name"], "sandbox-b"); + let schema = load_class_schema("http_activity"); + validate_required_fields(&sandbox_a, &schema); + validate_required_fields(&sandbox_b, &schema); + } + #[test] fn forward_l7_parse_rejection_ocsf_includes_denial_context() { let event = build_forward_l7_parse_rejection_ocsf_event( + openshell_ocsf::ctx::ctx(), "127.0.0.1:45123".parse().unwrap(), "GET", "api.example.com", @@ -7367,6 +7571,7 @@ network_policies: assert_eq!(path, "/v1/[CREDENTIAL]"); let allowed = build_forward_allow_ocsf_event( + openshell_ocsf::ctx::ctx(), peer, "GET", "api.example.com", @@ -7381,6 +7586,7 @@ network_policies: .to_json() .unwrap(); let denied = build_forward_policy_deny_ocsf_event( + openshell_ocsf::ctx::ctx(), peer, "GET", "api.example.com", @@ -7407,6 +7613,7 @@ network_policies: assert_eq!(host, "api.example.com"); assert_eq!(path, "/?token=real-secret"); let no_path_query = build_forward_allow_ocsf_event( + openshell_ocsf::ctx::ctx(), peer, "GET", &host, @@ -7426,9 +7633,12 @@ network_policies: assert!(!serialized.contains("real-secret"), "{serialized}"); assert!(!serialized.contains("?token="), "{serialized}"); - let malformed = build_forward_parse_error_ocsf_event(&forward_telemetry_path( - "not-a-uri?token=real-secret&key=openshell:resolve:env:API_TOKEN", - )) + let malformed = build_forward_parse_error_ocsf_event( + openshell_ocsf::ctx::ctx(), + &forward_telemetry_path( + "not-a-uri?token=real-secret&key=openshell:resolve:env:API_TOKEN", + ), + ) .to_json() .unwrap(); assert_eq!( @@ -10390,8 +10600,13 @@ network_policies: fn unsupported_forward_scheme_event_omits_request_url() { use openshell_ocsf::validation::{load_class_schema, validate_required_fields}; - let event = - build_forward_unsupported_scheme_ocsf_event("GET", "https", "api.example.com", 443); + let event = build_forward_unsupported_scheme_ocsf_event( + openshell_ocsf::ctx::ctx(), + "GET", + "https", + "api.example.com", + 443, + ); let json = event.to_json().unwrap(); assert_eq!(json["http_request"]["http_method"], "GET"); @@ -10404,18 +10619,93 @@ network_policies: fn credential_endpoint_mismatch_event_includes_method_and_response() { use openshell_ocsf::validation::{load_class_schema, validate_required_fields}; - let event = - build_credential_endpoint_mismatch_event("POST", "api.example.com", 443, "bound"); + let event_context = proxy_event_context("sandbox-incident-id", "sandbox-incident"); + let event = build_credential_endpoint_mismatch_event( + &event_context, + "POST", + "api.example.com", + 443, + "bound", + ); let json = event.to_json().unwrap(); + let finding = crate::l7::build_credential_endpoint_mismatch_finding( + &event_context, + "bound", + "api.example.com", + None, + "Provider credential endpoint binding mismatch; request denied", + ) + .to_json() + .unwrap(); assert_eq!(json["class_uid"], 4002); assert_eq!(json["activity_name"], "Post"); assert_eq!(json["http_request"]["http_method"], "POST"); assert!(json["http_request"].get("url").is_none()); assert_eq!(json["http_response"]["code"], 403); + for incident_event in [&json, &finding] { + assert_eq!(incident_event["container"]["uid"], "sandbox-incident-id"); + assert_eq!(incident_event["container"]["name"], "sandbox-incident"); + } validate_required_fields(&json, &load_class_schema("http_activity")); } + #[test] + fn uninspected_credential_finding_keeps_per_proxy_sandbox_attribution() { + let event_context = proxy_event_context("sandbox-uninspected-id", "sandbox-uninspected"); + let finding = crate::l7::build_uninspected_credential_finding( + &event_context, + "api.example.com", + "bound", + "tls-skip", + ) + .to_json() + .unwrap(); + + assert_eq!(finding["container"]["uid"], "sandbox-uninspected-id"); + assert_eq!(finding["container"]["name"], "sandbox-uninspected"); + } + + #[tokio::test] + async fn stale_connect_rejection_keeps_context_and_returns_policy_denial() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let client = TcpStream::connect(address); + let accepted = listener.accept(); + let (client, accepted) = tokio::join!(client, accepted); + let mut client = client.unwrap(); + let (mut proxy, _) = accepted.unwrap(); + let event_context = proxy_event_context("sandbox-stale-id", "sandbox-stale"); + + reject_stale_connect_policy( + &event_context, + &mut proxy, + "api.example.com", + 443, + None, + miette::miette!("policy generation is stale"), + ) + .await + .unwrap(); + + let mut response = vec![0; 512]; + let bytes_read = client.read(&mut response).await.unwrap(); + let response = String::from_utf8_lossy(&response[..bytes_read]); + assert!(response.starts_with("HTTP/1.1 403 Forbidden"), "{response}"); + assert!(response.contains("policy_denied"), "{response}"); + + let event = build_l7_tunnel_close_after_policy_change_event( + &event_context, + "api.example.com", + 443, + &miette::miette!("policy generation is stale"), + ) + .to_json() + .unwrap(); + assert_eq!(event["container"]["uid"], "sandbox-stale-id"); + assert_eq!(event["container"]["name"], "sandbox-stale"); + } + #[test] fn forward_credentials_capture_endpoint_resolver_and_revision_together() { use openshell_core::proto::{StaticCredentialBinding, StaticCredentialEndpointBinding}; diff --git a/crates/openshell-supervisor-network/src/proxy/relay.rs b/crates/openshell-supervisor-network/src/proxy/relay.rs index 4c89e5e382..485b5eb04d 100644 --- a/crates/openshell-supervisor-network/src/proxy/relay.rs +++ b/crates/openshell-supervisor-network/src/proxy/relay.rs @@ -36,6 +36,7 @@ pub(super) struct RelayContext<'a> { request: &'a L7EvalContext, policy: PreparedHttpPolicy, middleware_engine: &'a OpaEngine, + event_context: &'a openshell_ocsf::EventContext, } /// Non-blocking observation channels attached to an authorized HTTP relay. @@ -146,9 +147,11 @@ pub(super) fn prepare_http_relay<'a>( opa_engine: &'a OpaEngine, decision: &EgressDecision, request: &'a L7EvalContext, + event_context: &'a openshell_ocsf::EventContext, ) -> Option> { if let Err(error) = validate_route_generation(route, decision.policy_generation) { emit_l7_tunnel_close_after_policy_change( + event_context, &decision.intent.destination.host, decision.intent.destination.port, error, @@ -161,6 +164,7 @@ pub(super) fn prepare_http_relay<'a>( Ok(evaluator) => evaluator, Err(error) => { emit_l7_tunnel_close_after_policy_change( + event_context, &decision.intent.destination.host, decision.intent.destination.port, error, @@ -182,6 +186,7 @@ pub(super) fn prepare_http_relay<'a>( Ok(guard) => guard, Err(error) => { emit_l7_tunnel_close_after_policy_change( + event_context, &decision.intent.destination.host, decision.intent.destination.port, error, @@ -196,6 +201,7 @@ pub(super) fn prepare_http_relay<'a>( request, policy, middleware_engine: opa_engine, + event_context, }) } @@ -206,9 +212,11 @@ pub(super) fn prepare_raw_relay( route: Option<&L7RouteSnapshot>, opa_engine: &OpaEngine, decision: &EgressDecision, + event_context: &openshell_ocsf::EventContext, ) -> Option { if let Err(error) = validate_route_generation(route, decision.policy_generation) { emit_l7_tunnel_close_after_policy_change( + event_context, &decision.intent.destination.host, decision.intent.destination.port, error, @@ -220,6 +228,7 @@ pub(super) fn prepare_raw_relay( Ok(guard) => Some(guard), Err(error) => { emit_l7_tunnel_close_after_policy_change( + event_context, &decision.intent.destination.host, decision.intent.destination.port, error, @@ -255,7 +264,11 @@ where context.request, ) => result, () = generation_guard.wait_until_stale() => { - emit_stale_relay_close(context.request, &generation_guard); + emit_stale_relay_close( + context.request, + &generation_guard, + context.event_context, + ); Ok(()) } } @@ -271,7 +284,11 @@ where context.request, ) => result, () = generation_guard.wait_until_stale() => { - emit_stale_relay_close(context.request, &generation_guard); + emit_stale_relay_close( + context.request, + &generation_guard, + context.event_context, + ); Ok(()) } } @@ -286,7 +303,11 @@ where Some(context.middleware_engine), ) => result, () = generation_guard.wait_until_stale() => { - emit_stale_relay_close(context.request, &generation_guard); + emit_stale_relay_close( + context.request, + &generation_guard, + context.event_context, + ); Ok(()) } } @@ -300,6 +321,7 @@ pub(super) async fn relay_tcp( upstream: &mut U, generation_guard: &PolicyGenerationGuard, request: &L7EvalContext, + event_context: &openshell_ocsf::EventContext, ) -> Result<()> where C: AsyncRead + AsyncWrite + Unpin, @@ -310,14 +332,19 @@ where result.into_diagnostic()?; } () = generation_guard.wait_until_stale() => { - emit_stale_relay_close(request, generation_guard); + emit_stale_relay_close(request, generation_guard, event_context); } } Ok(()) } -fn emit_stale_relay_close(request: &L7EvalContext, guard: &PolicyGenerationGuard) { +fn emit_stale_relay_close( + request: &L7EvalContext, + guard: &PolicyGenerationGuard, + event_context: &openshell_ocsf::EventContext, +) { emit_l7_tunnel_close_after_policy_change( + event_context, &request.host, request.port, miette::miette!( @@ -380,8 +407,14 @@ mod tests { let decision = decision(engine.current_generation()); let request = request_context(); - let context = prepare_http_relay(None, &engine, &decision, &request) - .expect("current L4 generation should prepare a relay"); + let context = prepare_http_relay( + None, + &engine, + &decision, + &request, + openshell_ocsf::ctx::ctx(), + ) + .expect("current L4 generation should prepare a relay"); let PreparedHttpPolicy::Passthrough { generation_guard } = context.policy else { panic!("route-less relay should use a generation guard"); }; @@ -403,7 +436,14 @@ mod tests { let request = request_context(); assert!( - prepare_http_relay(Some(&route), &engine, &decision, &request).is_none(), + prepare_http_relay( + Some(&route), + &engine, + &decision, + &request, + openshell_ocsf::ctx::ctx(), + ) + .is_none(), "a current L7 lookup must not freshen a stale L4 allow" ); } @@ -441,7 +481,14 @@ mod tests { let request = request_context(); assert!( - prepare_http_relay(Some(&route), &engine, &decision, &request).is_none(), + prepare_http_relay( + Some(&route), + &engine, + &decision, + &request, + openshell_ocsf::ctx::ctx(), + ) + .is_none(), "an inspected route must use the generation that authorized CONNECT" ); } @@ -456,7 +503,8 @@ mod tests { }; assert!( - prepare_raw_relay(Some(&route), &engine, &decision).is_none(), + prepare_raw_relay(Some(&route), &engine, &decision, openshell_ocsf::ctx::ctx(),) + .is_none(), "a raw relay must not freshen a stale L4 allow" ); } @@ -469,7 +517,14 @@ mod tests { engine.reload(POLICY_REGO, EMPTY_POLICY_DATA).unwrap(); assert!( - prepare_http_relay(None, &engine, &decision, &request).is_none(), + prepare_http_relay( + None, + &engine, + &decision, + &request, + openshell_ocsf::ctx::ctx(), + ) + .is_none(), "policy reload must prevent a stale relay from starting" ); } @@ -485,7 +540,14 @@ mod tests { let (_upstream_peer, mut proxy_upstream) = tokio::io::duplex(64); let relay = tokio::spawn(async move { - relay_tcp(&mut proxy_client, &mut proxy_upstream, &guard, &request).await + relay_tcp( + &mut proxy_client, + &mut proxy_upstream, + &guard, + &request, + openshell_ocsf::ctx::ctx(), + ) + .await }); tokio::task::yield_now().await; diff --git a/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs b/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs index 5948cd5224..759f888b20 100644 --- a/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs +++ b/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs @@ -92,6 +92,7 @@ async fn destination_denials_preserve_adapter_specific_wire_contracts() { let (mut app, mut proxy) = tcp_pair().await; deny_connect_destination( + openshell_ocsf::ctx::ctx(), &mut proxy, &denial, peer, @@ -119,6 +120,7 @@ async fn destination_denials_preserve_adapter_specific_wire_contracts() { let (mut app, mut proxy) = tcp_pair().await; deny_forward_destination( + openshell_ocsf::ctx::ctx(), &mut proxy, &denial, peer, @@ -165,6 +167,7 @@ fn representative_adapter_denials_preserve_ocsf_fields() { // global tracing pipeline. Its callsite-interest cache is process-global, // so parallel tests can otherwise make captured-event assertions flaky. let connect = serde_json::to_value(build_connect_destination_deny_ocsf_event( + openshell_ocsf::ctx::ctx(), &denial, peer, "target.example", @@ -194,6 +197,7 @@ fn representative_adapter_denials_preserve_ocsf_fields() { assert_eq!(connect["status_detail"], denial_reason); let forward = serde_json::to_value(build_forward_destination_deny_ocsf_event( + openshell_ocsf::ctx::ctx(), &denial, peer, "POST", @@ -229,6 +233,7 @@ fn representative_adapter_denials_preserve_ocsf_fields() { fn representative_adapter_allows_preserve_ocsf_fields() { let peer: SocketAddr = "127.0.0.1:41000".parse().unwrap(); let connect = serde_json::to_value(build_connect_allow_ocsf_event( + openshell_ocsf::ctx::ctx(), peer, "target.example", 8443, @@ -254,6 +259,7 @@ fn representative_adapter_allows_preserve_ocsf_fields() { assert_eq!(connect["message"], "CONNECT_L7 allowed target.example:8443"); let forward = serde_json::to_value(build_forward_allow_ocsf_event( + openshell_ocsf::ctx::ctx(), peer, "GET", "target.example", From 651ed7e03d3f30b930acaf6693147595499542b4 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas <34834085+prekshivyas@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:38:07 -0700 Subject: [PATCH 11/42] NVBug 6783374: make MXC HTTPS L7 qualification authoritative (#3479) * test(mxc): qualify HTTPS L7 enforcement (NVBug 6783374) Signed-off-by: Prekshi Vyas * docs(mxc): clarify real qualification failures Signed-off-by: Prekshi Vyas * docs(mxc): align real test skip semantics Signed-off-by: Shailendra Singh --------- Signed-off-by: Prekshi Vyas Signed-off-by: Shailendra Singh Co-authored-by: Shailendra Singh --- .../build-openshell-mxc-windows/SKILL.md | 9 +- crates/openshell-driver-mxc/README.md | 27 ++++-- .../tests/wxc_exec_real.rs | 83 ++++++------------- docs/reference/gateway-config.mdx | 2 + docs/security/best-practices.mdx | 2 + tasks/windows.toml | 4 +- 6 files changed, 55 insertions(+), 72 deletions(-) diff --git a/.agents/skills/build-openshell-mxc-windows/SKILL.md b/.agents/skills/build-openshell-mxc-windows/SKILL.md index 0ee92ed6a7..13ae8f19f8 100644 --- a/.agents/skills/build-openshell-mxc-windows/SKILL.md +++ b/.agents/skills/build-openshell-mxc-windows/SKILL.md @@ -163,9 +163,12 @@ exclusive on a single host (each rejects the other architecture -- see the table below): run `windows:test:mxc-real:x64` on an x64 host, or `windows:test:mxc-real:arm64` on an ARM64 host, as part of validating this subsystem -- run the one matching your host architecture, not both, and not -neither. Both are skip-safe (they print a SKIP reason and exit 0 when -`wxc-exec` or the matching backend isn't available), so running the -arch-appropriate task is always safe even without real MXC hardware. Neither +neither. Both print a SKIP reason and exit 0 when `wxc-exec` or the matching +backend is unavailable. Once the host proves that ProcessContainer is live, +however, required capabilities are authoritative: rejection of +`network.proxy` or another enforcement failure fails the task. Running the +architecture-appropriate task is therefore safe without real MXC hardware but +must not be described as wholly skip-safe on supported hardware. Neither task is part of `windows:ci`'s ordered contract, so invoke it explicitly. For GB300 Windows ARM64 qualification, do not use the skip-safe developer task diff --git a/crates/openshell-driver-mxc/README.md b/crates/openshell-driver-mxc/README.md index 469c7a4820..b4ff31b5df 100644 --- a/crates/openshell-driver-mxc/README.md +++ b/crates/openshell-driver-mxc/README.md @@ -167,11 +167,18 @@ process env. In every environment mode, the driver stages the public CA files under the authorized `/.openshell-proxy/` directory. The host proxy's private temporary directory is never added to the sandbox's read-write shares. The staged directory contains only public CA certificates; the ephemeral -CA private key remains in the host proxy's memory. The driver -seeds only `SYSTEMROOT`, `WINDIR`, `PATH`, `COMSPEC`, and `LOCALAPPDATA` from the -gateway host before applying sandbox and TLS overrides, so required Windows -bootstrap values remain available without exposing the gateway's full -environment unless the gateway explicitly opts into another environment mode. +CA private key remains in the host proxy's memory. + +Windows inbox `curl.exe` uses Schannel and ignores `CURL_CA_BUNDLE` as an +environment variable, so workloads using it must pass +`--cacert %CURL_CA_BUNDLE%` explicitly. Clients that honor the injected trust +variables consume the same per-sandbox bundle directly. + +The driver seeds only `SYSTEMROOT`, `WINDIR`, `PATH`, `COMSPEC`, and +`LOCALAPPDATA` from the gateway host before applying sandbox and TLS overrides, +so required Windows bootstrap values remain available without exposing the +gateway's full environment unless the gateway explicitly opts into another +environment mode. When governed egress is disabled, any network rule fails closed during sandbox creation. @@ -194,10 +201,12 @@ This example uses `process_container`. The `IsoSessionApp.dll` and ## Real-MXC test lane -The generic real-`wxc-exec.exe` tasks are **skip-safe**: a test or scenario that -requires an absent binary or backend prints a SKIP reason and exits 0. They are -useful developer diagnostics, but a skipped run is not qualification evidence. -The GB300 task is deliberately strict and fails on every required skip. +The generic real-`wxc-exec.exe` tasks print a SKIP reason and exit 0 when the +binary or requested backend is unavailable. Once ProcessContainer is live, +required capabilities are authoritative: rejection of `network.proxy` or +another enforcement failure fails the task. These tasks are useful developer +diagnostics, but a skipped run is not qualification evidence. The GB300 task +is deliberately strict and fails on every required skip. | Task | What it runs | When to use | |---|---|---| diff --git a/crates/openshell-driver-mxc/tests/wxc_exec_real.rs b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs index 1e6e041932..e5e7dbd399 100644 --- a/crates/openshell-driver-mxc/tests/wxc_exec_real.rs +++ b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs @@ -659,58 +659,6 @@ fn probe_processcontainer(wxc: &PathBuf) -> Result<(), String> { Ok(()) } -/// Probe the released binary's live `network.proxy` support separately from -/// ordinary `ProcessContainer` support. Some builds accept the proxy JSON during -/// `--dry-run` but return `ERROR_INVALID_PARAMETER` from the live launcher. -fn probe_processcontainer_proxy(wxc: &PathBuf) -> Result<(), String> { - let (_tempdir, temp_path) = temp_fixture(); - let proxy_listener = std::net::TcpListener::bind("127.0.0.1:0") - .map_err(|error| format!("failed to reserve proxy probe port: {error}"))?; - let proxy_port = proxy_listener - .local_addr() - .map_err(|error| format!("failed to read proxy probe port: {error}"))? - .port(); - let config = serde_json::json!({ - "version": "0.6.0-alpha", - "containerId": "probe-pc-proxy", - "containment": "processcontainer", - "process": { - "commandLine": "C:\\Windows\\System32\\cmd.exe /c exit 0", - "cwd": temp_path, - "timeout": 30_000, - }, - "filesystem": { - "readwritePaths": [temp_path], - }, - "processContainer": { - "leastPrivilege": false, - }, - "network": { - "defaultPolicy": "block", - "proxy": { "localhost": proxy_port }, - }, - }); - - let json = serde_json::to_string(&config).unwrap(); - let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); - let output = Command::new(wxc) - .arg("--config-base64") - .arg(&b64) - .output() - .map_err(|error| format!("wxc-exec proxy probe failed to spawn: {error}"))?; - - if output.status.success() { - return Ok(()); - } - - Err(format!( - "live network.proxy probe returned exit {}: stdout={} stderr={}", - output.status.code().unwrap_or(-1), - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr), - )) -} - /// Probe the `isolation_session` backend. /// /// Attempts a `provision` phase. Returns `Ok(sandbox_id)` when live, or @@ -1013,11 +961,6 @@ async fn pc_https_egress_reads_injected_ca_bundle() { eprintln!("SKIP: processcontainer not live: {reason}"); return; } - if let Err(reason) = probe_processcontainer_proxy(&wxc) { - eprintln!("SKIP: processcontainer network.proxy not live: {reason}"); - return; - } - let system_root = std::env::var("SYSTEMROOT").expect("SYSTEMROOT must be set on Windows"); let cmd = PathBuf::from(&system_root).join("System32").join("cmd.exe"); let curl = PathBuf::from(system_root).join("System32").join("curl.exe"); @@ -1029,10 +972,14 @@ async fn pc_https_egress_reads_injected_ca_bundle() { let output_dir = tempfile::tempdir().expect("HTTPS output directory"); let output_path = output_dir.path().join("example.html"); let certificate_path = output_dir.path().join("peer-certificate.txt"); + let post_response_path = output_dir.path().join("post-response.json"); + let post_status_path = output_dir.path().join("post-status.txt"); let diagnostic_path = output_dir.path().join("https-diagnostic.txt"); let output_dir_string = output_dir.path().to_string_lossy().into_owned(); let output_path_string = output_path.to_string_lossy().into_owned(); let certificate_path_string = certificate_path.to_string_lossy().into_owned(); + let post_response_path_string = post_response_path.to_string_lossy().into_owned(); + let post_status_path_string = post_status_path.to_string_lossy().into_owned(); let diagnostic_path_string = diagnostic_path.to_string_lossy().into_owned(); let cmd_string = cmd.to_string_lossy().into_owned(); // Schannel's revocation lookup targets are intentionally outside this @@ -1046,8 +993,15 @@ async fn pc_https_egress_reads_injected_ca_bundle() { --cacert \"%CURL_CA_BUNDLE%\" \ https://example.com/ --output \"{output_path_string}\" \ --write-out \"%{{certs}}\" 1>\"{certificate_path_string}\" \ + 2>>\"{diagnostic_path_string}\" && \ + \"{}\" --silent --show-error --ssl-no-revoke \ + --cacert \"%CURL_CA_BUNDLE%\" --request POST \ + --header \"Content-Type: application/json\" --data \"{{}}\" \ + https://example.com/ --output \"{post_response_path_string}\" \ + --write-out \"%{{http_code}}\" 1>\"{post_status_path_string}\" \ 2>>\"{diagnostic_path_string}\"", - curl.display() + curl.display(), + curl.display(), ); let command = vec![ cmd_string.clone(), @@ -1156,6 +1110,19 @@ async fn pc_https_egress_reads_injected_ca_bundle() { peer_certificate.contains("OpenShell Sandbox CA"), "HTTPS response must use a certificate issued by the host proxy CA" ); + let post_status = std::fs::read_to_string(post_status_path).expect("POST status output"); + assert_eq!( + post_status.trim(), + "403", + "read-only policy must deny HTTPS POST" + ); + let post_response = + std::fs::read_to_string(post_response_path).expect("POST denial response body"); + assert!( + post_response.contains("policy_denied") + || post_response.contains("no matching L7 allow rule"), + "POST denial must come from the OpenShell L7 policy: {post_response}" + ); } /// Write to a path OUTSIDE the granted dir; assert exit non-zero and file absent. diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index a292cb030c..ce1d9735e0 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -962,6 +962,8 @@ non-mutating administrator-gated Service Control Manager open. Set `egress_proxy = true` with `egress_proxy_addr = "127.0.0.1:18080"` to enable the Windows Pattern C split. The address must be a `127.0.0.1:PORT` socket. The driver allocates a unique ephemeral port per sandbox, injects that listener through proxy environment variables, and stages the public proxy CA beneath the sandbox's configured `/.openshell-proxy//`. A non-empty per-sandbox `cwd` is therefore required when governed egress is enabled; sandbox-specific subdirectories prevent concurrent sandboxes from overwriting each other's trust files. MXC denies direct Internet egress but allows `127.0.0.1/32`; this permits dynamic forwarding but also means the sandbox can reach unrelated host services bound to loopback. +The driver points common TLS trust variables, including `CURL_CA_BUNDLE`, at the staged public CA bundle. Windows inbox `curl.exe` uses Schannel and does not honor `CURL_CA_BUNDLE` automatically. Invoke it with `--cacert %CURL_CA_BUNDLE%` so HTTPS requests through the governed-egress proxy trust the per-sandbox CA. + MXC rejects policies containing `network_middlewares` before launch because this host-proxy path does not receive the gateway middleware registry. Supply the workload command and optional working directory through `sandbox create --driver-config-json`, for example `{"mxc":{"command":["cmd","/c","echo hello"],"cwd":"C:\\work"}}`. Supply workload environment through `sandbox create --env` or `--env-from`; it is not part of gateway configuration. diff --git a/docs/security/best-practices.mdx b/docs/security/best-practices.mdx index bd7cd5ff61..4fff61f276 100644 --- a/docs/security/best-practices.mdx +++ b/docs/security/best-practices.mdx @@ -125,6 +125,8 @@ This enables credential injection and L7 inspection without explicit configurati | Risk if relaxed | `tls: skip` disables placeholder credential rewriting, dynamic token grant injection, and L7 inspection for that endpoint. The proxy relays encrypted traffic without seeing the contents. | | Recommendation | Use auto-detect (the default) for most endpoints. Use `tls: skip` only when the upstream requires the client's own TLS certificate (mTLS) or uses a non-HTTP protocol. A provider-credentialed endpoint also requires the explicit `allow_uninspected_credentials: true` exception. | +On Windows, inbox `curl.exe` uses Schannel and does not automatically honor the injected `CURL_CA_BUNDLE` value. Pass `--cacert %CURL_CA_BUNDLE%` explicitly when that client sends HTTPS traffic through an MXC governed-egress proxy. Other clients continue to use the trust variable they support. + ### SSRF Protection After OPA policy allows a connection, the proxy resolves DNS and rejects undeclared internal destinations. diff --git a/tasks/windows.toml b/tasks/windows.toml index fb8e64d52f..5b8bab4008 100644 --- a/tasks/windows.toml +++ b/tasks/windows.toml @@ -76,12 +76,12 @@ run = "echo 'windows:* tasks require a Windows MSVC host' && exit 1" run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 ci all" ["windows:test:mxc-real:x64"] -description = "Run native x64 real-wxc-exec Tier-2 integration tests (skip-safe: tests print SKIP when binary/backend absent)" +description = "Run native x64 real-wxc-exec Tier-2 integration tests (skip only when binary/backend is unavailable; supported ProcessContainer failures are fatal)" run = "echo 'windows:* tasks require a Windows MSVC host' && exit 1" run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 test-mxc-real x86_64-pc-windows-msvc" ["windows:test:mxc-real:arm64"] -description = "Run native ARM64 real-wxc-exec Tier-2 integration tests (skip-safe: tests print SKIP when binary/backend absent)" +description = "Run native ARM64 real-wxc-exec Tier-2 integration tests (skip only when binary/backend is unavailable; supported ProcessContainer failures are fatal)" run = "echo 'windows:* tasks require a Windows MSVC host' && exit 1" run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 test-mxc-real aarch64-pc-windows-msvc" From 27a36b400453d32fc84f96c20ce45fb239bbf8d2 Mon Sep 17 00:00:00 2001 From: pkhodade-NV <84895267+pkhodade-NV@users.noreply.github.com> Date: Wed, 23 Sep 2026 02:09:43 +0530 Subject: [PATCH 12/42] fix(mxc): reject a non-absolute wxc_exec_path at gateway startup (#3497) * fix(mxc): reject a non-absolute wxc_exec_path at gateway startup wxc_exec_path is the binary that builds every sandbox, but nothing validated it before spawning: a relative value (including the shipped default, a bare "wxc-exec.exe") let PATH-lookup or working-directory- relative resolution execute a decoy binary with the gateway's identity instead of the approved wxc-exec, turning the containment mechanism itself into an arbitrary-code-execution primitive. Add MxcComputeConfig::validate_configuration, wired into the existing (previously no-op) compute-driver config preflight, rejecting an empty or non-absolute wxc_exec_path with a clear diagnostic. Change the default from the relative "wxc-exec.exe" to an empty string so the field must be explicitly configured -- no usable-but-insecure fallback survives. Update the architecture doc's stale "else PATH" discovery claim to match. Signed-off-by: Prashant Khodade (cherry picked from commit d4192a0072f8f0ca0a4035f10cae07889c2b578f) * test(mxc): cover wxc_exec_path enforcement at the gateway boundary MxcComputeConfig::validate_configuration already had unit coverage, but that only proves the validation function itself is correct -- it says nothing about whether MxcFactory::validate_config (src/lib.rs) still calls it. Before this fix, that factory method discarded the parsed config entirely, so a regression back to that no-op shape would leave every unit test passing while a relative wxc_exec_path again reached gateway startup. Add an integration test that spawns the actual compiled openshell-gateway binary through its config preflight subcommand, exercising the real chain: CLI parsing, TOML loading, driver selection, MxcFactory::validate_config, and MxcComputeConfig::validate_configuration. Assertions check only pass/fail, not message content: run_effective_config_preflight replaces any validation failure with a generic message whenever a config file is used, to keep file-sourced values out of preflight diagnostics -- pre-existing, deliberate, and covered by its own tests. Also document the new required-and-absolute wxc_exec_path constraint in the gateway config reference and the driver README, which previously only showed example values without stating the requirement. Signed-off-by: Prashant Khodade --------- Signed-off-by: Prashant Khodade --- crates/openshell-driver-mxc/README.md | 8 ++ crates/openshell-driver-mxc/src/driver.rs | 84 ++++++++++++++- crates/openshell-gateway/src/lib.rs | 4 +- .../tests/mxc_config_preflight.rs | 100 ++++++++++++++++++ docs/reference/gateway-config.mdx | 15 +++ 5 files changed, 207 insertions(+), 4 deletions(-) create mode 100644 crates/openshell-gateway/tests/mxc_config_preflight.rs diff --git a/crates/openshell-driver-mxc/README.md b/crates/openshell-driver-mxc/README.md index b4ff31b5df..89269bb3a1 100644 --- a/crates/openshell-driver-mxc/README.md +++ b/crates/openshell-driver-mxc/README.md @@ -75,6 +75,14 @@ debug = false etw_audit = false ``` +`wxc_exec_path` is required and must be an absolute path to `wxc-exec.exe`. +The gateway rejects an omitted or relative value (including the bare filename +`wxc-exec.exe`) at startup, before any sandbox is created: `wxc-exec.exe` is +the binary that builds every sandbox, so a relative value would let +PATH-lookup or working-directory-relative resolution execute an unapproved +binary with the gateway's identity instead of the approved `wxc-exec`. There +is no usable default. + When `egress_proxy` is enabled, `egress_proxy_addr` must be a loopback `IP:PORT` seed. For policies with explicit network rules, the driver preserves the configured IP and allocates a unique ephemeral port for that sandbox's diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index 8cf5e17a47..412b352b34 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -179,7 +179,12 @@ impl MxcBackend { #[serde(default, deny_unknown_fields)] #[allow(clippy::struct_excessive_bools)] // Independent, existing gateway TOML options. pub struct MxcComputeConfig { - /// Path to `wxc-exec.exe`. Required for live runs. + /// Path to `wxc-exec.exe`. Required for live runs, and must be an + /// absolute path: `wxc-exec` is the binary that builds every sandbox, so + /// a relative path (including the unset default) would let PATH-lookup + /// or working-directory-relative resolution execute a decoy binary with + /// the gateway's identity instead of the approved `wxc-exec`. Enforced + /// at gateway startup by the compute-driver config preflight. pub wxc_exec_path: String, /// Backend to target. Default: `process_container`. pub backend: MxcBackend, @@ -256,7 +261,12 @@ pub struct MxcComputeConfig { impl Default for MxcComputeConfig { fn default() -> Self { Self { - wxc_exec_path: "wxc-exec.exe".into(), + // No usable default: `wxc_exec_path` must be explicitly set to an + // absolute path (see `validate_configuration` and the field doc + // comment above). Shipping a bare relative filename here would + // silently reintroduce the exact PATH/CWD-hijack risk the + // validation exists to reject. + wxc_exec_path: String::new(), backend: MxcBackend::default(), pc_least_privilege: false, pc_capabilities: Vec::new(), @@ -275,6 +285,32 @@ impl Default for MxcComputeConfig { } } +impl MxcComputeConfig { + /// Validate startup configuration without touching `wxc-exec` or the + /// filesystem beyond `Path::is_absolute`. + /// + /// `wxc_exec_path` must be set to an absolute path: it is the binary + /// that builds every sandbox, so a relative path (including an unset, + /// empty value) would let PATH-lookup or working-directory-relative + /// resolution execute a decoy binary with the gateway's identity instead + /// of the approved `wxc-exec`, turning the containment mechanism itself + /// into an arbitrary-code-execution primitive. + pub fn validate_configuration(&self) -> openshell_core::Result<()> { + if self.wxc_exec_path.trim().is_empty() { + return Err(openshell_core::Error::config( + "[openshell.drivers.mxc] wxc_exec_path must be set to an absolute path to wxc-exec.exe", + )); + } + if !Path::new(&self.wxc_exec_path).is_absolute() { + return Err(openshell_core::Error::config(format!( + "[openshell.drivers.mxc] wxc_exec_path must be an absolute path, got '{}'", + self.wxc_exec_path + ))); + } + Ok(()) + } +} + /// Per-sandbox MXC workload settings supplied through /// `template.driver_config.mxc` / `--driver-config-json`. #[derive(Debug, Clone, Deserialize)] @@ -2790,6 +2826,50 @@ mod lifecycle_tests { } } + #[test] + fn validate_configuration_rejects_unset_wxc_exec_path() { + // Regression test: the shipped default used to be the bare relative + // filename "wxc-exec.exe", which is exactly the PATH/CWD-hijack + // primitive this validation exists to reject. The default must stay + // rejected, not silently become a usable-but-insecure fallback. + let config = MxcComputeConfig::default(); + assert!(config.wxc_exec_path.is_empty()); + let error = config.validate_configuration().unwrap_err(); + assert!(error.to_string().contains("wxc_exec_path")); + } + + #[test] + fn validate_configuration_rejects_relative_wxc_exec_path() { + let config = MxcComputeConfig { + wxc_exec_path: "wxc-exec.exe".into(), + ..Default::default() + }; + let error = config.validate_configuration().unwrap_err(); + assert!(error.to_string().contains("wxc_exec_path")); + + let config = MxcComputeConfig { + wxc_exec_path: r"..\wxc-exec.exe".into(), + ..Default::default() + }; + assert!(config.validate_configuration().is_err()); + } + + #[test] + fn validate_configuration_accepts_absolute_wxc_exec_path() { + let config = MxcComputeConfig { + wxc_exec_path: r"C:\mxc-kit\bin\wxc-exec.exe".into(), + ..Default::default() + }; + config.validate_configuration().unwrap(); + } + + #[test] + fn governed_egress_defaults_off_and_allocates_unique_loopback_ports() { + let config = MxcComputeConfig::default(); + assert!(!config.egress_proxy); + assert!(config.egress_proxy_addr.is_empty()); + } + #[test] fn sandbox_proxy_addr_uses_ephemeral_loopback_port() { let configured = "127.0.0.1:18080".parse().unwrap(); diff --git a/crates/openshell-gateway/src/lib.rs b/crates/openshell-gateway/src/lib.rs index 30e1c83727..f5f2074da9 100644 --- a/crates/openshell-gateway/src/lib.rs +++ b/crates/openshell-gateway/src/lib.rs @@ -149,8 +149,8 @@ impl openshell_server::ComputeDriverFactory for MxcFactory { &self, context: openshell_server::ComputeDriverConfigContext<'_>, ) -> openshell_core::Result<()> { - let _: openshell_driver_mxc::MxcComputeConfig = context.driver_config()?; - Ok(()) + let config: openshell_driver_mxc::MxcComputeConfig = context.driver_config()?; + config.validate_configuration() } async fn build( diff --git a/crates/openshell-gateway/tests/mxc_config_preflight.rs b/crates/openshell-gateway/tests/mxc_config_preflight.rs new file mode 100644 index 0000000000..c4314f3079 --- /dev/null +++ b/crates/openshell-gateway/tests/mxc_config_preflight.rs @@ -0,0 +1,100 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Integration test for the actual enforcement boundary of the +//! `wxc_exec_path` absolute-path requirement: the real `openshell-gateway` +//! binary's `config preflight` subcommand, selecting the `mxc` compute +//! driver. +//! +//! `openshell_driver_mxc::MxcComputeConfig::validate_configuration` already +//! has unit coverage, but that only proves the validation function itself is +//! correct -- it says nothing about whether `MxcFactory::validate_config` (in +//! `src/lib.rs`) still calls it. Before this fix, that factory method was a +//! no-op that discarded the parsed config entirely, so a regression back to +//! that shape would leave every unit test passing while a relative +//! `wxc_exec_path` again reached gateway startup. Spawning the real compiled +//! binary through its actual CLI entry point exercises the whole chain: CLI +//! parsing, TOML config loading, driver selection, `MxcFactory::validate_config`, +//! and `MxcComputeConfig::validate_configuration`. +//! +//! These tests assert only pass/fail, not the diagnostic's exact wording: +//! `openshell_server::cli::run_effective_config_preflight` replaces any +//! validation failure with a generic "malformed" message whenever a config +//! file path is in play (`ConfigPreflightError::invalid_current`), so the +//! specific `wxc_exec_path` wording from `validate_configuration` is not +//! observable through this boundary today. That message-masking behavior is +//! pre-existing and unrelated to this fix. + +#![cfg(all(target_os = "windows", feature = "compute-driver-mxc"))] + +use std::io::Write; +use std::process::Command; + +/// Run `openshell-gateway config preflight` against a disposable TOML config +/// with the `mxc` driver selected. Returns whether the process exited +/// successfully and its captured stderr. +fn run_preflight(toml_body: &str) -> (bool, String) { + let mut config_file = tempfile::NamedTempFile::new().expect("create temp config file"); + write!(config_file, "{toml_body}").expect("write temp config file"); + + let output = Command::new(env!("CARGO_BIN_EXE_openshell-gateway")) + .arg("config") + .arg("preflight") + .env("OPENSHELL_GATEWAY_CONFIG", config_file.path()) + .env("OPENSHELL_COMPUTE_DRIVER", "mxc") + .env("OPENSHELL_DISABLE_TLS", "true") + .env_remove("OPENSHELL_DRIVERS") + .output() + .expect("spawn openshell-gateway config preflight"); + + let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); + (output.status.success(), stderr) +} + +#[test] +fn config_preflight_rejects_omitted_wxc_exec_path() { + let (ok, stderr) = run_preflight( + r" +[openshell] +version = 2 +", + ); + assert!( + !ok, + "omitted wxc_exec_path must fail preflight, stderr: {stderr}" + ); +} + +#[test] +fn config_preflight_rejects_relative_wxc_exec_path() { + let (ok, stderr) = run_preflight( + r#" +[openshell] +version = 2 + +[openshell.drivers.mxc] +wxc_exec_path = "wxc-exec.exe" +"#, + ); + assert!( + !ok, + "a relative wxc_exec_path must fail preflight, stderr: {stderr}" + ); +} + +#[test] +fn config_preflight_accepts_absolute_wxc_exec_path() { + let (ok, stderr) = run_preflight( + r#" +[openshell] +version = 2 + +[openshell.drivers.mxc] +wxc_exec_path = "C:\\mxc-kit\\bin\\wxc-exec.exe" +"#, + ); + assert!( + ok, + "an absolute wxc_exec_path must pass preflight, stderr: {stderr}" + ); +} diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index ce1d9735e0..a4ca56a250 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -687,6 +687,14 @@ debug = false etw_audit = true ``` +`wxc_exec_path` is required and must be an absolute path to `wxc-exec.exe`. +The gateway rejects an omitted or relative value at startup: `wxc-exec.exe` +is the binary that builds every sandbox, so a relative value would let +PATH-lookup or working-directory-relative resolution execute an unapproved +binary with the gateway's identity instead of the intended `wxc-exec`. There +is no usable default; older configurations that relied on the bare relative +filename `wxc-exec.exe` must set an absolute path before upgrading. + `etw_audit` defaults to `false`. When enabled, the gateway account must be an administrator or belong to the Windows Performance Log Users group. Workload commands and working directories remain sandbox-scoped and must be supplied in @@ -949,6 +957,13 @@ debug = false etw_audit = false ``` +`wxc_exec_path` is required and must be an absolute path; the gateway rejects +an omitted or relative value at startup. `wxc-exec.exe` builds every sandbox, +so a relative value would let PATH-lookup or working-directory-relative +resolution execute an unapproved binary with the gateway's identity. There is +no usable default -- older configurations that relied on the bare relative +filename `wxc-exec.exe` must set an absolute path before upgrading. + The default `pc_least_privilege = false` still runs the workload with an AppContainer token. Set it to `true` to request the stricter Less Privileged AppContainer (LPAC) variant. Windows AppContainer tokens retain the launching From 37c2bae2e6e79ae3ee7c48d4fe022656f0fea226 Mon Sep 17 00:00:00 2001 From: pkhodade-NV <84895267+pkhodade-NV@users.noreply.github.com> Date: Wed, 23 Sep 2026 02:11:12 +0530 Subject: [PATCH 13/42] feat(mxc): warn when the ETW consumer receives zero provider events (#3499) * feat(mxc): warn when the ETW consumer receives zero provider events EnableTraceEx2 succeeding only proves the request to enable the Sandboxing provider succeeded, not that the provider exists on this host/build or will ever fire. A provider-identity mismatch or a non-firing provider left the ETW->OCSF audit trail silently empty across real, successful sandbox lifecycles, with no error, warning, or diagnostic anywhere. Track raw provider-matched events received per session and add a zero-events watchdog on the consumer thread: once real sandbox activity has happened (register_launch called at least once) and a grace period elapses with zero events matched, log a warning and emit a Detection Finding [2004] naming the gap. Gated on actual activity (not just session uptime) so an idle gateway with etw_audit=true and no sandboxes created never warns. Also exposes EtwSession::events_received() alongside the existing is_capture_alive(), so a status/diagnostics surface can query capture health directly, not just infer it from tracing output. The watchdog's decision logic is extracted into a pure function (should_warn_zero_events) so it's unit-testable without a real ETW session or elevation. Signed-off-by: Prashant Khodade (cherry picked from commit 4fcfa716a808eaa91c6476e9c69366f91702b1fa) * fix(mxc): repair build/lint breaks left by the zero-events watchdog commit warn_zero_events_received() referenced a nonexistent SESSION_NAME constant (only SESSION_NAME_PREFIX exists), so the crate failed to compile. Separately, the consumer thread's decode-or-log match on Option tripped clippy::single_match_else under -D warnings. Neither issue is specific to a platform or toolchain version -- both reproduce on a clean checkout of this branch's tip. Signed-off-by: Prashant Khodade * fix(mxc): attribute ETW Sandboxing-provider events via identity/CV, not PID The Sandboxing provider's events are logged under two PIDs that are never the driver's own wxc-exec PID: a short-lived launcher PID (wxc-exec.exe exits within seconds even while the sandboxed workload keeps running) and a shared, constant PID hosted by a system-wide OS broker service across unrelated sandboxes. Anchoring attribution on `register_launch`'s wxc_pid therefore left every event unattributable, so the OCSF audit trail stayed empty despite the provider firing correctly (confirmed against a raw logman/tracerpt capture running alongside this consumer). wxc-exec never reports its OS-generated `identity`/`__TlgCV__` back to the driver, so there is nothing to pre-seed `by_identity` with at registration time the way `by_pid` is pre-seeded. Track launches still awaiting their first identity/CV in a `pending_launches` queue instead, and bind opportunistically in `resolve()`: when an event carries a never-seen identity/CV and has no `by_pid` registration at all (the real-world shape of these events) and exactly one launch is pending, it can only be that launch's burst. Zero or multiple pending launches stay ambiguous and fall through to the existing unresolved-event buffer/TTL path rather than guess -- misattributing an audit event to the wrong sandbox_id is worse than dropping it. A PID registration that does exist (even generation-mismatched) is treated as positive evidence of an existing PID-reuse race and takes precedence over the opportunistic path, preserving the existing generation-key guarantees. Signed-off-by: Prashant Khodade * fix(mxc): surface dropped-unattributed ETW events above debug level An event that ages out of the unresolved-event buffer unattributed is a permanent audit-trail gap: the OS action it represents will never appear in the OCSF log, and nothing retries it afterward. That was only visible at --log-level debug, so an operator running with the default level would never see it. Promote it to warn, matching the severity already used for the zero-events watchdog's own gap warning. Signed-off-by: Prashant Khodade * fix(mxc): keep ETW attribution fail-closed, rate-limit drop warnings The prior opportunistic "single pending launch" fallback attributed any fresh identity/CV from an unregistered or system-broker PID to the sole pending OpenShell launch. Queue cardinality is not attribution evidence: unrelated, non-OpenShell AppContainer or UAC activity shares this same OS Sandboxing provider, so an event arriving in the five-second window could cross-link an unrelated identity and emit subsequent events under the wrong sandbox_id, corrupting the audit trail. Revert it -- attribution requires the driver-owned wxc-exec PID and its kernel process start key to both match, exactly as before. Records without that generation-backed evidence remain unattributed rather than guessed. The dropped-unattributed warning also moved from one line per record to a rate-limited, aggregated warning: unattributed drops are expected, ordinary system-wide activity, not a rare condition, so warning per record could flood operator logs during a burst of unrelated AppContainer/UAC activity. UnattributedDropReporter mirrors the existing OverloadReporter pattern -- warn immediately on the first drop, then coalesce to one warning with a running count every 30 seconds while drops continue. Updates the MXC observability documentation to match: the unattributed-record sentence now explains why fail-closed is deliberate, documents the coalesced warning cadence, and documents the mxc-etw-zero-events OCSF Detection Finding [2004], which was not mentioned anywhere before. Signed-off-by: Prashant Khodade --------- Signed-off-by: Prashant Khodade --- crates/openshell-driver-mxc/README.md | 21 +- .../openshell-driver-mxc/src/etw_consumer.rs | 301 +++++++++++++++++- 2 files changed, 317 insertions(+), 5 deletions(-) diff --git a/crates/openshell-driver-mxc/README.md b/crates/openshell-driver-mxc/README.md index 89269bb3a1..553bea1566 100644 --- a/crates/openshell-driver-mxc/README.md +++ b/crates/openshell-driver-mxc/README.md @@ -132,7 +132,26 @@ PID from inheriting the previous process's attribution regardless of delivery delay. The process monitor retires the live PID at exit. Established identity, activity, and correlation-vector links remain available for five seconds so already in-flight ETW records can arrive, but retired PID evidence cannot resolve -them. Records without matching generation evidence remain unattributed. +them. Records without matching generation evidence remain unattributed -- +deliberately: misattributing an ETW record to the wrong `sandbox_id` would +corrupt the audit trail, which is worse than a coverage gap. Unrelated, +non-OpenShell AppContainer or UAC activity shares this same OS Sandboxing +provider and cannot be told apart from OpenShell's own records without this +generation evidence, so guessing (for example, by assuming a lone pending +launch owns an unmatched record) is not a safe substitute for it. + +An unattributed record is dropped after five seconds, and the driver warns +once immediately, then coalesces further drops to at most one aggregated +warning every 30 seconds while they continue -- unattributed drops are +expected, ordinary activity, not a rare condition, so warning once per record +would let a burst of that activity flood operator logs. + +If the real-time ETW session itself never matches a single record from the +Sandboxing provider despite observed sandbox activity -- for example a +provider-identity mismatch, or the provider not firing at all on a given +host/build -- the driver warns once per session and emits a `mxc-etw-zero-events` +OCSF Detection Finding [2004] naming the gap, distinct from the per-record +unattributed-drop warning above. Each sandbox receives a distinct proxy listener and a random per-sandbox credential through its proxy environment. Missing, incorrect, duplicate, or another sandbox's proxy credentials receive HTTP 407 before policy evaluation or forwarding. This authenticates requests to the OpenShell proxy; it does not restrict access to unrelated host-loopback services or authenticate individual processes inside a sandbox. Proxy credentials and command/environment payloads must not be logged. diff --git a/crates/openshell-driver-mxc/src/etw_consumer.rs b/crates/openshell-driver-mxc/src/etw_consumer.rs index 09b8079868..612412bf5f 100644 --- a/crates/openshell-driver-mxc/src/etw_consumer.rs +++ b/crates/openshell-driver-mxc/src/etw_consumer.rs @@ -111,6 +111,20 @@ const EVENT_QUEUE_BYTE_CAPACITY: usize = 16 * 1024 * 1024; /// while the consumer remains behind. const OVERLOAD_WARNING_INTERVAL: Duration = Duration::from_secs(30); +/// Emit the first dropped-unattributed warning immediately, then coalesce +/// additional drops the same way [`OVERLOAD_WARNING_INTERVAL`] does for queue +/// overload. Unattributed drops are expected, ordinary system-wide activity +/// (unrelated AppContainer/UAC events sharing this same OS Sandboxing +/// provider) rather than a rare condition, so warning once per record would +/// let a burst of that unrelated activity flood operator logs. +const UNATTRIBUTED_DROP_WARNING_INTERVAL: Duration = Duration::from_secs(30); + +/// Grace period, after the first sandbox activity is observed, before the +/// zero-events watchdog warns that the session has matched no provider +/// events at all. Generous on purpose: the goal is to catch a genuinely +/// non-firing provider, not to flag normal per-sandbox event latency. +const ZERO_EVENTS_GRACE: Duration = Duration::from_secs(30); + /// `EVENT_CONTROL_CODE_ENABLE_PROVIDER`. const EVENT_CONTROL_CODE_ENABLE_PROVIDER: u32 = 1; @@ -291,6 +305,14 @@ struct CaptureHealth { queued_bytes: AtomicUsize, /// Largest observed value of `queued_bytes`, retained for diagnostics. queue_high_water_bytes: AtomicUsize, + /// Count of raw events the callback matched to [`SANDBOXING_PROVIDER_GUID`] + /// and forwarded to the consumer thread, regardless of whether TDH decode + /// later succeeded. Zero here after real sandbox activity means the OS + /// session is not delivering *any* events under this GUID at all -- a + /// provider-identity mismatch or a provider that isn't firing on this + /// host/build, not a decode/attribution bug. See the zero-events watchdog + /// in `start_session`'s consumer loop. + events_matched: AtomicU64, } struct CallbackContext { @@ -298,6 +320,50 @@ struct CallbackContext { health: Arc, } +/// Rate-limits the dropped-unattributed warning the same way +/// [`OverloadReporter`] rate-limits the queue-overload warning: report the +/// first drop immediately, then at most once every +/// [`UNATTRIBUTED_DROP_WARNING_INTERVAL`] while drops continue, aggregating +/// the count instead of warning per record. Unrelated, non-OpenShell +/// AppContainer/UAC activity shares this OS Sandboxing provider and cannot be +/// attributed without authoritative per-sandbox evidence, so a burst of that +/// ordinary activity must not flood operator logs one line per event. +#[derive(Default)] +struct UnattributedDropReporter { + total_dropped: u64, + last_reported_dropped: u64, + last_warning: Option, +} + +impl UnattributedDropReporter { + /// Record one more dropped-unattributed event and warn if due. Returns + /// `true` when a warning was actually emitted (useful for tests). + fn record_drop(&mut self, reason: &str, summary: &str) -> bool { + self.total_dropped += 1; + + let now = Instant::now(); + if self + .last_warning + .is_some_and(|last| now.duration_since(last) < UNATTRIBUTED_DROP_WARNING_INTERVAL) + { + return false; + } + + let dropped_since_last_warning = self.total_dropped - self.last_reported_dropped; + self.last_reported_dropped = self.total_dropped; + self.last_warning = Some(now); + tracing::warn!( + target: "mxc_etw", + total_dropped = self.total_dropped, + dropped_since_last_warning, + reason, + last_summary = summary, + "MXC ETW events dropped unattributed; audit coverage has a gap" + ); + true + } +} + #[derive(Default)] struct OverloadReporter { last_reported_drops: u64, @@ -380,6 +446,13 @@ impl EtwSession { pub fn dropped_event_count(&self) -> u64 { self.health.dropped_events.load(Ordering::Relaxed) } + + /// Count of raw provider-matched events received since the session + /// started. Exposed so the backend can surface "capture is running but + /// producing nothing" in status/diagnostics, alongside `is_capture_alive`. + pub fn events_received(&self) -> u64 { + self.health.events_matched.load(Ordering::Relaxed) + } } impl Drop for EtwSession { @@ -420,10 +493,12 @@ pub(crate) fn start_session(index: Arc>) -> Result(EVENT_QUEUE_CAPACITY); - let consumer_thread = std::thread::Builder::new() .name("etw-ocsf-consumer".into()) .spawn(move || { @@ -435,10 +510,23 @@ pub(crate) fn start_session(index: Arc>) -> Result = None; + let mut warned_no_events = false; loop { match rx.recv_timeout(Duration::from_millis(200)) { Ok(mut raw) => { release_queue_bytes(&consumer_health, raw.queued_bytes); + consumer_health + .events_matched + .fetch_add(1, Ordering::Relaxed); if let Some(ev) = decode_raw(&mut raw) { process_event(&index, ev); } else { @@ -462,6 +550,18 @@ pub(crate) fn start_session(index: Arc>) -> Result, + /// Total sandboxes ever registered, never decremented by [`Self::forget`]. + /// Used to gate the zero-events watchdog: a session that hasn't seen any + /// sandbox activity yet is expected to be quiet, so only warn once real + /// activity has happened and still produced nothing. + total_launches: u64, + /// Rate-limits the dropped-unattributed warning; see + /// [`UnattributedDropReporter`]. + unattributed_drops: UnattributedDropReporter, } impl AttributionIndex { @@ -1300,6 +1408,7 @@ impl AttributionIndex { wxc_pid: u32, process_start_key: u64, ) { + self.total_launches += 1; let now = Instant::now(); self.purge_expired_retirements(now); let previous = self.by_pid.remove(&wxc_pid); @@ -1371,6 +1480,12 @@ impl AttributionIndex { .any(|registration| registration.sid == sandbox_id) } + /// Total sandboxes ever registered via [`Self::register_launch`], including + /// ones since [`Self::forget`]-ten. Used to gate the zero-events watchdog. + pub fn total_launches(&self) -> u64 { + self.total_launches + } + /// Drop all keys for a finished sandbox to bound memory. pub fn forget(&mut self, sandbox_id: &str) { self.by_pid @@ -1480,7 +1595,15 @@ impl AttributionIndex { if now.duration_since(front.at) > PENDING_TTL { let stale = self.pending.pop_front(); if let Some(p) = stale { - tracing::debug!(target: "mxc_etw", pid = p.ev.process_id, "dropping unattributed (aged out) {}", p.ev.summary()); + // A permanently dropped event is an audit-trail gap (the + // OS action it represents will never appear in the OCSF + // log), so it's worth surfacing above debug level by + // default rather than only under `--log-level debug`. + // Rate-limited and aggregated: unattributed drops are + // expected, ordinary activity from unrelated AppContainer/ + // UAC events sharing this provider, not a rare condition. + self.unattributed_drops + .record_drop("aged_out", &p.ev.summary()); } } else { break; @@ -1489,7 +1612,8 @@ impl AttributionIndex { if self.pending.len() >= PENDING_MAX && let Some(p) = self.pending.pop_front() { - tracing::debug!(target: "mxc_etw", pid = p.ev.process_id, "dropping unattributed (buffer full) {}", p.ev.summary()); + self.unattributed_drops + .record_drop("buffer_full", &p.ev.summary()); } self.pending.push_back(PendingEvent { at: now, ev }); } @@ -1509,7 +1633,8 @@ impl AttributionIndex { let mut keep = VecDeque::with_capacity(drained.len()); for p in drained { if now.duration_since(p.at) > PENDING_TTL { - tracing::debug!(target: "mxc_etw", pid = p.ev.process_id, "dropping unattributed (aged out) {}", p.ev.summary()); + self.unattributed_drops + .record_drop("aged_out", &p.ev.summary()); continue; } match self.resolve(&p.ev) { @@ -1828,6 +1953,74 @@ fn map_finding(ctx: &EventContext, ev: &DecodedEtwEvent) -> OcsfEvent { .build() } +/// Decide whether the zero-events watchdog should fire, and track when +/// sandbox activity was first observed. Pure aside from `*activity_since`, +/// which the caller retains across calls (and across a loop tick that +/// doesn't warn) so the grace period is measured from first activity, not +/// re-armed on every tick. +/// +/// Gates on `total_launches` (not the index's current size) specifically so +/// a quiet, idle gateway with `etw_audit=true` but zero sandboxes created +/// never warns -- only genuine "activity happened, nothing arrived" does. +fn should_warn_zero_events( + events_matched: u64, + total_launches: u64, + activity_since: &mut Option, + grace: Duration, +) -> bool { + if events_matched != 0 { + return false; + } + if activity_since.is_none() && total_launches > 0 { + *activity_since = Some(Instant::now()); + } + activity_since.is_some_and(|since| since.elapsed() >= grace) +} + +/// Fired once per session by the zero-events watchdog when real sandbox +/// activity has happened but the session has never matched a single event to +/// [`SANDBOXING_PROVIDER_GUID`]. `EnableTraceEx2` success only proves the +/// *request* to enable the provider succeeded, not that the provider exists +/// on this host/build or will ever actually fire -- this is the detection gap +/// that made a real-world provider-identity mismatch silently produce an +/// empty OCSF audit trail with no diagnostic at all. +fn warn_zero_events_received() { + tracing::warn!( + target: "mxc_etw", + provider = ?SANDBOXING_PROVIDER_GUID, + session = SESSION_NAME_PREFIX, + "MXC ETW->OCSF consumer has received zero events from the Sandboxing \ + provider despite sandbox activity; the OS-sourced audit trail is \ + empty for this session. EnableTraceEx2 succeeding does not prove the \ + provider exists on this host/build or will ever fire -- verify with \ + `logman query providers` and confirm wxc-exec targets this GUID." + ); + emit_ocsf( + "", + DetectionFindingBuilder::new(&etw_ctx("", "mxc-etw-consumer")) + .activity(ActivityId::Open) // finding label = "Create" + .severity(SeverityId::High) + .is_alert(true) + .finding_info( + FindingInfo::new( + "mxc-etw-zero-events", + "MXC ETW audit consumer received zero provider events", + ) + .with_desc( + "The Sandboxing ETW provider produced zero events despite \ + observed sandbox activity; the OS-sourced portion of the \ + audit trail is empty for this session.", + ), + ) + .message( + "MXC ETW->OCSF consumer active but received zero events from \ + the Sandboxing provider after sandbox activity" + .to_string(), + ) + .build(), + ); +} + /// Best-effort executable name from a command line: first whitespace-delimited /// token, stripped of any directory prefix and surrounding quotes. fn exe_name(cmd_line: &str) -> String { @@ -2009,6 +2202,38 @@ mod tests { assert_eq!(reporter.last_reported_drops, 3); } + #[test] + fn unattributed_drop_reporter_warns_immediately_then_coalesces() { + let mut reporter = UnattributedDropReporter::default(); + + // The very first drop, ever, warns immediately (no prior warning to + // rate-limit against). + assert!(reporter.record_drop("aged_out", "SandboxConfig (id=0)")); + assert_eq!(reporter.total_dropped, 1); + assert_eq!(reporter.last_reported_dropped, 1); + + // Further drops within the interval are aggregated, not re-warned, + // but still counted so the next warning reports the true total. + assert!(!reporter.record_drop("aged_out", "EnforceOsPolicy (id=0)")); + assert!(!reporter.record_drop("buffer_full", "ProcessLaunched (id=0)")); + assert_eq!( + reporter.total_dropped, 3, + "every drop is counted even when coalesced" + ); + assert_eq!( + reporter.last_reported_dropped, 1, + "the reported total only advances when a warning actually fires" + ); + + // Force the rate limit open by backdating the last warning, then + // confirm the next drop reports the two that were coalesced since. + reporter.last_warning = Instant::now() + .checked_sub(UNATTRIBUTED_DROP_WARNING_INTERVAL + Duration::from_millis(1)); + assert!(reporter.record_drop("aged_out", "SetUILimitsOnJob (id=0)")); + assert_eq!(reporter.total_dropped, 4); + assert_eq!(reporter.last_reported_dropped, 4); + } + #[test] fn gateway_processes_and_restarts_use_distinct_session_names() { let first_gateway = format_session_name(1001, 0x1111, 0); @@ -2198,6 +2423,74 @@ mod tests { assert_eq!(idx.resolve(&ev_b).as_deref(), Some("sbx-B")); } + // Regression test for the zero-events watchdog: it must gate on whether + // sandbox activity has *ever* happened, not on the index's current size, + // since `forget` empties the index for every sandbox that completes + // normally -- `total_launches` must keep counting past that. + #[test] + fn total_launches_survives_forget() { + let mut idx = AttributionIndex::new(); + assert_eq!(idx.total_launches(), 0); + + idx.register_launch("sbx-1", "s1", 100, 1); + idx.forget("sbx-1"); + assert_eq!(idx.total_launches(), 1); + + idx.register_launch("sbx-2", "s2", 200, 2); + idx.register_launch("sbx-3", "s3", 300, 3); + assert_eq!(idx.total_launches(), 3); + } + + #[test] + fn zero_events_watchdog_stays_quiet_without_sandbox_activity() { + // An idle gateway with etw_audit=true but no sandboxes created yet + // must never warn, however long it's been running. + let mut activity_since = None; + assert!(!should_warn_zero_events( + 0, + 0, + &mut activity_since, + Duration::ZERO + )); + assert!(activity_since.is_none()); + } + + #[test] + fn zero_events_watchdog_stays_quiet_once_any_event_matched() { + let mut activity_since = None; + assert!(!should_warn_zero_events( + 1, + 5, + &mut activity_since, + Duration::ZERO + )); + } + + #[test] + fn zero_events_watchdog_waits_out_the_grace_period() { + let mut activity_since = None; + let grace = Duration::from_hours(1); + // First tick after activity starts: grace hasn't elapsed yet. + assert!(!should_warn_zero_events(0, 1, &mut activity_since, grace)); + assert!(activity_since.is_some()); + // A later tick still within the (long) grace window: still quiet. + assert!(!should_warn_zero_events(0, 1, &mut activity_since, grace)); + } + + #[test] + fn zero_events_watchdog_fires_once_grace_elapses() { + let mut activity_since = Some(Instant::now().checked_sub(Duration::from_mins(1)).unwrap()); + assert!(should_warn_zero_events( + 0, + 1, + &mut activity_since, + Duration::from_secs(30) + )); + } + + // Shailendra #1 (cmd ambiguity): two sandboxes running the identical command + // line must not let that command line resolve anything (it's ambiguous); a + // unique command line still works as a fallback. #[test] fn displaced_live_pid_does_not_claim_new_pre_registration_event() { let mut idx = AttributionIndex::new(); From e084e1b09e646e25aca0af11f1855da88acb495a Mon Sep 17 00:00:00 2001 From: pkhodade-NV <84895267+pkhodade-NV@users.noreply.github.com> Date: Wed, 23 Sep 2026 02:13:49 +0530 Subject: [PATCH 14/42] fix(server): keep Error-phase sandbox records through the prune sweep (#3498) * fix(server): keep Error-phase sandbox records through the prune sweep The periodic store-vs-backend reconciliation sweep deleted any persisted sandbox not present in the driver's live backend snapshot, except for Completed and failed-main-process phases. A driver whose registry is in-process-only and never rehydrates after a restart (no persistence of its own) reports every previously-known sandbox as missing on the very first sweep after startup -- including ones already correctly, terminally marked Error by earlier crash detection -- so the sweep silently deleted them shortly after gateway restart, racing any client (GetSandbox/ListSandboxes/DeleteSandbox) working with the same sandbox in that window. Treat Error the same as the existing Completed exemption: it is already a settled, informational terminal state with no live compute resource to reclaim, so keep the durable record instead of deleting it. Signed-off-by: Prashant Khodade (cherry picked from commit 721a1659a822a72e76e3c0dffc6847f17129a3fc) * fix(server): narrow the prune sweep's Error-phase exemption The blanket phase == SandboxPhase::Error exemption changed established missing-backend cleanup for every compute driver, not only the MXC restart race the PR intended to fix. It also matched BackendResourceMissing (set by gateway-startup recovery when a previously-known sandbox's backend resource is already gone), StartFailed (startup recovery's driver-error case), and ComputeResourceMissing (this same sweep's own first-pass Error transition for a Stopping/Stopped/Starting sandbox). All three mark exactly the orphaned resources this sweep exists to reclaim across Docker, Podman, VM, Kubernetes, and extension drivers -- exempting them left orphaned names and gateway-owned records in place indefinitely and skipped the idempotent driver cleanup for volumes/secrets until a user explicitly deleted the sandbox. Add is_missing_compute_resource_reason to inspect the sandbox's Ready condition and narrow the exemption to a settled Error record only: one whose reason isn't one of those three. A crashed main process or any other non-resource failure keeps the exemption (no live resource ever expected again); a resource-missing reason keeps flowing through the normal delete-and-cleanup path exactly as before this PR. Adds regression coverage for BackendResourceMissing and ComputeResourceMissing confirming they are still pruned with driver cleanup invoked, and documents the settled-vs-missing-resource retention distinction in architecture/compute-runtimes.md. Signed-off-by: Prashant Khodade --------- Signed-off-by: Prashant Khodade --- architecture/compute-runtimes.md | 13 ++ crates/openshell-server/src/compute/mod.rs | 182 ++++++++++++++++++++- 2 files changed, 194 insertions(+), 1 deletion(-) diff --git a/architecture/compute-runtimes.md b/architecture/compute-runtimes.md index 8f7c5e5080..1205b67594 100644 --- a/architecture/compute-runtimes.md +++ b/architecture/compute-runtimes.md @@ -282,6 +282,19 @@ owned task prevents cancellation from stranding a mutation. A gateway restart does not start a persisted `Deleting` operation. If the backend completed the delete, reconciliation removes the row; otherwise it can remain `Deleting`. +The prune sweep retains a `Completed` sandbox and a settled `Error` sandbox +(a crashed main process, or any other terminal failure not specifically about +the compute resource itself) even when the driver reports it missing: both +are audit records with no live resource to reclaim, and deleting one out from +under a concurrent `GetSandbox`/`ListSandboxes`/`DeleteSandbox` caller would +be a silent race, not a cleanup. An `Error` sandbox whose condition reports +the compute resource itself as missing -- set either by startup recovery +finding a previously-known sandbox already gone, or by this same sweep +transitioning a `Stopping`/`Stopped`/`Starting` sandbox to `Error` on an +earlier pass -- is not retained this way: it is exactly the orphaned resource +this sweep exists to reclaim, so it keeps flowing through the normal +delete-and-cleanup path above. + ## Runtime Summary | Runtime | Best fit | Sandbox boundary | Notes | diff --git a/crates/openshell-server/src/compute/mod.rs b/crates/openshell-server/src/compute/mod.rs index be57ca32dd..9b5bab1259 100644 --- a/crates/openshell-server/src/compute/mod.rs +++ b/crates/openshell-server/src/compute/mod.rs @@ -3875,9 +3875,33 @@ impl ComputeRuntime { let sandbox = decode_sandbox_record(¤t_record)?; let phase = SandboxPhase::try_from(sandbox.phase()).unwrap_or(SandboxPhase::Unknown); - if phase == SandboxPhase::Completed || is_failed_main_process_result(&sandbox) { + if phase == SandboxPhase::Completed + || (phase == SandboxPhase::Error && !is_missing_compute_resource_reason(&sandbox)) + || is_failed_main_process_result(&sandbox) + { // A terminal canonical process may legitimately have removed its // transient compute object. Keep the durable command result. + // + // A settled Error record (crash detection, an explicit + // non-resource failure, etc.) is the same kind of terminal state + // with no live compute resource to reclaim -- deleting it outright + // instead of leaving it in place silently races a concurrent + // GetSandbox/ListSandboxes/DeleteSandbox caller. A driver whose + // registry never rehydrates after a restart (in-process-only + // state, e.g. MXC) reports every previously-known sandbox as + // "missing" on the very first sweep after startup, even ones + // already correctly marked Error by earlier crash detection, so + // this is reached far more than the "orphaned compute resource" + // case this pruning otherwise exists for. + // + // This must NOT exempt an Error record whose reason specifically + // says the compute resource itself is missing + // (`is_missing_compute_resource_reason`) -- those are exactly + // this sweep's intended target (see that function's doc comment), + // set either by startup recovery or by this same sweep transitioning + // Stopping/Stopped/Starting below. Exempting them too would leave + // orphaned names and gateway-owned records in place indefinitely + // and skip the idempotent driver cleanup for volumes/secrets. return Ok(()); } if matches!( @@ -4040,6 +4064,38 @@ fn is_failed_main_process_result(sandbox: &Sandbox) -> bool { }) } +/// Ready-condition reasons the gateway uses to mark an Error-phase sandbox +/// specifically because its compute resource was found missing, rather than +/// because a running process crashed or exited: `BackendResourceMissing` and +/// `StartFailed` come from gateway-startup recovery +/// (`resume_sandboxes_at_startup`) finding a `NotFound`/other backend error +/// for a previously-known sandbox, and `ComputeResourceMissing` comes from +/// `prune_missing_sandbox` itself transitioning a Stopping/Stopped/Starting +/// sandbox to Error the first time its resource is found missing. +/// +/// These reasons mark exactly the sandboxes `prune_missing_sandbox`'s orphan +/// cleanup exists to reclaim -- an Error phase alone must not exempt them +/// from that sweep's delete-and-cleanup path the way a settled, no-resource- +/// expected Error record (a crashed main process, an explicit non-resource +/// failure) is exempted, or orphaned names and gateway-owned records would +/// never get pruned and idempotent driver cleanup for volumes/secrets would +/// never run. +const MISSING_COMPUTE_RESOURCE_REASONS: [&str; 3] = [ + "BackendResourceMissing", + "StartFailed", + "ComputeResourceMissing", +]; + +fn is_missing_compute_resource_reason(sandbox: &Sandbox) -> bool { + sandbox + .status + .as_ref() + .and_then(|status| status.conditions.iter().find(|c| c.r#type == "Ready")) + .is_some_and(|condition| { + MISSING_COMPUTE_RESOURCE_REASONS.contains(&condition.reason.as_str()) + }) +} + /// Connect to an unmanaged remote compute driver that is already listening on /// `socket_path` and return the acquired endpoint. /// @@ -9372,6 +9428,130 @@ mod tests { ); } + #[tokio::test] + async fn prune_missing_sandbox_keeps_error_phase_records() { + // Regression test: a driver whose registry never rehydrates after a + // restart (in-process-only state, e.g. MXC) reports every + // previously-known sandbox as missing on the first sweep after + // startup -- including ones already correctly, terminally marked + // Error by earlier crash detection. The sweep must not delete those; + // it must treat Error the same as the existing Completed exemption + // and leave the durable record in place. + let driver = ControlledDriver::new(); + driver.set_get_outcome(ControlledGetOutcome::Missing); + let runtime = test_runtime(driver.clone()).await; + let sandbox = sandbox_record("sb-1", "sandbox-a", SandboxPhase::Error); + runtime.store.put_message(&sandbox).await.unwrap(); + + runtime + .reconcile_store_with_backend(Duration::ZERO) + .await + .unwrap(); + + let retained = runtime + .store + .get_message::("sb-1") + .await + .unwrap() + .expect("Error-phase sandbox record must survive the prune sweep"); + assert_eq!(retained.phase(), SandboxPhase::Error as i32); + assert_eq!(driver.delete_calls(), 0); + } + + #[tokio::test] + async fn prune_missing_sandbox_still_reclaims_backend_resource_missing_records() { + // Regression test for a review finding on the Error-phase exemption + // above: BackendResourceMissing is the reason gateway-startup + // recovery uses (see `resume_sandboxes_at_startup`) when a + // previously-known sandbox's backend resource is already gone. The + // Error-phase exemption must not swallow this case -- it is exactly + // the "orphaned compute resource" this sweep exists to reclaim, so + // the record and its driver-owned resources must still be pruned. + let driver = ControlledDriver::new(); + driver.set_get_outcome(ControlledGetOutcome::Missing); + let runtime = test_runtime(driver.clone()).await; + let mut sandbox = sandbox_record("sb-1", "sandbox-a", SandboxPhase::Error); + let name = sandbox.object_name().to_string(); + upsert_ready_condition( + &mut sandbox.status, + &name, + SandboxCondition { + r#type: "Ready".to_string(), + status: "False".to_string(), + reason: "BackendResourceMissing".to_string(), + message: "Sandbox compute resource disappeared while the gateway was offline" + .to_string(), + last_transition_time: String::new(), + }, + ); + runtime.store.put_message(&sandbox).await.unwrap(); + + runtime + .reconcile_store_with_backend(Duration::ZERO) + .await + .unwrap(); + + assert!( + runtime + .store + .get_message::("sb-1") + .await + .unwrap() + .is_none(), + "BackendResourceMissing must not be exempted from pruning" + ); + tokio::time::timeout(Duration::from_secs(1), driver.delete_started.notified()) + .await + .expect("background driver cleanup did not run"); + assert_eq!(driver.delete_calls(), 1); + } + + #[tokio::test] + async fn prune_missing_sandbox_still_reclaims_compute_resource_missing_records() { + // Regression test for the same review finding: ComputeResourceMissing + // is the reason this same sweep sets when it first finds a + // Stopping/Stopped/Starting sandbox's resource missing (see below). + // A later sweep must still reclaim it once the retention window + // passes, not treat the Error phase it left behind as settled. + let driver = ControlledDriver::new(); + driver.set_get_outcome(ControlledGetOutcome::Missing); + let runtime = test_runtime(driver.clone()).await; + let mut sandbox = sandbox_record("sb-1", "sandbox-a", SandboxPhase::Error); + let name = sandbox.object_name().to_string(); + upsert_ready_condition( + &mut sandbox.status, + &name, + SandboxCondition { + r#type: "Ready".to_string(), + status: "False".to_string(), + reason: "ComputeResourceMissing".to_string(), + message: "The compute driver could not find the retained sandbox resource; delete the sandbox to clean up its remaining state" + .to_string(), + last_transition_time: String::new(), + }, + ); + runtime.store.put_message(&sandbox).await.unwrap(); + + runtime + .reconcile_store_with_backend(Duration::ZERO) + .await + .unwrap(); + + assert!( + runtime + .store + .get_message::("sb-1") + .await + .unwrap() + .is_none(), + "ComputeResourceMissing must not be exempted from pruning" + ); + tokio::time::timeout(Duration::from_secs(1), driver.delete_started.notified()) + .await + .expect("background driver cleanup did not run"); + assert_eq!(driver.delete_calls(), 1); + } + #[tokio::test] async fn prune_sweep_does_not_block_on_a_stuck_driver_delete_call() { // Regression test: the prune sweep's driver cleanup must not be From 0b8f3821e2a9ac79c4ea074c2ab33c7383fd92d0 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas <34834085+prekshivyas@users.noreply.github.com> Date: Tue, 22 Sep 2026 13:47:25 -0700 Subject: [PATCH 15/42] fix(network): normalize Windows binary paths (NVBug 6782969) (#3482) * fix(network): normalize Windows policy binary paths Match Windows executable identities using a stable case-insensitive, separator-normalized representation across policy data, L4 input, and L7 relay evaluation. Preserve exact matching on other platforms and keep the original path for hashing and filesystem access. NVBug 6782969 Signed-off-by: Prekshi Vyas * fix(network): harden Windows binary matching Signed-off-by: Prekshi Vyas * fix(ci): scope Windows relay test imports * fix(network): harden Windows binary path matching --------- Signed-off-by: Prekshi Vyas --- Cargo.lock | 1 + .../openshell-supervisor-network/Cargo.toml | 3 + .../data/sandbox-policy.rego | 50 ++- .../src/l7/relay.rs | 1 + .../openshell-supervisor-network/src/opa.rs | 408 +++++++++++++++++- .../openshell-supervisor-network/src/proxy.rs | 10 +- .../src/proxy/egress.rs | 2 + .../src/proxy/relay.rs | 125 +++++- .../src/proxy/tests/compatibility.rs | 1 + docs/reference/policy-schema.mdx | 7 + docs/security/best-practices.mdx | 8 + 11 files changed, 598 insertions(+), 18 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 572d0742a6..182b278170 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4685,6 +4685,7 @@ dependencies = [ "tracing-subscriber", "uuid", "webpki-roots", + "windows", ] [[package]] diff --git a/crates/openshell-supervisor-network/Cargo.toml b/crates/openshell-supervisor-network/Cargo.toml index e4a2f12917..23db6efcfe 100644 --- a/crates/openshell-supervisor-network/Cargo.toml +++ b/crates/openshell-supervisor-network/Cargo.toml @@ -69,6 +69,9 @@ tokio-stream = { workspace = true, features = ["net"] } [target.'cfg(unix)'.dependencies] libc = "0.2" +[target.'cfg(windows)'.dependencies] +windows = { workspace = true, features = ["Win32_Storage_FileSystem"] } + [target.'cfg(unix)'.dev-dependencies] [lints] diff --git a/crates/openshell-supervisor-network/data/sandbox-policy.rego b/crates/openshell-supervisor-network/data/sandbox-policy.rego index 278291a288..1c10eb0565 100644 --- a/crates/openshell-supervisor-network/data/sandbox-policy.rego +++ b/crates/openshell-supervisor-network/data/sandbox-policy.rego @@ -141,25 +141,54 @@ binary_allowed(_, _) if { not binary_identity_required } +# Binary path matching uses exact spelling by default. On Windows, Rust adds an +# ASCII-folded candidate only after querying every parent directory of the +# runtime-observed executable and confirming case-insensitive lookup throughout. +binary_path_matches(binary, candidate) if { + binary.path == candidate.path +} + +binary_path_matches(binary, candidate) if { + candidate.ascii_case_folded != "" + binary.ascii_case_folded_path == candidate.ascii_case_folded +} + +binary_glob_matches(binary, candidate) if { + glob.match(binary.path, ["/"], candidate.path) +} + +binary_glob_matches(binary, candidate) if { + candidate.ascii_case_folded != "" + glob.match(binary.ascii_case_folded_path, ["/"], candidate.ascii_case_folded) +} + # Binary matching: exact path. # SHA256 integrity is enforced in Rust via trust-on-first-use (TOFU) cache, # not in Rego. The proxy computes and caches binary hashes at runtime. binary_allowed(policy, exec) if { - some b - b := policy.binaries[_] + some b in policy.binaries not contains(b.path, "*") b.path == exec.path } # Binary matching: ancestor exact path (e.g., claude spawns node). binary_allowed(policy, exec) if { - some b - b := policy.binaries[_] + some b in policy.binaries not contains(b.path, "*") - ancestor := exec.ancestors[_] + some ancestor in exec.ancestors b.path == ancestor } +# Trusted runtime evidence may additionally authorize an ASCII case-folded +# Windows path. Legacy and synthetic L7 inputs omit match_paths and continue to +# use only the exact rules above. +binary_allowed(policy, exec) if { + some b in policy.binaries + not contains(b.path, "*") + some candidate in exec.match_paths + binary_path_matches(b, candidate) +} + # Binary matching: glob pattern against exe path or any ancestor. # NOTE: cmdline_paths are intentionally excluded — argv[0] is trivially # spoofable via execve and must not be used as a grant-access signal. @@ -167,8 +196,15 @@ binary_allowed(policy, exec) if { some b in policy.binaries contains(b.path, "*") all_paths := array.concat([exec.path], exec.ancestors) - some p in all_paths - glob.match(b.path, ["/"], p) + some path in all_paths + glob.match(b.path, ["/"], path) +} + +binary_allowed(policy, exec) if { + some b in policy.binaries + contains(b.path, "*") + some candidate in exec.match_paths + binary_glob_matches(b, candidate) } # --- Network action (allow / deny) --- diff --git a/crates/openshell-supervisor-network/src/l7/relay.rs b/crates/openshell-supervisor-network/src/l7/relay.rs index b319c54112..ca7ca2f57a 100644 --- a/crates/openshell-supervisor-network/src/l7/relay.rs +++ b/crates/openshell-supervisor-network/src/l7/relay.rs @@ -2936,6 +2936,7 @@ fn evaluate_l7_request_once( "path": ctx.binary_path, "ancestors": ctx.ancestors, "cmdline_paths": ctx.cmdline_paths, + "match_paths": engine.binary_match_paths(), }, "request": { "method": request.action, diff --git a/crates/openshell-supervisor-network/src/opa.rs b/crates/openshell-supervisor-network/src/opa.rs index 31fc7f99ee..a4bf2c319b 100644 --- a/crates/openshell-supervisor-network/src/opa.rs +++ b/crates/openshell-supervisor-network/src/opa.rs @@ -78,6 +78,7 @@ pub struct EgressAuthorization { pub matched_endpoints: Vec, pub exact_declared_endpoint_host: bool, pub generation: u64, + pub(crate) binary_match_paths: Vec, } /// Input for a network access policy evaluation. @@ -230,6 +231,7 @@ pub struct TunnelPolicyEngine { generation_guard: PolicyGenerationGuard, middleware_runner: ChainRunner, websocket_assembly_budget: crate::l7::websocket::WebSocketAssemblyBudget, + binary_match_paths: Vec, } impl TunnelPolicyEngine { @@ -257,6 +259,10 @@ impl TunnelPolicyEngine { &self.middleware_runner } + pub(crate) fn binary_match_paths(&self) -> &[NetworkBinaryPathEvidence] { + &self.binary_match_paths + } + pub(crate) fn websocket_assembly_budget( &self, ) -> crate::l7::websocket::WebSocketAssemblyBudget { @@ -448,6 +454,7 @@ impl OpaEngine { let mut data: serde_json::Value = serde_json::from_str(&data_json_str) .map_err(|e| miette::miette!("internal: failed to parse proto JSON: {e}"))?; inject_runtime_policy_data(&mut data, require_binary_identity); + normalize_network_binary_paths(&mut data); normalize_endpoint_protocols(&mut data); // Validate BEFORE expanding presets @@ -554,7 +561,8 @@ impl OpaEngine { #[cfg(test)] record_test_opa_query(); - let input_json = network_input_json(input); + let binary_match_paths = network_binary_path_evidence_for_input(input); + let input_json = network_input_json_with_match_paths(input, &binary_match_paths); let mut engine = self .engine @@ -574,6 +582,7 @@ impl OpaEngine { matched_endpoints: Vec::new(), exact_declared_endpoint_host: false, generation, + binary_match_paths, }); } @@ -613,6 +622,7 @@ impl OpaEngine { matched_endpoints, exact_declared_endpoint_host, generation, + binary_match_paths, }) } @@ -1060,6 +1070,14 @@ impl OpaEngine { /// and only duplicates interpreter state (~microseconds). The cloned /// engine can be used without Mutex contention. pub fn clone_engine_for_tunnel(&self, expected_generation: u64) -> Result { + self.clone_engine_for_tunnel_with_match_paths(expected_generation, Vec::new()) + } + + pub(crate) fn clone_engine_for_tunnel_with_match_paths( + &self, + expected_generation: u64, + binary_match_paths: Vec, + ) -> Result { let engine = self .engine .lock() @@ -1079,6 +1097,7 @@ impl OpaEngine { }, middleware_runner: self.middleware_runner()?, websocket_assembly_budget: self.websocket_assembly_budget(), + binary_match_paths, }) } } @@ -1138,10 +1157,31 @@ fn get_str_array(val: ®orus::Value, key: &str) -> Vec { } fn network_input_json(input: &NetworkInput) -> serde_json::Value { - let ancestor_strs: Vec = input - .ancestors + let match_paths = network_binary_path_evidence_for_input(input); + network_input_json_with_match_paths(input, &match_paths) +} + +fn network_binary_path_evidence_for_input(input: &NetworkInput) -> Vec { + let mut match_paths = Vec::with_capacity(input.ancestors.len() + 1); + match_paths.push(network_binary_path_evidence(&input.binary_path)); + match_paths.extend( + input + .ancestors + .iter() + .map(|path| network_binary_path_evidence(path)), + ); + match_paths +} + +fn network_input_json_with_match_paths( + input: &NetworkInput, + match_paths: &[NetworkBinaryPathEvidence], +) -> serde_json::Value { + let binary = &match_paths[0]; + let ancestor_strs: Vec<&str> = match_paths .iter() - .map(|p| p.to_string_lossy().into_owned()) + .skip(1) + .map(|path| path.path.as_str()) .collect(); let cmdline_strs: Vec = input .cmdline_paths @@ -1150,9 +1190,10 @@ fn network_input_json(input: &NetworkInput) -> serde_json::Value { .collect(); serde_json::json!({ "exec": { - "path": input.binary_path.to_string_lossy(), + "path": &binary.path, "ancestors": ancestor_strs, "cmdline_paths": cmdline_strs, + "match_paths": match_paths, }, "network": { "host": input.host, @@ -1161,6 +1202,126 @@ fn network_input_json(input: &NetworkInput) -> serde_json::Value { }) } +/// Return the stable representation used only for network-policy path matching. +/// +/// Windows paths accept either path separator. Namespace paths retain their exact +/// spelling, and other platforms retain exact matching. This lexical operation +/// never probes the filesystem, so it is also safe for policy-authored paths. +pub(crate) fn network_binary_match_path(path: &Path) -> String { + let path = path.to_string_lossy(); + #[cfg(target_os = "windows")] + { + windows_network_binary_match_path(&path) + } + #[cfg(not(target_os = "windows"))] + { + path.into_owned() + } +} + +#[cfg(any(target_os = "windows", test))] +fn windows_network_binary_match_path(path: &str) -> String { + if is_windows_namespace_path(path) { + return path.to_owned(); + } + path.replace('\\', "/") +} + +#[cfg(any(target_os = "windows", test))] +fn is_windows_namespace_path(path: &str) -> bool { + let bytes = path.as_bytes(); + bytes.len() >= 2 && matches!(bytes[0], b'/' | b'\\') && matches!(bytes[1], b'/' | b'\\') +} + +#[derive(Clone, Debug, serde::Serialize)] +pub(crate) struct NetworkBinaryPathEvidence { + path: String, + /// Present only when trusted runtime evidence confirms that every parent + /// directory uses case-insensitive lookup. + ascii_case_folded: String, +} + +fn network_binary_path_evidence(path: &Path) -> NetworkBinaryPathEvidence { + let normalized = network_binary_match_path(path); + #[cfg(target_os = "windows")] + let ascii_case_folded = windows_path_components_case_insensitive(path) + .filter(|case_insensitive| *case_insensitive) + .map_or_else(String::new, |_| normalized.to_ascii_lowercase()); + #[cfg(not(target_os = "windows"))] + let ascii_case_folded = String::new(); + + NetworkBinaryPathEvidence { + path: normalized, + ascii_case_folded, + } +} + +#[cfg(target_os = "windows")] +#[allow(unsafe_code)] +fn windows_directory_case_sensitive(path: &Path) -> Option { + use std::mem::size_of; + use std::os::windows::io::AsRawHandle; + use windows::Win32::Foundation::HANDLE; + use windows::Win32::Storage::FileSystem::{ + FILE_CASE_SENSITIVE_INFO, FileCaseSensitiveInfo, GetFileInformationByHandleEx, + }; + + let handle = open_windows_directory_for_attributes(path)?; + let mut info = FILE_CASE_SENSITIVE_INFO::default(); + let info_size = u32::try_from(size_of::()).ok()?; + // SAFETY: `handle` stays alive for the call and `info` is the exact buffer + // type and size required by `FileCaseSensitiveInfo`. + unsafe { + GetFileInformationByHandleEx( + HANDLE(handle.as_raw_handle()), + FileCaseSensitiveInfo, + (&raw mut info).cast(), + info_size, + ) + } + .ok()?; + Some(info.Flags & 1 != 0) +} + +#[cfg(target_os = "windows")] +fn windows_path_components_case_insensitive(path: &Path) -> Option { + let text = path.to_string_lossy(); + let bytes = text.as_bytes(); + if text.contains(['*', '?']) + || is_windows_namespace_path(&text) + || bytes.len() < 3 + || !bytes[0].is_ascii_alphabetic() + || bytes[1] != b':' + || !matches!(bytes[2], b'/' | b'\\') + { + return None; + } + + let parent = path.parent()?; + for directory in parent.ancestors().filter(|p| !p.as_os_str().is_empty()) { + if windows_directory_case_sensitive(directory)? { + return Some(false); + } + } + Some(true) +} + +#[cfg(target_os = "windows")] +fn open_windows_directory_for_attributes(path: &Path) -> Option { + use std::os::windows::fs::OpenOptionsExt; + use windows::Win32::Storage::FileSystem::{ + FILE_FLAG_BACKUP_SEMANTICS, FILE_READ_ATTRIBUTES, FILE_SHARE_DELETE, FILE_SHARE_READ, + FILE_SHARE_WRITE, + }; + + std::fs::OpenOptions::new() + .access_mode(FILE_READ_ATTRIBUTES.0) + .share_mode(FILE_SHARE_READ.0 | FILE_SHARE_WRITE.0 | FILE_SHARE_DELETE.0) + .custom_flags(FILE_FLAG_BACKUP_SEMANTICS.0) + .open(path) + .ok() +} + /// Sets an already-built JSON value as Regorus input without encoding and reparsing JSON text. /// /// The explicit fallible conversion preserves evaluator errors because Regorus's infallible @@ -1463,6 +1624,7 @@ fn preprocess_yaml_data( .map_err(|e| miette::miette!("failed to parse YAML data: {e}"))?; validate_opa_data_structure(&data)?; inject_runtime_policy_data(&mut data, require_binary_identity); + normalize_network_binary_paths(&mut data); normalize_endpoint_protocols(&mut data); // Normalize port → ports for all endpoints so Rego always sees "ports" array. @@ -1564,6 +1726,43 @@ fn normalize_endpoint_protocols(data: &mut serde_json::Value) { } } +/// Normalize configured binary paths without accessing the filesystem. +/// +/// Policy paths are untrusted input and may name remote shares or device +/// namespaces. Case-insensitive matching is therefore decided later using only +/// trusted runtime path evidence. +fn normalize_network_binary_paths(data: &mut serde_json::Value) { + let Some(policies) = data + .get_mut("network_policies") + .and_then(serde_json::Value::as_object_mut) + else { + return; + }; + + for policy in policies.values_mut() { + let Some(binaries) = policy + .get_mut("binaries") + .and_then(serde_json::Value::as_array_mut) + else { + continue; + }; + for binary in binaries { + let Some(binary) = binary.as_object_mut() else { + continue; + }; + let Some(value) = binary.get("path").and_then(serde_json::Value::as_str) else { + continue; + }; + let normalized = network_binary_match_path(Path::new(value)); + binary.insert( + "ascii_case_folded_path".to_string(), + normalized.to_ascii_lowercase().into(), + ); + binary.insert("path".to_string(), normalized.into()); + } + } +} + /// Normalize endpoint port/ports in JSON data. /// /// YAML policies may use `port: N` (single) or `ports: [N, M]` (multi). @@ -2397,6 +2596,127 @@ mod tests { OpaEngine::from_strings(TEST_POLICY, TEST_DATA_YAML).expect("Failed to load test policy") } + #[test] + fn windows_binary_match_path_normalizes_case_and_separators() { + let normalized = windows_network_binary_match_path(r"C:\WINDOWS\SYSTEM32\CURL.EXE"); + assert_eq!(normalized, "C:/WINDOWS/SYSTEM32/CURL.EXE"); + assert_ne!( + normalized, + windows_network_binary_match_path(r"C:\Windows\System32\powershell.exe") + ); + } + + #[test] + fn windows_binary_match_path_preserves_namespace_paths_without_probing() { + for namespace_path in [ + r"\\?\C:\Windows\System32\CURL.EXE", + r"\\.\PhysicalDrive0", + r"\\server\share\tool.exe", + ] { + assert_eq!( + windows_network_binary_match_path(namespace_path), + namespace_path + ); + } + } + + #[cfg(target_os = "windows")] + #[allow(unsafe_code)] + fn set_windows_directory_case_sensitive(path: &Path, enabled: bool) { + use std::mem::size_of; + use std::os::windows::fs::OpenOptionsExt; + use std::os::windows::io::AsRawHandle; + use windows::Win32::Foundation::HANDLE; + use windows::Win32::Storage::FileSystem::{ + FILE_CASE_SENSITIVE_INFO, FILE_FLAG_BACKUP_SEMANTICS, FILE_READ_ATTRIBUTES, + FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, FILE_WRITE_ATTRIBUTES, + FileCaseSensitiveInfo, SetFileInformationByHandle, + }; + + let handle = std::fs::OpenOptions::new() + .access_mode(FILE_READ_ATTRIBUTES.0 | FILE_WRITE_ATTRIBUTES.0) + .share_mode(FILE_SHARE_READ.0 | FILE_SHARE_WRITE.0 | FILE_SHARE_DELETE.0) + .custom_flags(FILE_FLAG_BACKUP_SEMANTICS.0) + .open(path) + .expect("open temporary directory for case-sensitivity update"); + let info = FILE_CASE_SENSITIVE_INFO { + Flags: u32::from(enabled), + }; + let info_size = u32::try_from(size_of::()) + .expect("case-sensitivity info size fits in u32"); + // SAFETY: `handle` and `info` remain alive for the call, and the buffer + // has the exact type and size required by `FileCaseSensitiveInfo`. + unsafe { + SetFileInformationByHandle( + HANDLE(handle.as_raw_handle()), + FileCaseSensitiveInfo, + (&raw const info).cast(), + info_size, + ) + } + .expect("set temporary directory case sensitivity"); + } + + #[cfg(target_os = "windows")] + #[test] + fn windows_binary_matching_denies_case_mismatch_under_sensitive_ancestor() { + let root = tempfile::tempdir().expect("create temporary directory"); + set_windows_directory_case_sensitive(root.path(), true); + let containing_directory = root.path().join("TrustedTools"); + std::fs::create_dir(&containing_directory).expect("create binary directory"); + set_windows_directory_case_sensitive(&containing_directory, false); + let runtime_binary = containing_directory.join("curl.exe"); + std::fs::write(&runtime_binary, b"test executable").expect("create test executable"); + + assert_eq!(windows_directory_case_sensitive(root.path()), Some(true)); + assert_eq!( + windows_directory_case_sensitive(&containing_directory), + Some(false), + "the immediate parent must be insensitive to reproduce the old bug" + ); + assert_eq!( + windows_path_components_case_insensitive(&runtime_binary), + Some(false), + "a sensitive ancestor must keep the whole match case-exact" + ); + + let policy_binary = runtime_binary + .to_string_lossy() + .replace("TrustedTools", "trustedtools"); + assert_ne!(policy_binary, runtime_binary.to_string_lossy()); + let mut proto = openshell_policy::restrictive_default_policy(); + proto.network_policies.insert( + "windows_sensitive_ancestor".to_string(), + NetworkPolicyRule { + name: "windows_sensitive_ancestor".to_string(), + endpoints: vec![NetworkEndpoint { + host: "example.com".to_string(), + port: 443, + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: policy_binary, + }], + }, + ); + let engine = OpaEngine::from_proto(&proto).expect("load Windows policy"); + let decision = engine + .evaluate_network(&NetworkInput { + host: "example.com".to_string(), + port: 443, + binary_path: runtime_binary, + binary_sha256: "unused".to_string(), + ancestors: Vec::new(), + cmdline_paths: Vec::new(), + }) + .expect("evaluate Windows policy"); + + assert!( + !decision.allowed, + "case-distinct paths under a sensitive ancestor must not share grants" + ); + } + fn opa_container_policy() -> serde_json::Value { serde_json::json!({ "filesystem_policy": {}, @@ -3420,6 +3740,84 @@ network_policies: assert_eq!(decision.matched_policy.as_deref(), Some("claude_code")); } + #[cfg(target_os = "windows")] + #[test] + fn from_proto_matches_windows_equivalent_binary_path() { + let mut proto = openshell_policy::restrictive_default_policy(); + proto.network_policies.insert( + "windows_binary".to_string(), + NetworkPolicyRule { + name: "windows_binary".to_string(), + endpoints: vec![NetworkEndpoint { + host: "example.com".to_string(), + port: 443, + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: r"C:\WINDOWS\SYSTEM32\CURL.EXE".to_string(), + }], + }, + ); + let engine = OpaEngine::from_proto(&proto).expect("Failed to create engine from proto"); + + let equivalent = NetworkInput { + host: "example.com".into(), + port: 443, + binary_path: PathBuf::from("c:/windows/system32/curl.exe"), + binary_sha256: "unused".into(), + ancestors: vec![], + cmdline_paths: vec![], + }; + let decision = engine.evaluate_network(&equivalent).unwrap(); + assert!( + decision.allowed, + "Windows-equivalent binary path should be allowed: {}", + decision.reason + ); + + let different_binary = NetworkInput { + binary_path: PathBuf::from("c:/windows/system32/powershell.exe"), + ..equivalent + }; + let decision = engine.evaluate_network(&different_binary).unwrap(); + assert!( + !decision.allowed, + "normalization must not allow a different binary" + ); + } + + #[cfg(target_os = "windows")] + #[test] + fn from_strings_matches_windows_equivalent_binary_path() { + let engine = OpaEngine::from_strings( + TEST_POLICY, + r#" +network_policies: + windows_binary: + endpoints: + - { host: example.com, port: 443 } + binaries: + - { path: 'C:\WINDOWS\SYSTEM32\CURL.EXE' } +"#, + ) + .expect("Failed to create engine from YAML"); + let input = NetworkInput { + host: "example.com".into(), + port: 443, + binary_path: PathBuf::from("c:/windows/system32/curl.exe"), + binary_sha256: "unused".into(), + ancestors: vec![], + cmdline_paths: vec![], + }; + + let decision = engine.evaluate_network(&input).unwrap(); + assert!( + decision.allowed, + "Windows-equivalent YAML binary path should be allowed: {}", + decision.reason + ); + } + #[test] fn from_proto_denies_unmatched_request() { let proto = test_proto(); diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index 0bfea5731d..fb722ad4da 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -2883,6 +2883,7 @@ fn authorize_egress_intent_procfs( binary_pid, ancestors, cmdline_paths, + binary_match_paths: Vec::new(), } }; @@ -2953,6 +2954,7 @@ fn authorize_egress_intent_procfs( binary_pid: Some(binary_pid), ancestors, cmdline_paths, + binary_match_paths: authorization.binary_match_paths.clone(), }, Err(e) => deny( format!("policy evaluation error: {e}"), @@ -3010,6 +3012,7 @@ fn evaluate_endpoint_only_opa(engine: &OpaEngine, intent: EgressIntent) -> Egres binary_pid: None, ancestors: vec![], cmdline_paths: vec![], + binary_match_paths: authorization.binary_match_paths.clone(), }, Err(e) => EgressDecision { intent, @@ -3025,6 +3028,7 @@ fn evaluate_endpoint_only_opa(engine: &OpaEngine, intent: EgressIntent) -> Egres binary_pid: None, ancestors: vec![], cmdline_paths: vec![], + binary_match_paths: Vec::new(), }, } } @@ -3079,6 +3083,7 @@ fn authorize_egress_intent( binary_pid: None, ancestors: Vec::new(), cmdline_paths: Vec::new(), + binary_match_paths: authorization.binary_match_paths.clone(), }, Err(error) => EgressDecision { intent, @@ -3092,6 +3097,7 @@ fn authorize_egress_intent( binary_pid: None, ancestors: Vec::new(), cmdline_paths: Vec::new(), + binary_match_paths: Vec::new(), }, } } @@ -4971,7 +4977,7 @@ async fn handle_forward_proxy( .await?; return Ok(()); } - let tunnel_engine = match relay::pin_l7_evaluator(&opa_engine, route.l7_policy_generation) { + let tunnel_engine = match relay::pin_l7_evaluator(&opa_engine, &decision) { Ok(engine) => engine, Err(e) => { warn!( @@ -8614,6 +8620,7 @@ network_policies: binary_pid: None, ancestors: vec![], cmdline_paths: vec![], + binary_match_paths: authorization.binary_match_paths.clone(), }; let route = query_l7_route_snapshot(&decision, host, port).expect("L7 route should match"); let config = select_l7_config_for_path(&route.configs, path) @@ -12673,6 +12680,7 @@ network_policies: binary_pid: Some(1), ancestors: vec![], cmdline_paths: vec![], + binary_match_paths: authorization.binary_match_paths.clone(), }; query_tls_mode(&decision, "203.0.113.10", 443) }; diff --git a/crates/openshell-supervisor-network/src/proxy/egress.rs b/crates/openshell-supervisor-network/src/proxy/egress.rs index 314596b048..345360e5e2 100644 --- a/crates/openshell-supervisor-network/src/proxy/egress.rs +++ b/crates/openshell-supervisor-network/src/proxy/egress.rs @@ -156,6 +156,8 @@ pub(super) struct EgressDecision { pub(super) ancestors: Vec, /// Cmdline-derived absolute paths (for script detection). pub(super) cmdline_paths: Vec, + /// Runtime-derived path representations carried into pinned L7 evaluation. + pub(super) binary_match_paths: Vec, } #[cfg(test)] diff --git a/crates/openshell-supervisor-network/src/proxy/relay.rs b/crates/openshell-supervisor-network/src/proxy/relay.rs index 485b5eb04d..9c5cfb1af8 100644 --- a/crates/openshell-supervisor-network/src/proxy/relay.rs +++ b/crates/openshell-supervisor-network/src/proxy/relay.rs @@ -77,12 +77,12 @@ pub(super) fn http_context( binary_path: decision .binary .as_ref() - .map(|path| path.to_string_lossy().into_owned()) + .map(|path| crate::opa::network_binary_match_path(path)) .unwrap_or_default(), ancestors: decision .ancestors .iter() - .map(|path| path.to_string_lossy().into_owned()) + .map(|path| crate::opa::network_binary_match_path(path)) .collect(), cmdline_paths: decision .cmdline_paths @@ -115,9 +115,12 @@ pub(super) fn pin_policy_generation( /// Clone an L7 evaluator for a relay or the forward HTTP single-request path. pub(super) fn pin_l7_evaluator( opa_engine: &OpaEngine, - expected_generation: u64, + decision: &EgressDecision, ) -> Result { - opa_engine.clone_engine_for_tunnel(expected_generation) + opa_engine.clone_engine_for_tunnel_with_match_paths( + decision.policy_generation, + decision.binary_match_paths.clone(), + ) } pub(super) fn validate_route_generation( @@ -160,7 +163,7 @@ pub(super) fn prepare_http_relay<'a>( } let policy = if let Some(route) = route.filter(|route| !route.configs.is_empty()) { - let evaluator = match pin_l7_evaluator(opa_engine, decision.policy_generation) { + let evaluator = match pin_l7_evaluator(opa_engine, decision) { Ok(evaluator) => evaluator, Err(error) => { emit_l7_tunnel_close_after_policy_change( @@ -357,8 +360,16 @@ fn emit_stale_relay_close( #[cfg(test)] mod tests { + #[cfg(target_os = "windows")] + use super::super::query_l7_route_snapshot; use super::super::{EgressIntent, EndpointDecision, ProcessIdentityEvidence}; use super::*; + #[cfg(target_os = "windows")] + use crate::opa::NetworkInput; + #[cfg(target_os = "windows")] + use std::path::PathBuf; + #[cfg(target_os = "windows")] + use tokio::io::{AsyncReadExt, AsyncWriteExt}; const POLICY_REGO: &str = include_str!("../../data/sandbox-policy.rego"); const EMPTY_POLICY_DATA: &str = "network_policies: {}\n"; @@ -376,6 +387,7 @@ mod tests { binary_pid: None, ancestors: vec![], cmdline_paths: vec![], + binary_match_paths: Vec::new(), } } @@ -401,6 +413,109 @@ mod tests { } } + #[cfg(target_os = "windows")] + #[tokio::test] + async fn inspected_http_relay_matches_mixed_case_windows_binary_path() { + let (mut caller, mut relay_client) = tokio::io::duplex(4096); + let (mut relay_upstream, mut server) = tokio::io::duplex(4096); + let relay = tokio::spawn(async move { + let engine = OpaEngine::from_strings( + POLICY_REGO, + r" +network_policies: + windows_binary: + endpoints: + - host: example.com + port: 80 + protocol: rest + access: full + binaries: + - path: 'C:\WINDOWS\SYSTEM32\CURL.EXE' +", + ) + .expect("load Windows L7 policy"); + let input = NetworkInput { + host: "example.com".to_string(), + port: 80, + binary_path: PathBuf::from("c:/windows/system32/curl.exe"), + binary_sha256: "unused".to_string(), + ancestors: Vec::new(), + cmdline_paths: Vec::new(), + }; + let authorization = engine.authorize_egress(&input).expect("authorize egress"); + assert!(matches!(authorization.action, NetworkAction::Allow { .. })); + let decision = EgressDecision { + intent: EgressIntent::connect("example.com".to_string(), 80), + action: authorization.action.clone(), + policy_generation: authorization.generation, + identity: ProcessIdentityEvidence::Available, + endpoint: EndpointDecision::from_authorization(&authorization), + binary: Some(input.binary_path), + binary_pid: None, + ancestors: Vec::new(), + cmdline_paths: Vec::new(), + binary_match_paths: authorization.binary_match_paths.clone(), + }; + let route = query_l7_route_snapshot(&decision, "example.com", 80) + .expect("REST endpoint should produce an inspected route"); + let mut request = http_context( + &decision, + None, + None, + None, + openshell_core::proposals::AgentProposals::default(), + String::new(), + RelaySignals { + activity: None, + endpoint_observation: None, + }, + ); + request.request_default_port = Some(80); + let context = prepare_http_relay(Some(&route), &engine, &decision, &request) + .expect("current policy generation should prepare the relay"); + relay_http_stream(&mut relay_client, &mut relay_upstream, context).await + }); + + caller + .write_all(b"GET /v1 HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n") + .await + .expect("write client request"); + let mut forwarded = [0_u8; 512]; + let forwarded_len = tokio::time::timeout( + std::time::Duration::from_secs(2), + server.read(&mut forwarded), + ) + .await + .expect("request should reach upstream") + .expect("read relayed request"); + assert!( + forwarded[..forwarded_len].starts_with(b"GET /v1 HTTP/1.1\r\n"), + "unexpected upstream request: {:?}", + String::from_utf8_lossy(&forwarded[..forwarded_len]) + ); + server + .write_all(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") + .await + .expect("write upstream response"); + + let mut response = [0_u8; 512]; + let response_len = tokio::time::timeout( + std::time::Duration::from_secs(2), + caller.read(&mut response), + ) + .await + .expect("response should reach client") + .expect("read relay response"); + assert!(response[..response_len].starts_with(b"HTTP/1.1 204 No Content")); + drop(caller); + drop(server); + tokio::time::timeout(std::time::Duration::from_secs(2), relay) + .await + .expect("HTTP relay should complete") + .expect("relay task should not panic") + .expect("HTTP relay should allow the normalized binary path"); + } + #[test] fn relay_without_route_pins_l4_decision_generation() { let engine = OpaEngine::from_strings(POLICY_REGO, EMPTY_POLICY_DATA).unwrap(); diff --git a/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs b/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs index 759f888b20..767f4ef2db 100644 --- a/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs +++ b/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs @@ -20,6 +20,7 @@ fn allowed_decision(intent: EgressIntent) -> EgressDecision { binary_pid: Some(42), ancestors: vec![PathBuf::from("/usr/bin/sh")], cmdline_paths: vec![], + binary_match_paths: Vec::new(), } } diff --git a/docs/reference/policy-schema.mdx b/docs/reference/policy-schema.mdx index 36bfa0778a..fbe403a190 100644 --- a/docs/reference/policy-schema.mdx +++ b/docs/reference/policy-schema.mdx @@ -584,6 +584,13 @@ Identifies an executable that is permitted to use the associated endpoints. |---|---|---|---| | `path` | string | Yes | Filesystem path to the executable. Supports glob patterns with `*` and `**`. For example, `/sandbox/.vscode-server/**` matches any executable under that directory tree. | +On Windows, ordinary binary paths treat `/` and `\` as equivalent. ASCII case is +folded only when Windows confirms that every parent directory of the +runtime-observed executable uses case-insensitive lookup. If any component is +case-sensitive or cannot be checked, matching remains case-exact and fails +closed. Loading policy never opens a policy-authored path. UNC, device, and +verbatim namespace paths retain their exact spelling and separators. + ## Network Middleware **Category:** Dynamic diff --git a/docs/security/best-practices.mdx b/docs/security/best-practices.mdx index 4fff61f276..0f108ffcac 100644 --- a/docs/security/best-practices.mdx +++ b/docs/security/best-practices.mdx @@ -90,6 +90,14 @@ The proxy SHA256-hashes each binary on first use (trust-on-first-use). If someon | Risk if relaxed | Broad glob patterns (like `/**`) allow any binary to reach the endpoint, defeating the purpose of binary-scoped enforcement. | | Recommendation | Scope binaries to the specific executables that need each endpoint. Use narrow globs when the exact path varies (for example, across Python virtual environments). | +On Windows, OpenShell normalizes `/` and `\` for ordinary binary paths and folds +ASCII case only when trusted runtime evidence confirms that every parent +directory uses case-insensitive lookup. A sensitive or indeterminate component +keeps the whole match case-exact, so a case-distinct executable cannot inherit +another executable's grant. Policy loading never opens policy-authored paths. +UNC, device, and verbatim namespace paths are matched exactly without separator +or case rewriting; prefer ordinary absolute paths. + ### L4-Only vs L7 Inspection The `protocol` field on an endpoint controls whether the proxy inspects individual HTTP requests inside the tunnel. From 7305209ac7cb339c6a1b4ab8b303f25f9dee14d9 Mon Sep 17 00:00:00 2001 From: pkhodade-NV <84895267+pkhodade-NV@users.noreply.github.com> Date: Wed, 23 Sep 2026 02:19:45 +0530 Subject: [PATCH 16/42] fix(mxc): honor the generic sandbox create -- syntax (#3553) * fix(mxc): honor the generic sandbox create -- syntax sandbox_config only ever read the command from the MXC-specific driver_config (--driver-config-json), never DriverSandboxSpec.command -- the portable field the CLI's documented, driver-agnostic `sandbox create -- ` syntax actually populates, and the same field every other compute driver honors. A caller following that syntax got "driver_config.command must contain a non-empty executable", a message that reads as if no command was supplied at all. Add spec.command as a fallback source when no driver_config is present, and reword the empty-command error to name both ways to supply one. NVBug 6782884 Signed-off-by: Prashant Khodade * docs(mxc): document generic sandbox commands Signed-off-by: Shailendra Singh --------- Signed-off-by: Prashant Khodade Signed-off-by: Shailendra Singh Co-authored-by: Shailendra Singh --- crates/openshell-driver-mxc/README.md | 2 +- crates/openshell-driver-mxc/src/driver.rs | 101 +++++++++++++++++++--- docs/reference/gateway-config.mdx | 7 +- 3 files changed, 94 insertions(+), 16 deletions(-) diff --git a/crates/openshell-driver-mxc/README.md b/crates/openshell-driver-mxc/README.md index 553bea1566..678632210f 100644 --- a/crates/openshell-driver-mxc/README.md +++ b/crates/openshell-driver-mxc/README.md @@ -101,7 +101,7 @@ openshell sandbox create --name mxc-demo --policy demo.yaml ` --driver-config-json $config --env MODE=demo --no-tty ``` -The `command` array is required and preserves Windows argument boundaries. `cwd` is optional. Supply per-sandbox environment variables with `--env` or `--env-from`; gateway configuration does not carry workload commands or environment. Provider-owned keys override matching entries case-insensitively, but raw static values remain in the host proxy; MXC receives their revision-scoped placeholders. When governed egress is enabled, the driver replaces common TLS trust environment variables with paths to public proxy CA files staged under `/.openshell-proxy//`, and injects `HTTP_PROXY`/`HTTPS_PROXY` while clearing `NO_PROXY` so inherited bypass rules cannot skip policy enforcement. +The `command` array is required and preserves Windows argument boundaries. `cwd` is optional. The generic, driver-agnostic `sandbox create -- ` CLI syntax also works and is honored when no `--driver-config-json` is supplied; `--driver-config-json`'s `command` wins if both are somehow present. Supply per-sandbox environment variables with `--env` or `--env-from`; gateway configuration does not carry workload commands or environment. Provider-owned keys override matching entries case-insensitively, but raw static values remain in the host proxy; MXC receives their revision-scoped placeholders. When governed egress is enabled, the driver replaces common TLS trust environment variables with paths to public proxy CA files staged under `/.openshell-proxy//`, and injects `HTTP_PROXY`/`HTTPS_PROXY` while clearing `NO_PROXY` so inherited bypass rules cannot skip policy enforcement. UI capability (Win32k syscalls, clipboard, input injection) is a `SandboxPolicy` concern, not gateway TOML -- see the Capability Matrix above and `docs/reference/policy-schema.mdx`'s `ui` section. Defaults to disabled (Win32k syscall lockdown) when a policy has no explicit `ui:` section; set `allow_graphical_ui: true` for agents that touch user32/gdi32 at startup even without opening a real window (e.g. Node.js-based targets like OpenClaw's gateway -- see `examples/e2e-policies/openclaw-gateway.yaml`). diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index 412b352b34..7f28408535 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -469,23 +469,39 @@ impl std::fmt::Debug for MxcComputeBackend { } fn sandbox_config(sandbox: &DriverSandbox) -> Result { - let config = sandbox + let driver_config = sandbox .spec .as_ref() .and_then(|spec| spec.template.as_ref()) - .and_then(|template| template.driver_config.as_ref()) - .ok_or_else(|| { - tonic::Status::invalid_argument( - "mxc requires template.driver_config.mxc with a non-empty command array", - ) - })?; - let config: MxcSandboxConfig = - serde_json::from_value(struct_to_json_value(config)).map_err(|error| { - tonic::Status::invalid_argument(format!("invalid mxc driver_config: {error}")) - })?; + .and_then(|template| template.driver_config.as_ref()); + let config = match driver_config { + // Explicit, MXC-specific override (`--driver-config-json`). Takes + // priority over the generic CLI command below since it's the most + // deliberately-targeted input a caller can supply for this driver. + Some(driver_config) => serde_json::from_value(struct_to_json_value(driver_config)) + .map_err(|error| { + tonic::Status::invalid_argument(format!("invalid mxc driver_config: {error}")) + })?, + // Generic, driver-agnostic `sandbox create -- ` syntax + // (`DriverSandboxSpec.command`, the same field every other compute + // driver honors). Previously silently ignored here: the caller's + // typed command was accepted by the CLI and discarded before ever + // reaching this function, surfacing only as a "must contain a + // non-empty executable" error that gave no hint a command had been + // supplied at all. + None => MxcSandboxConfig { + command: sandbox + .spec + .as_ref() + .map(|spec| spec.command.clone()) + .unwrap_or_default(), + cwd: String::new(), + }, + }; if config.command.is_empty() || config.command[0].is_empty() { return Err(tonic::Status::invalid_argument( - "mxc driver_config.command must contain a non-empty executable", + "mxc sandbox command must contain a non-empty executable: set it via \ + `sandbox create -- ` or `--driver-config-json`", )); } Ok(config) @@ -2649,6 +2665,67 @@ mod lifecycle_tests { status: None, } } + + /// A `DriverSandbox` carrying only the generic `sandbox create -- ` + /// field (`DriverSandboxSpec.command`), with no MXC-specific + /// `driver_config` at all -- the shape the CLI's documented, + /// driver-agnostic syntax actually produces. + fn driver_sandbox_with_cli_command(id: &str, command: Vec) -> DriverSandbox { + DriverSandbox { + id: id.to_string(), + name: id.to_string(), + namespace: String::new(), + workspace: String::new(), + spec: Some(DriverSandboxSpec { + sandbox_token: "test-token".into(), + command, + ..Default::default() + }), + status: None, + } + } + + #[test] + fn sandbox_config_honors_generic_cli_command() { + // Regression test: `sandbox create -- ` (the CLI's own + // documented, driver-agnostic syntax) must actually reach the MXC + // driver instead of being silently discarded. + let sandbox = driver_sandbox_with_cli_command( + "sb-cli-cmd", + vec!["cmd.exe".into(), "/c".into(), "exit".into()], + ); + let config = sandbox_config(&sandbox).unwrap(); + assert_eq!( + config.command, + vec!["cmd.exe".to_string(), "/c".to_string(), "exit".to_string()] + ); + } + + #[test] + fn sandbox_config_driver_config_takes_priority_over_cli_command() { + // `--driver-config-json` is the more deliberately-targeted input for + // this driver; if both are somehow supplied, it must win. + let mut sandbox = + driver_sandbox_with_command("sb-both", "", vec!["driver-config-cmd.exe".into()]); + sandbox.spec.as_mut().unwrap().command = vec!["cli-cmd.exe".into()]; + let config = sandbox_config(&sandbox).unwrap(); + assert_eq!(config.command, vec!["driver-config-cmd.exe".to_string()]); + } + + #[test] + fn sandbox_config_rejects_empty_command_from_every_source() { + let sandbox = driver_sandbox_with_cli_command("sb-empty", Vec::new()); + let error = sandbox_config(&sandbox).unwrap_err(); + assert_eq!(error.code(), tonic::Code::InvalidArgument); + let message = error.message(); + // Actionable: names both ways to supply a command, unlike the old + // "driver_config.command must contain a non-empty executable" + // message, which read as if the CLI's own command syntax weren't + // one of them. + assert!(message.contains("sandbox create -- ")); + assert!(message.contains("--driver-config-json")); + } + fn fs_policy(read_write: &[&str]) -> SandboxPolicy { SandboxPolicy { filesystem: Some(FilesystemPolicy { diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index a4ca56a250..dce1b313a5 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -697,8 +697,9 @@ filename `wxc-exec.exe` must set an absolute path before upgrading. `etw_audit` defaults to `false`. When enabled, the gateway account must be an administrator or belong to the Windows Performance Log Users group. Workload -commands and working directories remain sandbox-scoped and must be supplied in -the `mxc` driver configuration when creating a sandbox. +commands and working directories remain sandbox-scoped. Supply the command with +the generic trailing-command syntax or through the `mxc` driver configuration +described below. Set `egress_proxy = true` with a loopback seed such as `egress_proxy_addr = "127.0.0.1:18080"` to enable the Windows Pattern-C split. @@ -981,7 +982,7 @@ The driver points common TLS trust variables, including `CURL_CA_BUNDLE`, at the MXC rejects policies containing `network_middlewares` before launch because this host-proxy path does not receive the gateway middleware registry. -Supply the workload command and optional working directory through `sandbox create --driver-config-json`, for example `{"mxc":{"command":["cmd","/c","echo hello"],"cwd":"C:\\work"}}`. Supply workload environment through `sandbox create --env` or `--env-from`; it is not part of gateway configuration. +Supply the workload command with the generic `sandbox create -- ` syntax. Use `sandbox create --driver-config-json` for an optional working directory or the legacy MXC-specific command form, for example `{"mxc":{"command":["cmd","/c","echo hello"],"cwd":"C:\\work"}}`. If both forms supply a command, `mxc.command` wins. Supply workload environment through `sandbox create --env` or `--env-from`; it is not part of gateway configuration. Attached provider credentials require this governed-egress path. The gateway gives MXC only revision-scoped environment placeholders and keeps real values in the host proxy's endpoint-bound resolver. Provider keys override matching sandbox environment entries case-insensitively. Sandbox creation fails when attached provider material exists but `egress_proxy` is disabled. MXC takes its static provider snapshot at creation and rejects credentials with an expiration timestamp because it has no live refresh channel. Dynamic token grants continue to mint credentials per request in the host proxy. Recreate the sandbox after attaching, detaching, rotating, or revoking a non-expiring static provider credential. From b031dc037a099e9ff1f1cdf5f15c7c60a867537d Mon Sep 17 00:00:00 2001 From: Prekshi Vyas <34834085+prekshivyas@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:44:44 -0700 Subject: [PATCH 17/42] fix(mxc): reject unsupported live policy updates (#3480) * fix(mxc): reject unsupported live policy updates (NVBug 6782891) Signed-off-by: Prekshi Vyas * fix(mxc): gate all live policy mutations Signed-off-by: Prekshi Vyas * fix(mxc): gate composed policy mutations Signed-off-by: Prekshi Vyas * fix(ci): satisfy provider update lint * fix(ci): order provider validation branches * test(mxc): make policy synchronization deterministic * fix(server): scope MXC policy synchronization * fix(server): serialize provider-backed sandbox creation * test(server): use valid provider create fixture name --------- Signed-off-by: Prekshi Vyas --- architecture/compute-runtimes.md | 10 + crates/openshell-cli/src/run.rs | 41 +- .../sandbox_name_fallback_integration.rs | 88 +++- crates/openshell-driver-docker/src/lib.rs | 1 + .../openshell-driver-kubernetes/src/driver.rs | 1 + crates/openshell-driver-mxc/src/driver.rs | 13 + crates/openshell-driver-podman/src/driver.rs | 1 + crates/openshell-driver-vm/src/driver.rs | 1 + crates/openshell-server/src/compute/mod.rs | 25 +- crates/openshell-server/src/grpc/policy.rs | 491 +++++++++++++++++- crates/openshell-server/src/grpc/provider.rs | 141 ++++- crates/openshell-server/src/grpc/sandbox.rs | 178 ++++++- crates/openshell-server/src/test_support.rs | 1 + docs/sandboxes/policies.mdx | 2 +- proto/compute_driver.proto | 5 + skills/openshell-cli/SKILL.md | 4 +- 16 files changed, 944 insertions(+), 59 deletions(-) diff --git a/architecture/compute-runtimes.md b/architecture/compute-runtimes.md index 1205b67594..e9320323be 100644 --- a/architecture/compute-runtimes.md +++ b/architecture/compute-runtimes.md @@ -118,6 +118,16 @@ be supplied by a gateway-global policy because it is applied at startup. When a global dynamic policy is active, effective-policy reads retain the UI block from the sandbox's creation policy. +Live policy updates are also capability-negotiated. A driver that reports +`supports_live_policy_updates = true` must apply every gateway mutation that can +change an existing sandbox's effective dynamic policy. A driver that reports +`false` causes the gateway to reject those mutations before persistence and to +serialize sandbox creation with global policy and provider changes. Rejected +mutations include direct replacement or merge, global policy changes while a +sandbox exists, policy-advisor approval or undo, provider attach or detach, and +updates to a provider profile used by a running sandbox. An omitted capability +retains the legacy `true` behavior for compatibility with existing drivers. + The gateway records driver identity and version from the startup capability response. Elevated gateway info reports that initialized driver snapshot instead of re-querying drivers on each request. diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index 673d4a9da3..c13eecbeeb 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -5171,6 +5171,34 @@ where } else { config.version }; + let (status, active_version) = if policy_source == PolicySource::Global { + // Drivers without live policy updates reject global policy mutations + // while any sandbox exists, so this sandbox necessarily received the + // reported global version at startup. Other drivers retain the + // synchronous global-policy contract. Do not query global history here: + // sandbox policy reads are workspace-readable, while that endpoint is + // platform-admin scoped. + (PolicyStatus::Loaded, version) + } else { + let status_response = client + .get_sandbox_policy_status(GetSandboxPolicyStatusRequest { + name: name.to_string(), + version, + global: false, + workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), + }) + .await + .into_diagnostic()? + .into_inner(); + let revision = status_response + .revision + .as_ref() + .ok_or_else(|| miette!("policy version {version} is missing from policy history"))?; + ( + PolicyStatus::try_from(revision.status).unwrap_or(PolicyStatus::Unspecified), + status_response.active_version, + ) + }; match output { "json" => { @@ -5178,9 +5206,15 @@ where obj.insert("scope".to_string(), serde_json::json!("sandbox")); obj.insert("sandbox".to_string(), serde_json::json!(name)); obj.insert("version".to_string(), serde_json::json!(version)); - obj.insert("active_version".to_string(), serde_json::json!(version)); + obj.insert( + "active_version".to_string(), + serde_json::json!(active_version), + ); obj.insert("hash".to_string(), serde_json::json!(config.policy_hash)); - obj.insert("status".to_string(), serde_json::json!("effective")); + obj.insert( + "status".to_string(), + serde_json::json!(policy_status_json_name(status)), + ); obj.insert( "config_revision".to_string(), serde_json::json!(config.config_revision), @@ -5211,8 +5245,9 @@ where } "table" => { writeln!(stdout, "Version: {version}").into_diagnostic()?; + writeln!(stdout, "Active: {active_version}").into_diagnostic()?; writeln!(stdout, "Hash: {}", config.policy_hash).into_diagnostic()?; - writeln!(stdout, "Status: Effective").into_diagnostic()?; + writeln!(stdout, "Status: {status:?}").into_diagnostic()?; writeln!(stdout, "Source: {policy_source_label}").into_diagnostic()?; writeln!(stdout, "Config rev: {}", config.config_revision).into_diagnostic()?; if config.global_policy_version > 0 { diff --git a/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs b/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs index d040501590..6e18dec517 100644 --- a/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs +++ b/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs @@ -25,6 +25,7 @@ use openshell_core::proto::{ ServiceStatus, SupervisorMessage, UpdateProviderRequest, WatchSandboxRequest, }; use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; use tempfile::TempDir; use tokio::net::TcpListener; use tokio::sync::{Mutex, mpsc}; @@ -38,6 +39,7 @@ use tonic::{Response, Status}; #[derive(Clone, Default)] struct SandboxState { last_get_name: Arc>>, + global_policy: Arc, } #[derive(Clone, Default)] @@ -191,6 +193,7 @@ impl OpenShell for TestOpenShell { req.sandbox_id, "test-id", "GetSandboxConfig should pass the id from GetSandbox" ); + let global_policy = self.state.global_policy.load(Ordering::Relaxed); Ok(Response::new(GetSandboxConfigResponse { policy: Some(SandboxPolicy { version: 9, @@ -233,7 +236,12 @@ impl OpenShell for TestOpenShell { version: 9, policy_hash: "sha256:effective-policy".to_string(), config_revision: 42, - policy_source: openshell_core::proto::PolicySource::Sandbox.into(), + policy_source: if global_policy { + openshell_core::proto::PolicySource::Global.into() + } else { + openshell_core::proto::PolicySource::Sandbox.into() + }, + global_policy_version: if global_policy { 4 } else { 0 }, ..Default::default() })) } @@ -460,9 +468,22 @@ impl OpenShell for TestOpenShell { ) -> Result, Status> { let req = request.into_inner(); assert_eq!(req.name, "my-sandbox"); - assert_eq!(req.version, 3); assert!(!req.global); + if req.version == 9 { + return Ok(Response::new(GetSandboxPolicyStatusResponse { + revision: Some(SandboxPolicyRevision { + version: 9, + policy_hash: "sha256:effective-policy".to_string(), + status: PolicyStatus::Pending.into(), + ..Default::default() + }), + active_version: 7, + })); + } + + assert_eq!(req.version, 3); + let policy = SandboxPolicy { version: 7, network_policies: std::iter::once(( @@ -843,9 +864,9 @@ async fn policy_get_full_json_cli_prints_policy_payload() { assert_eq!(json["scope"], "sandbox"); assert_eq!(json["sandbox"], "my-sandbox"); assert_eq!(json["version"], 9); - assert_eq!(json["active_version"], 9); + assert_eq!(json["active_version"], 7); assert_eq!(json["hash"], "sha256:effective-policy"); - assert_eq!(json["status"], "effective"); + assert_eq!(json["status"], "pending"); assert_eq!(json["config_revision"], 42); assert_eq!(json["policy_source"], "sandbox"); assert_eq!( @@ -891,7 +912,7 @@ async fn policy_get_base_json_cli_prints_round_trippable_policy_payload() { serde_json::from_slice(&stdout).expect("stdout should be valid JSON"); assert_eq!(json["scope"], "sandbox"); assert_eq!(json["sandbox"], "my-sandbox"); - assert_eq!(json["status"], "effective"); + assert_eq!(json["status"], "pending"); assert!( json["policy"]["network_policies"] .get("_provider_api") @@ -904,6 +925,63 @@ async fn policy_get_base_json_cli_prints_round_trippable_policy_payload() { ); } +#[tokio::test] +async fn policy_get_latest_table_reports_persisted_status_and_active_version() { + let ts = run_server().await; + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + + run::sandbox_policy_get_to_writer( + &ts.endpoint, + "my-sandbox", + 0, + run::PolicyGetView::Metadata, + "table", + "default", + &ts.tls, + (&mut stdout, &mut stderr), + ) + .await + .expect("policy get should succeed"); + + assert!(stderr.is_empty()); + let output = String::from_utf8(stdout).expect("table output should be UTF-8"); + assert!(output.contains("Version: 9"), "{output}"); + assert!(output.contains("Active: 7"), "{output}"); + assert!(output.contains("Status: Pending"), "{output}"); +} + +#[tokio::test] +async fn policy_get_global_effective_policy_remains_workspace_readable() { + let ts = run_server().await; + ts.openshell + .state + .global_policy + .store(true, Ordering::Relaxed); + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + + run::sandbox_policy_get_to_writer( + &ts.endpoint, + "my-sandbox", + 0, + run::PolicyGetView::Metadata, + "json", + "default", + &ts.tls, + (&mut stdout, &mut stderr), + ) + .await + .expect("workspace-readable global effective policy should not query admin-only history"); + + assert!(stderr.is_empty()); + let json: serde_json::Value = serde_json::from_slice(&stdout).expect("valid JSON"); + assert_eq!(json["version"], 4); + assert_eq!(json["active_version"], 4); + assert_eq!(json["status"], "loaded"); + assert_eq!(json["policy_source"], "global"); +} + #[tokio::test] async fn policy_get_explicit_revision_uses_stored_policy_status() { let ts = run_server().await; diff --git a/crates/openshell-driver-docker/src/lib.rs b/crates/openshell-driver-docker/src/lib.rs index 62ba8c9f69..376617e306 100644 --- a/crates/openshell-driver-docker/src/lib.rs +++ b/crates/openshell-driver-docker/src/lib.rs @@ -668,6 +668,7 @@ impl DockerComputeDriver { rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, supports_ui_policy: false, + supports_live_policy_updates: None, } } diff --git a/crates/openshell-driver-kubernetes/src/driver.rs b/crates/openshell-driver-kubernetes/src/driver.rs index 7b33d4b963..c6b8f3d218 100644 --- a/crates/openshell-driver-kubernetes/src/driver.rs +++ b/crates/openshell-driver-kubernetes/src/driver.rs @@ -580,6 +580,7 @@ impl KubernetesComputeDriver { rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, supports_ui_policy: false, + supports_live_policy_updates: None, }) } diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index 7f28408535..10411258cb 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -998,6 +998,7 @@ impl MxcComputeBackend { rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, supports_ui_policy: self.config.backend == MxcBackend::ProcessContainer, + supports_live_policy_updates: Some(false), } } @@ -2632,12 +2633,24 @@ mod lifecycle_tests { fn ui_policy_capability_tracks_configured_backend() { let process_container = MxcComputeBackend::new_mocked(MxcComputeConfig::default()); assert!(process_container.capabilities().supports_ui_policy); + assert_eq!( + process_container + .capabilities() + .supports_live_policy_updates, + Some(false) + ); let isolation_session = MxcComputeBackend::new_mocked(MxcComputeConfig { backend: MxcBackend::IsolationSession, ..Default::default() }); assert!(!isolation_session.capabilities().supports_ui_policy); + assert_eq!( + isolation_session + .capabilities() + .supports_live_policy_updates, + Some(false) + ); } fn driver_sandbox_with_command(id: &str, cwd: &str, command: Vec) -> DriverSandbox { diff --git a/crates/openshell-driver-podman/src/driver.rs b/crates/openshell-driver-podman/src/driver.rs index ae564cd4ab..c92f4e8ced 100644 --- a/crates/openshell-driver-podman/src/driver.rs +++ b/crates/openshell-driver-podman/src/driver.rs @@ -529,6 +529,7 @@ impl PodmanComputeDriver { rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, supports_ui_policy: false, + supports_live_policy_updates: None, }) } diff --git a/crates/openshell-driver-vm/src/driver.rs b/crates/openshell-driver-vm/src/driver.rs index b1b3073958..ecd3fa68fa 100644 --- a/crates/openshell-driver-vm/src/driver.rs +++ b/crates/openshell-driver-vm/src/driver.rs @@ -801,6 +801,7 @@ impl VmDriver { .into_owned(), rootfs_tar_max_bytes: self.config.rootfs_tar_max_bytes(), supports_ui_policy: false, + supports_live_policy_updates: None, } } diff --git a/crates/openshell-server/src/compute/mod.rs b/crates/openshell-server/src/compute/mod.rs index 9b5bab1259..5cc050f705 100644 --- a/crates/openshell-server/src/compute/mod.rs +++ b/crates/openshell-server/src/compute/mod.rs @@ -382,6 +382,9 @@ pub struct ComputeDriverInfoSnapshot { /// Whether this configured driver instance completely enforces the portable /// UI policy contract. pub supports_ui_policy: bool, + /// Whether this driver can apply policy changes after sandbox creation. + /// `None` preserves the behavior of older external drivers. + pub supports_live_policy_updates: Option, } /// Interval between store-vs-backend reconciliation sweeps. @@ -743,6 +746,7 @@ impl ComputeRuntime { rootfs_tar_staging_dir: capabilities.rootfs_tar_staging_dir, rootfs_tar_max_bytes: capabilities.rootfs_tar_max_bytes, supports_ui_policy: capabilities.supports_ui_policy, + supports_live_policy_updates: capabilities.supports_live_policy_updates, }; let default_image = capabilities.default_image; let gateway_listener_requirements = match driver @@ -924,6 +928,15 @@ impl ComputeRuntime { self.driver_info.supports_sandbox_authentication } + /// Whether operator-authored policy updates can reach an already-created + /// sandbox. Unspecified preserves compatibility with older drivers. + #[must_use] + pub(crate) fn supports_live_policy_updates(&self) -> bool { + self.driver_info + .supports_live_policy_updates + .unwrap_or(true) + } + pub(crate) async fn authenticate_sandbox(&self, credential: &str) -> Result { if !self.supports_sandbox_authentication() { return Err(Status::unimplemented( @@ -5057,6 +5070,7 @@ impl ComputeDriver for NoopTestDriver { rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, supports_ui_policy: false, + supports_live_policy_updates: None, }, )) } @@ -5182,7 +5196,12 @@ pub async fn new_test_runtime(store: Arc) -> ComputeRuntime { #[cfg(test)] pub async fn new_test_runtime_for_driver(store: Arc, driver_name: &str) -> ComputeRuntime { - new_test_runtime_with_driver(store, driver_name, Arc::new(NoopTestDriver::default())).await + let mut runtime = + new_test_runtime_with_driver(store, driver_name, Arc::new(NoopTestDriver::default())).await; + if driver_name == "mxc" { + runtime.driver_info.supports_live_policy_updates = Some(false); + } + runtime } #[cfg(test)] @@ -5205,6 +5224,7 @@ pub async fn new_test_runtime_with_driver( rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, supports_ui_policy: false, + supports_live_policy_updates: None, }, telemetry_compute_driver: TelemetryComputeDriver::custom(), driver_process: None, @@ -5529,6 +5549,7 @@ mod tests { rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, supports_ui_policy: false, + supports_live_policy_updates: None, })) } @@ -5895,6 +5916,7 @@ mod tests { rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, supports_ui_policy: false, + supports_live_policy_updates: None, })) } @@ -6109,6 +6131,7 @@ mod tests { rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, supports_ui_policy: false, + supports_live_policy_updates: None, }, telemetry_compute_driver: TelemetryComputeDriver::custom(), driver_process: None, diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index cd71e8064f..3164378c34 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -1646,7 +1646,7 @@ async fn auto_approve_chunk( .await?; let credential_binding_context = merge_validation.credential_binding_context(); let merge_result = merge_chunk_into_policy_with_validation( - state.store.as_ref(), + state, sandbox_id, context.workspace, &chunk, @@ -2323,6 +2323,34 @@ async fn validate_provider_composition_for_existing_sandboxes( } } +pub(super) fn require_live_policy_update_support(state: &ServerState) -> Result<(), Status> { + if state.compute.supports_live_policy_updates() { + return Ok(()); + } + + Err(Status::failed_precondition(format!( + "compute driver '{}' cannot apply effective policy changes to an existing sandbox; delete and recreate the sandbox with the requested policy", + state.compute.configured_driver_name() + ))) +} + +async fn require_global_policy_update_support(state: &ServerState) -> Result<(), Status> { + if state.compute.supports_live_policy_updates() { + return Ok(()); + } + + let page = state + .store + .list_message_page::(ObjectListQuery::AllWorkspaces, None, 1) + .await + .map_err(|e| Status::internal(format!("list sandboxes failed: {e}")))?; + if page.messages.is_empty() { + return Ok(()); + } + + require_live_policy_update_support(state) +} + pub async fn validate_provider_composition_startup_preflight( state: &ServerState, ) -> Result<(), Status> { @@ -3677,6 +3705,7 @@ async fn handle_update_config_inner( // Serialize its writes after validation so a report cannot commit // evidence derived from the policy this update has replaced. let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await; + require_global_policy_update_support(state).await?; let latest = state .store .get_latest_policy(GLOBAL_POLICY_SANDBOX_ID) @@ -3782,6 +3811,12 @@ async fn handle_update_config_inner( None }; let mut global_settings = load_global_settings(state.store.as_ref()).await?; + if key == POLICY_SETTING_KEY + && req.delete_setting + && global_settings.settings.contains_key(POLICY_SETTING_KEY) + { + require_global_policy_update_support(state).await?; + } let provider_composition_was_enabled = provider_policy_composition_enabled_in(&global_settings)?; let changed = if req.delete_setting { @@ -3844,6 +3879,10 @@ async fn handle_update_config_inner( let sandbox_id = sandbox.object_id().to_string(); let mut response_annotations = sandbox_metadata_annotations(&sandbox); + if !sandbox_caller && (has_policy || has_merge_ops) { + require_live_policy_update_support(state)?; + } + if has_setting { let _settings_guard = state.settings_mutex.lock().await; @@ -3965,7 +4004,7 @@ async fn handle_update_config_inner( }; let baseline_policy = spec.policy.clone(); let (version, hash, updated_sandbox) = apply_merge_operations_with_retry( - state.store.as_ref(), + state, &sandbox_id, &workspace, baseline_policy.as_ref(), @@ -5247,7 +5286,7 @@ async fn handle_approve_draft_chunk_inner( sandbox_policy_merge_validation_data(state, &workspace, &sandbox, provider_names).await?; let credential_binding_context = merge_validation.credential_binding_context(); let merge_result = merge_chunk_into_policy_with_validation( - state.store.as_ref(), + state, &sandbox_id, &workspace, &chunk, @@ -5658,7 +5697,7 @@ async fn handle_approve_all_draft_chunks_inner( Status::failed_precondition("bulk approval has no reviewed policy snapshot") })?; match apply_merge_operations_with_retry( - state.store.as_ref(), + state, &sandbox_id, &workspace, Some(&final_base), @@ -6758,6 +6797,31 @@ fn stage_validated_merge_operation( #[allow(clippy::too_many_arguments)] async fn apply_merge_operations_with_retry( + state: &ServerState, + sandbox_id: &str, + workspace: &str, + baseline_policy: Option<&ProtoSandboxPolicy>, + operations: &[PolicyMergeOp], + validation_context: PolicyMergeValidationContext<'_>, + expected_current_effective_hash: Option<&str>, + atomic_context: Option<&AtomicPolicyWriteContext<'_>>, +) -> Result<(i64, String, Option), Status> { + require_live_policy_update_support(state)?; + apply_merge_operations_with_retry_in_store( + state.store.as_ref(), + sandbox_id, + workspace, + baseline_policy, + operations, + validation_context, + expected_current_effective_hash, + atomic_context, + ) + .await +} + +#[allow(clippy::too_many_arguments)] +async fn apply_merge_operations_with_retry_in_store( store: &Store, sandbox_id: &str, workspace: &str, @@ -6916,7 +6980,7 @@ async fn apply_merge_operations_with_retry( } async fn merge_chunk_into_policy_with_validation( - store: &Store, + state: &ServerState, sandbox_id: &str, workspace: &str, chunk: &DraftChunkRecord, @@ -6935,7 +6999,7 @@ async fn merge_chunk_into_policy_with_validation( clear_provider_credentialed_markers(policy); } apply_merge_operations_with_retry( - store, + state, sandbox_id, workspace, baseline_policy.as_ref(), @@ -6957,17 +7021,34 @@ async fn merge_chunk_into_policy( chunk: &DraftChunkRecord, provider_layers: &[ProviderPolicyLayer], ) -> Result<(i64, String), Status> { - merge_chunk_into_policy_with_validation( + let rule = NetworkPolicyRule::decode(chunk.proposed_rule.as_slice()) + .map_err(|e| Status::internal(format!("decode proposed_rule failed: {e}")))?; + let operations = [PolicyMergeOp::AddRule { + rule_name: chunk.rule_name.clone(), + rule, + }]; + validate_merge_operations_for_server(&operations)?; + let mut baseline_policy = chunk.current_effective_policy.clone(); + if let Some(policy) = &mut baseline_policy { + strip_provider_rule_names(policy); + clear_provider_credentialed_markers(policy); + } + apply_merge_operations_with_retry_in_store( store, sandbox_id, workspace, - chunk, + baseline_policy.as_ref(), + &operations, PolicyMergeValidationContext { provider_layers, credential_binding: None, }, + (!chunk.current_effective_policy_hash.is_empty()) + .then_some(chunk.current_effective_policy_hash.as_str()), + None, ) .await + .map(|(version, hash, _)| (version, hash)) } async fn remove_chunk_from_policy( @@ -6977,7 +7058,7 @@ async fn remove_chunk_from_policy( chunk: &DraftChunkRecord, ) -> Result<(i64, String), Status> { apply_merge_operations_with_retry( - state.store.as_ref(), + state, sandbox_id, workspace, None, @@ -7307,7 +7388,9 @@ mod tests { Principal, SandboxIdentitySource, SandboxPrincipal, UserPrincipal, }; use crate::grpc::provider::ProviderEnvironment; - use crate::grpc::test_support::{authed_request, test_server_state}; + use crate::grpc::test_support::{ + authed_request, test_server_state, test_server_state_with_driver, + }; use crate::persistence::test_store; use std::collections::HashMap; use std::sync::Arc; @@ -8447,6 +8530,384 @@ mod tests { ); } + #[tokio::test] + async fn mxc_rejects_operator_policy_update_before_persistence() { + use openshell_core::proto::FilesystemPolicy; + + let state = test_server_state_with_driver("mxc").await; + let sandbox_id = "mxc-live-policy"; + let mut baseline = openshell_policy::restrictive_default_policy(); + baseline.filesystem = Some(FilesystemPolicy { + read_only: vec![r"C:\Windows".to_string()], + ..Default::default() + }); + state + .store + .put_message(&test_sandbox( + sandbox_id, + sandbox_id, + baseline.clone(), + Vec::new(), + )) + .await + .expect("store MXC sandbox"); + + let mut additive = baseline; + additive + .filesystem + .as_mut() + .expect("filesystem policy") + .read_only + .push(r"C:\Program Files".to_string()); + let error = handle_update_config( + &state, + with_user(Request::new(UpdateConfigRequest { + name: sandbox_id.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), + policy: Some(additive), + ..Default::default() + })), + ) + .await + .expect_err("MXC cannot apply an operator policy update to a live sandbox"); + + assert_eq!(error.code(), Code::FailedPrecondition); + assert!(error.message().contains("delete and recreate")); + assert!( + state + .store + .get_latest_policy(sandbox_id) + .await + .expect("policy history lookup") + .is_none(), + "a rejected update must not create a pending revision" + ); + } + + #[tokio::test] + async fn mxc_rejects_global_policy_changes_while_a_sandbox_exists() { + let state = test_server_state_with_driver("mxc").await; + let initial = test_policy_with_rule("initial", "initial.example.com"); + let created = handle_update_config( + &state, + with_user(Request::new(UpdateConfigRequest { + global: true, + policy: Some(initial.clone()), + ..Default::default() + })), + ) + .await + .expect("MXC may configure global policy before any sandbox exists") + .into_inner(); + assert_eq!(created.version, 1); + + state + .store + .put_message(&test_sandbox( + "mxc-global-policy", + "mxc-global-policy", + ProtoSandboxPolicy::default(), + Vec::new(), + )) + .await + .expect("store MXC sandbox"); + + let replace_error = handle_update_config( + &state, + with_user(Request::new(UpdateConfigRequest { + global: true, + policy: Some(test_policy_with_rule("replacement", "new.example.com")), + ..Default::default() + })), + ) + .await + .expect_err("MXC must reject a global policy replacement for an existing sandbox"); + assert_eq!(replace_error.code(), Code::FailedPrecondition); + + let delete_error = handle_update_config( + &state, + with_user(Request::new(UpdateConfigRequest { + global: true, + setting_key: POLICY_SETTING_KEY.to_string(), + delete_setting: true, + ..Default::default() + })), + ) + .await + .expect_err("MXC must reject deleting global policy for an existing sandbox"); + assert_eq!(delete_error.code(), Code::FailedPrecondition); + + let revisions = state + .store + .list_policies(GLOBAL_POLICY_SANDBOX_ID, 10, 0) + .await + .expect("list global policy revisions"); + assert_eq!(revisions.len(), 1); + assert_eq!(revisions[0].version, 1); + let settings = load_global_settings(state.store.as_ref()) + .await + .expect("load global settings"); + assert_eq!( + decode_policy_from_global_settings(&settings) + .expect("decode global policy") + .expect("global policy remains configured"), + initial + ); + } + + #[tokio::test] + async fn global_policy_transition_waits_for_sandbox_sync_guard() { + let state = test_server_state().await; + let guard = state.compute.sandbox_sync_guard().await; + + let update_state = state.clone(); + let update = tokio::spawn(async move { + handle_update_config( + &update_state, + with_user(Request::new(UpdateConfigRequest { + global: true, + policy: Some(test_policy_with_rule("global", "global.example.com")), + ..Default::default() + })), + ) + .await + }); + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + assert!( + !update.is_finished(), + "global update should wait for the guard" + ); + + drop(guard); + tokio::time::timeout(std::time::Duration::from_secs(5), update) + .await + .expect("global update should finish after guard release") + .expect("join global update") + .expect("global update should succeed"); + } + + #[tokio::test] + async fn mxc_rejects_all_advisor_policy_mutation_paths_before_persistence() { + let state = test_server_state_with_driver("mxc").await; + let sandbox_id = "mxc-advisor-policy"; + let sandbox_name = "mxc-advisor-policy"; + state + .store + .put_message(&test_sandbox( + sandbox_id, + sandbox_name, + ProtoSandboxPolicy::default(), + Vec::new(), + )) + .await + .expect("store MXC sandbox"); + + let proposal = |name: &str, host: &str| PolicyChunk { + rule_name: name.to_string(), + proposed_rule: Some(NetworkPolicyRule { + name: name.to_string(), + endpoints: vec![NetworkEndpoint { + host: host.to_string(), + port: 443, + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: r"C:\Windows\System32\curl.exe".to_string(), + }], + }), + ..Default::default() + }; + let submitted = handle_submit_policy_analysis( + &state, + with_user(Request::new(SubmitPolicyAnalysisRequest { + name: sandbox_name.to_string(), + analysis_mode: "agent_authored".to_string(), + proposed_chunks: vec![proposal("manual", "manual.example.com")], + ..Default::default() + })), + ) + .await + .expect("store pending proposal") + .into_inner(); + let chunk_id = &submitted.accepted_chunk_ids[0]; + let chunk = state + .store + .get_draft_chunk(chunk_id) + .await + .expect("fetch proposal") + .expect("stored proposal"); + assert_eq!(chunk.status, "pending"); + + let manual_error = handle_approve_draft_chunk( + &state, + with_user(Request::new(ApproveDraftChunkRequest { + name: sandbox_name.to_string(), + chunk_id: chunk.id.clone(), + review_token: chunk.review_token.clone(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), + })), + ) + .await + .expect_err("manual approval must not mutate MXC policy"); + assert_eq!(manual_error.code(), Code::FailedPrecondition); + + let bulk_error = handle_approve_all_draft_chunks( + &state, + with_user(Request::new(ApproveAllDraftChunksRequest { + name: sandbox_name.to_string(), + include_security_flagged: true, + approvals: vec![openshell_core::proto::DraftChunkApproval { + chunk_id: chunk.id.clone(), + review_token: chunk.review_token.clone(), + }], + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), + })), + ) + .await + .expect_err("bulk approval must not mutate MXC policy"); + assert_eq!(bulk_error.code(), Code::FailedPrecondition); + assert!( + state + .store + .get_latest_policy(sandbox_id) + .await + .expect("policy history lookup") + .is_none() + ); + + seed_sandbox_approval_mode(&state, sandbox_name, "auto").await; + let auto_submitted = handle_submit_policy_analysis( + &state, + with_user(Request::new(SubmitPolicyAnalysisRequest { + name: sandbox_name.to_string(), + analysis_mode: "agent_authored".to_string(), + proposed_chunks: vec![proposal("automatic", "automatic.example.com")], + ..Default::default() + })), + ) + .await + .expect("automatic approval failure leaves the proposal pending") + .into_inner(); + let automatic = state + .store + .get_draft_chunk(&auto_submitted.accepted_chunk_ids[0]) + .await + .expect("fetch automatic proposal") + .expect("stored automatic proposal"); + assert_eq!(automatic.status, "pending"); + assert!(automatic.application_error.contains("delete and recreate")); + assert!( + state + .store + .get_latest_policy(sandbox_id) + .await + .expect("policy history lookup") + .is_none() + ); + + let approved_rule = NetworkPolicyRule::decode(chunk.proposed_rule.as_slice()) + .expect("decode proposed rule"); + let mut approved_policy = ProtoSandboxPolicy::default(); + approved_policy + .network_policies + .insert(chunk.rule_name.clone(), approved_rule); + state + .store + .put_policy_revision( + "mxc-existing-policy", + sandbox_id, + "default", + 1, + &approved_policy.encode_to_vec(), + &deterministic_policy_hash(&approved_policy), + ) + .await + .expect("seed policy applied at sandbox startup"); + state + .store + .update_draft_chunk_status(chunk_id, "approved", Some(current_time_ms()), None) + .await + .expect("mark startup policy proposal approved"); + + let undo_error = handle_undo_draft_chunk( + &state, + with_user(Request::new(UndoDraftChunkRequest { + name: sandbox_name.to_string(), + chunk_id: chunk.id.clone(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), + })), + ) + .await + .expect_err("undo must not mutate MXC policy"); + assert_eq!(undo_error.code(), Code::FailedPrecondition); + + let reject_error = handle_reject_draft_chunk( + &state, + with_user(Request::new(RejectDraftChunkRequest { + name: sandbox_name.to_string(), + chunk_id: chunk.id.clone(), + reason: "reject approved proposal".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), + })), + ) + .await + .expect_err("rejecting an approved proposal must not mutate MXC policy"); + assert_eq!(reject_error.code(), Code::FailedPrecondition); + + let stored_chunk = state + .store + .get_draft_chunk(chunk_id) + .await + .expect("fetch approved proposal") + .expect("stored approved proposal"); + assert_eq!(stored_chunk.status, "approved"); + assert_eq!( + state + .store + .get_latest_policy(sandbox_id) + .await + .expect("policy history lookup") + .expect("startup policy revision") + .version, + 1 + ); + } + + #[tokio::test] + async fn mxc_allows_sandbox_authored_policy_sync() { + let state = test_server_state_with_driver("mxc").await; + let sandbox_id = "mxc-policy-sync"; + let policy = openshell_policy::restrictive_default_policy(); + state + .store + .put_message(&test_sandbox( + sandbox_id, + sandbox_id, + policy.clone(), + Vec::new(), + )) + .await + .expect("store MXC sandbox"); + + let response = handle_update_config( + &state, + with_sandbox( + Request::new(UpdateConfigRequest { + name: sandbox_id.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), + policy: Some(policy), + ..Default::default() + }), + sandbox_id, + ), + ) + .await + .expect("sandbox-authored startup sync remains supported") + .into_inner(); + + assert_eq!(response.version, 1); + } + #[tokio::test] async fn policy_record_identity_global_deduplicates_defaulted_mcp_history() { for (case, legacy_policy) in defaulted_mcp_policy_cases() { @@ -8592,7 +9053,7 @@ mod tests { .await .expect("store legacy merge base"); - let (version, hash, _) = apply_merge_operations_with_retry( + let (version, hash, _) = apply_merge_operations_with_retry_in_store( &store, &sandbox_id, "default", @@ -11179,7 +11640,7 @@ mod tests { .collect::>(); let error = apply_merge_operations_with_retry( - state.store.as_ref(), + state.as_ref(), "sb-ambiguous-merge", "default", None, @@ -13561,7 +14022,7 @@ mod tests { }, ]; let error = apply_merge_operations_with_retry( - state.store.as_ref(), + state.as_ref(), sandbox_id, "default", Some(&reviewed_policy), @@ -18196,7 +18657,7 @@ mod tests { }]; let (left, right) = tokio::join!( - apply_merge_operations_with_retry( + apply_merge_operations_with_retry_in_store( &store, sandbox_id, "default", @@ -18209,7 +18670,7 @@ mod tests { None, None ), - apply_merge_operations_with_retry( + apply_merge_operations_with_retry_in_store( &store, sandbox_id, "default", diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index e0a83890c4..a226680171 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -2729,16 +2729,15 @@ pub(super) async fn handle_import_provider_profiles( ); diagnostics.extend(validate_profile_set(&profiles)); if !has_errors(&diagnostics) { - diagnostics.extend( - profile_attached_sandbox_diagnostics( - state.store.as_ref(), - &catalog, - &workspace, - &profiles, - "import", - ) - .await?, - ); + let (attached_diagnostics, _) = profile_attached_sandbox_diagnostics( + state.store.as_ref(), + &catalog, + &workspace, + &profiles, + "import", + ) + .await?; + diagnostics.extend(attached_diagnostics); } if has_errors(&diagnostics) { @@ -2843,18 +2842,20 @@ pub(super) async fn handle_update_provider_profiles( severity: "error".to_string(), }); } - if !has_errors(&diagnostics) { - diagnostics.extend( - profile_attached_sandbox_diagnostics( - state.store.as_ref(), - &catalog, - &workspace, - &profiles, - "update", - ) - .await?, - ); - } + let affects_attached_sandbox = if has_errors(&diagnostics) { + false + } else { + let (attached_diagnostics, affects_attached) = profile_attached_sandbox_diagnostics( + state.store.as_ref(), + &catalog, + &workspace, + &profiles, + "update", + ) + .await?; + diagnostics.extend(attached_diagnostics); + affects_attached + }; if has_errors(&diagnostics) { return Ok(Response::new(UpdateProviderProfilesResponse { @@ -2864,6 +2865,10 @@ pub(super) async fn handle_update_provider_profiles( })); } + if affects_attached_sandbox { + super::policy::require_live_policy_update_support(state)?; + } + let expected_resource_version = expected_resource_version.unwrap_or_default(); let (_, profile) = profiles .into_iter() @@ -3489,7 +3494,7 @@ async fn profile_attached_sandbox_diagnostics( workspace: &str, profiles: &[(String, ProviderTypeProfile)], operation: &str, -) -> Result, Status> { +) -> Result<(Vec, bool), Status> { let mut candidate_profiles = HashMap::::new(); for (source, profile) in profiles { let Some(id) = normalize_profile_id(&profile.id) else { @@ -3498,7 +3503,7 @@ async fn profile_attached_sandbox_diagnostics( candidate_profiles.insert(id, (source.clone(), profile.clone())); } if candidate_profiles.is_empty() { - return Ok(Vec::new()); + return Ok((Vec::new(), false)); } let is_platform_scope = workspace.is_empty(); @@ -3523,6 +3528,7 @@ async fn profile_attached_sandbox_diagnostics( .await? }; let mut diagnostics = Vec::new(); + let mut affects_attached_sandbox = false; let validate_policy_composition = super::policy::provider_policy_composition_enabled(store).await?; for sandbox in sandboxes { @@ -3650,6 +3656,7 @@ async fn profile_attached_sandbox_diagnostics( if imported_profiles_used.is_empty() { continue; } + affects_attached_sandbox = true; if let Err(err) = validate_dynamic_token_grant_bindings_unambiguous(&bindings) { for (source, profile_id) in &imported_profiles_used { diagnostics.push(ProfileValidationDiagnostic { @@ -3684,7 +3691,7 @@ async fn profile_attached_sandbox_diagnostics( } } - Ok(diagnostics) + Ok((diagnostics, affects_attached_sandbox)) } fn stored_provider_profile_for_workspace( @@ -4966,7 +4973,9 @@ mod tests { use super::*; use crate::auth::identity::{Identity, IdentityProvider}; use crate::auth::principal::{Principal, UserPrincipal}; - use crate::grpc::test_support::{authed_request, test_server_state}; + use crate::grpc::test_support::{ + authed_request, test_server_state, test_server_state_with_driver, + }; use crate::grpc::{MAX_MAP_KEY_LEN, MAX_PROVIDER_TYPE_LEN}; use crate::persistence::test_store; use openshell_core::proto::{ @@ -5435,6 +5444,86 @@ mod tests { ); } + #[tokio::test] + async fn mxc_rejects_attached_provider_profile_update_before_persistence() { + let state = test_server_state_with_driver("mxc").await; + let mut original = custom_profile("custom-api"); + original.endpoints = vec![NetworkEndpoint { + host: "api.before.example".to_string(), + port: 443, + ..Default::default() + }]; + state + .store + .put_message(&stored_provider_profile(original)) + .await + .unwrap(); + state + .store + .put_message(&provider_with_values("work-custom", "custom-api")) + .await + .unwrap(); + state + .store + .put_message(&Sandbox { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: "mxc-profile-sandbox-id".to_string(), + name: "mxc-profile-sandbox".to_string(), + workspace: "default".to_string(), + ..Default::default() + }), + spec: Some(SandboxSpec { + providers: vec!["work-custom".to_string()], + ..Default::default() + }), + ..Default::default() + }) + .await + .unwrap(); + + let stored_before = state + .store + .get_message_by_name::("default", "custom-api") + .await + .unwrap() + .unwrap(); + let mut updated = custom_profile("custom-api"); + updated.resource_version = stored_before.metadata.as_ref().unwrap().resource_version; + updated.endpoints = vec![NetworkEndpoint { + host: "api.after.example".to_string(), + port: 443, + ..Default::default() + }]; + + let error = handle_update_provider_profiles( + &state, + authed_request(UpdateProviderProfilesRequest { + profile: Some(ProviderProfileImportItem { + profile: Some(updated), + source: "custom-api.yaml".to_string(), + }), + expected_resource_version: 0, + id: "custom-api".to_string(), + workspace: "default".to_string(), + }), + ) + .await + .expect_err("MXC cannot apply an attached provider profile update"); + assert_eq!(error.code(), Code::FailedPrecondition); + assert!(error.message().contains("delete and recreate")); + + let stored_after = state + .store + .get_message_by_name::("default", "custom-api") + .await + .unwrap() + .unwrap(); + assert_eq!( + stored_after.profile.unwrap().endpoints[0].host, + "api.before.example" + ); + } + #[tokio::test] async fn update_provider_profile_rejects_built_in_and_missing_profiles() { let state = test_server_state().await; diff --git a/crates/openshell-server/src/grpc/sandbox.rs b/crates/openshell-server/src/grpc/sandbox.rs index cc16a141db..73bdd75299 100644 --- a/crates/openshell-server/src/grpc/sandbox.rs +++ b/crates/openshell-server/src/grpc/sandbox.rs @@ -394,11 +394,16 @@ async fn handle_create_sandbox_inner( .authorize(&token, &workspace, &subject)?; } - let _sandbox_sync_guard = if spec.providers.is_empty() { - None - } else { - Some(state.compute.sandbox_sync_guard().await) - }; + // Drivers without live policy updates need an atomic boundary between + // create-time policy resolution and mutations that affect existing sandboxes. + // Provider-backed creates also serialize with profile mutation so the initial + // policy snapshot cannot miss a concurrent profile update before persistence. + let _sandbox_sync_guard = + if !state.compute.supports_live_policy_updates() || !spec.providers.is_empty() { + Some(state.compute.sandbox_sync_guard().await) + } else { + None + }; // Validate provider names exist (fail fast). for name in &spec.providers { @@ -1119,6 +1124,14 @@ pub(super) async fn handle_attach_sandbox_provider( .as_ref() .ok_or_else(|| Status::internal("sandbox spec is missing"))?; + if !spec + .providers + .iter() + .any(|name| name == &request.provider_name) + { + super::policy::require_live_policy_update_support(state)?; + } + // Pre-check: fail fast if already at MAX_PROVIDERS limit (avoid spurious CAS conflicts) // Note: This is an optimization; the CAS closure rechecks after dedupe in case of races if spec.providers.len() >= MAX_PROVIDERS @@ -1256,6 +1269,14 @@ pub(super) async fn handle_detach_sandbox_provider( .spec .as_ref() .ok_or_else(|| Status::internal("sandbox spec is missing"))?; + + if spec + .providers + .iter() + .any(|name| name == &request.provider_name) + { + super::policy::require_live_policy_update_support(state)?; + } let mut candidate_spec = spec.clone(); candidate_spec .providers @@ -3876,6 +3897,74 @@ mod tests { assert_eq!(spec.log_level, "debug"); } + #[tokio::test] + async fn mxc_rejects_provider_attachment_and_detachment_before_persistence() { + let state = test_server_state_with_driver("mxc").await; + state + .store + .put_message(&test_provider("work-github", "github")) + .await + .unwrap(); + state + .store + .put_message(&test_sandbox("attach-target", Vec::new())) + .await + .unwrap(); + + let attach_error = handle_attach_sandbox_provider( + &state, + authed_request(AttachSandboxProviderRequest { + sandbox_name: "attach-target".to_string(), + provider_name: "work-github".to_string(), + expected_resource_version: 0, + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), + }), + ) + .await + .expect_err("MXC cannot apply provider attachment to an existing sandbox"); + assert_eq!(attach_error.code(), tonic::Code::FailedPrecondition); + assert!(attach_error.message().contains("delete and recreate")); + let attach_target = state + .store + .get_message_by_name::("default", "attach-target") + .await + .unwrap() + .unwrap(); + assert!(attach_target.spec.unwrap().providers.is_empty()); + + state + .store + .put_message(&test_sandbox( + "detach-target", + vec!["work-github".to_string()], + )) + .await + .unwrap(); + let detach_error = handle_detach_sandbox_provider( + &state, + authed_request(DetachSandboxProviderRequest { + sandbox_name: "detach-target".to_string(), + provider_name: "work-github".to_string(), + expected_resource_version: 0, + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), + }), + ) + .await + .expect_err("MXC cannot apply provider detachment to an existing sandbox"); + assert_eq!(detach_error.code(), tonic::Code::FailedPrecondition); + assert!(detach_error.message().contains("delete and recreate")); + let detach_target = state + .store + .get_message_by_name::("default", "detach-target") + .await + .unwrap() + .unwrap(); + assert_eq!( + detach_target.spec.unwrap().providers, + vec!["work-github".to_string()] + ); + } + #[tokio::test] async fn attach_sandbox_provider_uses_configured_provider_profile_sources() { let state = test_server_state_with_user_only_github_profile().await; @@ -4896,6 +4985,83 @@ mod tests { assert!(err.message().contains("label value exceeds")); } + #[tokio::test] + async fn mxc_provider_free_create_waits_for_sandbox_sync_guard() { + let state = test_server_state_with_driver("mxc").await; + + let guard = state.compute.sandbox_sync_guard().await; + let task_state = state.clone(); + let task = tokio::spawn(async move { + handle_create_sandbox( + &task_state, + authed_request(CreateSandboxRequest { + name: "guarded-create".to_string(), + spec: Some(SandboxSpec::default()), + labels: HashMap::new(), + annotations: HashMap::new(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), + await_main_process_attachment: false, + workload_template_name: String::new(), + }), + ) + .await + }); + + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + assert!( + !task.is_finished(), + "provider-free sandbox create should wait for sandbox sync guard" + ); + drop(guard); + + let response = tokio::time::timeout(std::time::Duration::from_secs(5), task) + .await + .expect("create should finish after guard release") + .expect("join create task") + .expect("create should succeed") + .into_inner(); + assert!( + response.sandbox.unwrap().spec.unwrap().providers.is_empty(), + "the synchronization test must exercise a provider-free create" + ); + } + + #[tokio::test] + async fn live_update_driver_create_does_not_wait_for_sandbox_sync_guard() { + let state = test_server_state().await; + + let guard = state.compute.sandbox_sync_guard().await; + let task_state = state.clone(); + let task = tokio::spawn(async move { + handle_create_sandbox( + &task_state, + authed_request(CreateSandboxRequest { + name: "concurrent-create".to_string(), + spec: Some(SandboxSpec::default()), + labels: HashMap::new(), + annotations: HashMap::new(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), + await_main_process_attachment: false, + workload_template_name: String::new(), + }), + ) + .await + }); + + let response = tokio::time::timeout(std::time::Duration::from_secs(5), task) + .await + .expect("live-update-capable create must not wait for the sync guard") + .expect("join create task") + .expect("create should succeed") + .into_inner(); + drop(guard); + + assert!( + response.sandbox.unwrap().spec.unwrap().providers.is_empty(), + "the concurrency test must exercise a provider-free create" + ); + } + #[tokio::test] async fn create_sandbox_with_providers_waits_for_sandbox_sync_guard() { let state = test_server_state().await; @@ -4911,7 +5077,7 @@ mod tests { handle_create_sandbox( &task_state, authed_request(CreateSandboxRequest { - name: "guarded-create".to_string(), + name: "provider-create".to_string(), spec: Some(SandboxSpec { providers: vec!["work-github".to_string()], ..Default::default() diff --git a/crates/openshell-server/src/test_support.rs b/crates/openshell-server/src/test_support.rs index 62487d6bdb..4261f8ab6b 100644 --- a/crates/openshell-server/src/test_support.rs +++ b/crates/openshell-server/src/test_support.rs @@ -101,6 +101,7 @@ impl FakeComputeDriver { rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, supports_ui_policy: false, + supports_live_policy_updates: None, }, gateway_listener_requirements: Vec::new(), gateway_listener_requirements_supported: true, diff --git a/docs/sandboxes/policies.mdx b/docs/sandboxes/policies.mdx index 1d3685bf39..c83cb66f00 100644 --- a/docs/sandboxes/policies.mdx +++ b/docs/sandboxes/policies.mdx @@ -12,7 +12,7 @@ Use this page to apply and iterate policy changes on running sandboxes. For a fu ## Policy Structure -A policy has static sections `filesystem_policy`, `landlock`, `process`, and `ui` that are locked at sandbox creation. `network_policies` and `network_middlewares` are dynamic schema sections, but live updates work only on compute drivers that support runtime policy reload. MXC rejects live policy replacement and merge updates, so recreate the sandbox there. +A policy has static sections `filesystem_policy`, `landlock`, `process`, and `ui` that are locked at sandbox creation. `network_policies` and `network_middlewares` are dynamic schema sections, but live updates work only on compute drivers that support runtime policy reload. MXC rejects every operation that would change a running sandbox's effective policy: direct replacement or merge, global policy changes, policy-advisor approval or undo, provider attach or detach, and updates to an attached provider's profile. The gateway rejects these operations before saving anything; delete and recreate the MXC sandbox instead. ```yaml wordWrap showLineNumbers={false} version: 1 diff --git a/proto/compute_driver.proto b/proto/compute_driver.proto index 1d781bd6ea..16dc25091f 100644 --- a/proto/compute_driver.proto +++ b/proto/compute_driver.proto @@ -104,6 +104,11 @@ message GetCapabilitiesResponse { // portable SandboxPolicy.ui contract. Partial support must report false so // the gateway rejects every explicit UI section before provisioning. bool supports_ui_policy = 12; + // Whether the driver can apply policy changes to an already-created sandbox. + // Omit this field to preserve the legacy gateway behavior. Drivers that + // cannot update a live sandbox must explicitly report false so the gateway + // rejects changes instead of persisting a policy that is not enforced. + optional bool supports_live_policy_updates = 13; } message AuthenticateSandboxRequest { diff --git a/skills/openshell-cli/SKILL.md b/skills/openshell-cli/SKILL.md index b68cb3ca58..b44b748e50 100644 --- a/skills/openshell-cli/SKILL.md +++ b/skills/openshell-cli/SKILL.md @@ -443,7 +443,7 @@ the operation that removes retained state. This is the most important multi-step workflow. It enables a tight feedback cycle where sandbox policy is refined based on observed activity. -**Key concept**: Policies have static fields (immutable after creation: `filesystem_policy`, `landlock`, `process`, `ui`) and two dynamic fields: `network_policies` and `network_middlewares`. Both dynamic fields can be updated without recreating the sandbox when the selected compute driver supports live policy updates. MXC rejects live policy replacement and merge updates; delete and recreate an MXC sandbox instead. UI capabilities are enforced only by a configured driver/backend advertising complete UI-policy support. Today that is the MXC driver's OpenShell `process_container` backend, which emits MXC's `processcontainer` containment value. `isolation_session` and non-Windows drivers reject any explicit UI section before provisioning; omit it to preserve their existing behavior. +**Key concept**: Policies have static fields (immutable after creation: `filesystem_policy`, `landlock`, `process`, `ui`) and two dynamic fields: `network_policies` and `network_middlewares`. Both dynamic fields can be updated without recreating the sandbox when the selected compute driver supports live policy updates. MXC rejects every operation that would change a running sandbox's effective policy: direct replacement or merge, global policy changes, policy-advisor approval or undo, provider attach or detach, and updates to an attached provider's profile. The gateway rejects these operations before saving anything; delete and recreate the MXC sandbox instead. UI capabilities are enforced only by a configured driver/backend advertising complete UI-policy support. Today that is the MXC driver's OpenShell `process_container` backend, which emits MXC's `processcontainer` containment value. `isolation_session` and non-Windows drivers reject any explicit UI section before provisioning; omit it to preserve their existing behavior. An endpoint with omitted `protocol` retains explicit-proxy behavior. Explicit `protocol: tcp` requests policy DNS and transparent TCP and currently requires @@ -513,7 +513,7 @@ Edit `current-policy.yaml` to allow the blocked actions. **For policy content au - Binary matching patterns - Ordered `network_middlewares`, host selection, HTTP and WebSocket bindings, and `fail_open` or `fail_closed` behavior -`network_policies` and `network_middlewares` can be modified at runtime when the selected compute driver supports live policy updates. Use `--wait` to verify that the active runtime loaded the revision; do not infer enforcement from the gateway accepting the update. MXC rejects live policy replacement and merge updates; delete and recreate an MXC sandbox instead. If `filesystem_policy`, `landlock`, `process`, or `ui` need changes, the sandbox must be recreated. Built-in middleware such as `openshell/regex` needs no gateway registration. An operator-run middleware must already be registered under `[[openshell.supervisor.middleware]]`; changing that static registration requires a gateway restart. +`network_policies` and `network_middlewares` can be modified at runtime when the selected compute driver supports live policy updates. Use `--wait` to verify that the active runtime loaded the revision; do not infer enforcement from the gateway accepting the update. MXC rejects every operation that would change a running sandbox's effective policy: direct replacement or merge, global policy changes, policy-advisor approval or undo, provider attach or detach, and updates to an attached provider's profile. The gateway rejects these operations before saving anything; delete and recreate the MXC sandbox instead. If `filesystem_policy`, `landlock`, `process`, or `ui` need changes, the sandbox must be recreated. Built-in middleware such as `openshell/regex` needs no gateway registration. An operator-run middleware must already be registered under `[[openshell.supervisor.middleware]]`; changing that static registration requires a gateway restart. Middleware can inspect parsed HTTP request bodies and complete client-to-upstream WebSocket text messages over both `ws://` and `wss://` when the implementation advertises the matching binding. The built-in `openshell/regex` advertises both bindings and applies its fixed patterns to UTF-8 text. A host-matched HTTP-only attachment can inspect the upgrade GET but does not join the WebSocket chain; look for `binding_not_selected` coverage. Binary messages pass under both `on_error` modes and active stages emit `unsupported_message_type` coverage; upstream-to-client messages remain uninspected. A broken fail-open WebSocket stage is disabled for the rest of that connection; inspect sandbox OCSF logs for `openshell.middleware.websocket_stage_disabled`. From 2d5c9bfaab3c59bd7e5462784ec4515e0f3f726d Mon Sep 17 00:00:00 2001 From: Prekshi Vyas <34834085+prekshivyas@users.noreply.github.com> Date: Tue, 22 Sep 2026 20:22:40 -0700 Subject: [PATCH 18/42] fix(network): scope Windows egress by socket owner (#3491) * fix(ocsf): attribute MXC proxy events to sandboxes NVBug 6783086 Signed-off-by: Prekshi Vyas * fix(network): scope Windows egress by socket owner Resolve each accepted Windows proxy connection to its unique owning PID and executable before evaluating binary-scoped network policy. Fail closed when ownership or process identity cannot be established, and cover allowed and undeclared child processes with a real MXC regression. * fix(network): preserve sandbox context with socket owners Signed-off-by: Prekshi Vyas (cherry picked from commit 32ea316deb6c83c157fdf2de4a2cdc3a7d2da1e3) * fix(network): harden Windows process identity Signed-off-by: Prekshi Vyas --------- Signed-off-by: Prekshi Vyas --- Cargo.toml | 4 +- crates/openshell-driver-mxc/src/driver.rs | 9 - .../tests/wxc_exec_real.rs | 165 ++++++++ .../openshell-supervisor-network/src/host.rs | 55 +-- .../src/identity.rs | 44 +- .../openshell-supervisor-network/src/lib.rs | 2 + .../openshell-supervisor-network/src/proxy.rs | 157 +++++++- .../src/proxy/relay.rs | 10 +- .../openshell-supervisor-network/src/run.rs | 4 +- .../src/windows_process.rs | 381 ++++++++++++++++++ 10 files changed, 760 insertions(+), 71 deletions(-) create mode 100644 crates/openshell-supervisor-network/src/windows_process.rs diff --git a/Cargo.toml b/Cargo.toml index 8f8a2efcc6..b8e6f599d4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,8 +56,8 @@ terminal-colorsaurus = "1.0" miette = { version = "7", features = ["fancy"] } thiserror = "2" -# Windows platform APIs (ETW/TDH audit consumer in openshell-driver-mxc; Windows-only) -windows = { version = "0.62", features = ["Wdk_System_Threading", "Win32_Foundation", "Win32_NetworkManagement_IpHelper", "Win32_Networking_WinSock", "Win32_System_Diagnostics_Etw", "Win32_System_Time"] } +# Windows platform APIs (MXC audit and host-proxy process identity; Windows-only) +windows = { version = "0.62", features = ["Wdk_System_Threading", "Win32_Foundation", "Win32_NetworkManagement_IpHelper", "Win32_Networking_WinSock", "Win32_System_Diagnostics_Etw", "Win32_System_Threading", "Win32_System_Time"] } anyhow = "1" # Logging/Tracing diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index 10411258cb..6df81a09e3 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -668,14 +668,6 @@ fn allocate_sandbox_proxy_addr( const MINIMAL_WINDOWS_BOOTSTRAP_ENV: [&str; 5] = ["SYSTEMROOT", "WINDIR", "PATH", "COMSPEC", "LOCALAPPDATA"]; -fn host_proxy_binary_path(config: &MxcSandboxConfig) -> PathBuf { - config - .command - .first() - .filter(|command| !command.trim().is_empty()) - .map_or_else(|| PathBuf::from("mxc-agent"), PathBuf::from) -} - const TLS_ENV_KEYS: [&str; 6] = [ "NODE_EXTRA_CA_CERTS", "DENO_CERT", @@ -1622,7 +1614,6 @@ async fn run_lifecycle( openshell_supervisor_network::host::HostProxyConfig { bind_addr: addr, policy: proxy_policy, - binary_path: host_proxy_binary_path(&sandbox_config), client_auth: proxy_auth.host_client_auth(), sandbox_id: Some(sandbox_id.clone()), sandbox_name: Some(sandbox_name.clone()), diff --git a/crates/openshell-driver-mxc/tests/wxc_exec_real.rs b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs index e5e7dbd399..d54a9b8cb6 100644 --- a/crates/openshell-driver-mxc/tests/wxc_exec_real.rs +++ b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs @@ -1125,6 +1125,171 @@ async fn pc_https_egress_reads_injected_ca_bundle() { ); } +/// Prove that host-proxy binary policy follows the process that owns each TCP +/// connection, rather than the sandbox entry command. This deliberately uses +/// L4 CONNECT policy so the assertion is independent of TLS/L7 enforcement. +#[tokio::test] +#[ignore = "requires real wxc-exec and outbound HTTPS"] +async fn pc_proxy_scopes_network_policy_to_socket_owner() { + let Some(wxc) = wxc_path() else { + eprintln!("SKIP: wxc-exec not found"); + return; + }; + if let Err(reason) = probe_processcontainer(&wxc) { + eprintln!("SKIP: processcontainer not live: {reason}"); + return; + } + + // QueryFullProcessImageNameW returns this Win32 spelling on the Windows + // test image. Keep the spelling exact here; path and case normalization + // are covered separately. + let cmd = PathBuf::from(r"C:\Windows\System32\cmd.exe"); + let curl = PathBuf::from(r"C:\Windows\System32\curl.exe"); + if !cmd.exists() || !curl.exists() { + eprintln!( + "SKIP: expected Windows binaries are absent (cmd={}, curl={})", + cmd.display(), + curl.display() + ); + return; + } + + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + run_proxy_binary_scope_case(&wxc, "pc-owner-allow-child", &cmd, &curl, &curl, true).await; + run_proxy_binary_scope_case(&wxc, "pc-owner-deny-child", &cmd, &curl, &cmd, false).await; +} + +async fn run_proxy_binary_scope_case( + wxc: &Path, + sandbox_id: &str, + cmd: &Path, + curl: &Path, + allowed_binary: &Path, + expect_allowed: bool, +) { + let output_dir = tempfile::tempdir().expect("proxy scope output directory"); + let output_path = output_dir.path().join("example.html"); + let diagnostic_path = output_dir.path().join("curl-diagnostic.txt"); + let output_dir_string = output_dir.path().to_string_lossy().into_owned(); + let command = vec![ + cmd.to_string_lossy().into_owned(), + "/d".to_string(), + "/c".to_string(), + format!( + "echo proxy-scope 1>\"{}\" && \"{}\" --fail --silent --show-error --ssl-no-revoke --cacert \"%CURL_CA_BUNDLE%\" https://example.com/ --output \"{}\" 2>>\"{}\"", + diagnostic_path.display(), + curl.display(), + output_path.display(), + diagnostic_path.display() + ), + ]; + let serde_json::Value::Object(driver_config) = serde_json::json!({ + "command": command, + "cwd": output_dir_string, + }) else { + unreachable!(); + }; + let policy = SandboxPolicy { + version: 1, + filesystem: Some(FilesystemPolicy { + include_workdir: false, + read_only: Vec::new(), + read_write: vec![output_dir_string], + }), + network_policies: std::collections::HashMap::from([( + "https_example".to_string(), + NetworkPolicyRule { + name: "https-example".to_string(), + endpoints: vec![NetworkEndpoint { + host: "example.com".to_string(), + ports: vec![443], + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: allowed_binary.to_string_lossy().into_owned(), + }], + }, + )]), + ..Default::default() + }; + let sandbox = DriverSandbox { + id: sandbox_id.to_string(), + name: sandbox_id.to_string(), + spec: Some(DriverSandboxSpec { + template: Some(DriverSandboxTemplate { + driver_config: Some( + openshell_core::proto_struct::json_object_to_struct(driver_config) + .expect("driver config"), + ), + ..Default::default() + }), + policy: Some(policy), + ..Default::default() + }), + ..Default::default() + }; + let backend = MxcComputeBackend::new(MxcComputeConfig { + wxc_exec_path: wxc.to_string_lossy().into_owned(), + egress_proxy: true, + egress_proxy_addr: "127.0.0.1:18080".to_string(), + ..Default::default() + }); + backend + .create_sandbox(&sandbox) + .await + .expect("real proxy-scope sandbox create accepted"); + + let mut terminal_condition = None; + for _ in 0..600 { + if let Some(observed) = backend.get_sandbox(sandbox_id).await + && let Some(condition) = observed + .status + .and_then(|status| status.conditions.into_iter().find(|c| c.r#type == "Ready")) + && matches!( + condition.reason.as_str(), + "AgentCompleted" | "ExecFailed" | "ProvisionFailed" + ) + { + terminal_condition = Some(condition); + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + let condition = terminal_condition.expect("proxy-scope sandbox should terminate"); + let diagnostic = std::fs::read_to_string(&diagnostic_path) + .unwrap_or_else(|error| format!("failed to read curl diagnostic: {error}")); + backend + .delete_sandbox(sandbox_id, sandbox_id) + .await + .expect("delete completed proxy-scope sandbox"); + + if expect_allowed { + assert_eq!( + condition.reason, "AgentCompleted", + "declared child binary must be allowed: {}; diagnostic: {diagnostic}", + condition.message + ); + assert!( + std::fs::metadata(&output_path).is_ok_and(|metadata| metadata.len() > 0), + "allowed curl response should be non-empty; diagnostic: {diagnostic}" + ); + } else { + assert_eq!( + condition.reason, "ExecFailed", + "entry-command grant must not be inherited by curl: {}; diagnostic: {diagnostic}", + condition.message + ); + assert!( + diagnostic.contains("403"), + "undeclared curl child should receive proxy 403; diagnostic: {diagnostic}" + ); + assert!( + !output_path.exists(), + "denied curl child must not write an HTTPS response" + ); + } +} + /// Write to a path OUTSIDE the granted dir; assert exit non-zero and file absent. /// This is the genuine OS default-deny proof — the `AppContainer` blocks the write /// without requiring any host ACL lockdown. The mock can only fake this. diff --git a/crates/openshell-supervisor-network/src/host.rs b/crates/openshell-supervisor-network/src/host.rs index ea4082b9fa..e2a020ffb5 100644 --- a/crates/openshell-supervisor-network/src/host.rs +++ b/crates/openshell-supervisor-network/src/host.rs @@ -61,10 +61,6 @@ pub struct HostProxyConfig { pub bind_addr: SocketAddr, /// Network-only policy produced by the compute driver's policy split. pub policy: ProtoSandboxPolicy, - /// Static process identity used when the platform cannot recover the - /// socket-owning sandbox process. Policy binaries must match this path for - /// L4/L7 allow rules to pass. - pub binary_path: PathBuf, /// Per-sandbox client authentication. Host-side MXC proxies must set this /// so another sandbox cannot borrow this proxy's identity and policy. pub client_auth: HostProxyClientAuth, @@ -250,10 +246,9 @@ pub async fn start_host_proxy(config: HostProxyConfig) -> Result HostProxyConfig { + fn test_config(bind_addr: SocketAddr) -> HostProxyConfig { HostProxyConfig { bind_addr, policy: ProtoSandboxPolicy { version: 1, ..Default::default() }, - binary_path, client_auth: HostProxyClientAuth::basic("openshell", "test-secret"), sandbox_id: Some("sandbox-123".to_string()), sandbox_name: Some("agent-box".to_string()), @@ -314,7 +308,7 @@ mod tests { #[test] fn host_proxy_event_context_uses_configured_sandbox_identity() { let bind_addr = "127.0.0.1:18080".parse().unwrap(); - let config = test_config(bind_addr, PathBuf::from("agent.exe")); + let config = test_config(bind_addr); let context = host_proxy_event_context(&config).unwrap(); @@ -326,10 +320,7 @@ mod tests { #[test] fn host_proxy_event_context_rejects_missing_sandbox_identity() { - let mut config = test_config( - "127.0.0.1:18080".parse().unwrap(), - PathBuf::from("agent.exe"), - ); + let mut config = test_config("127.0.0.1:18080".parse().unwrap()); config.sandbox_id = Some(" ".to_string()); let error = host_proxy_event_context(&config).unwrap_err(); @@ -369,7 +360,9 @@ mod tests { let mut client = TcpStream::connect(addr).await.unwrap(); client.write_all(request.as_bytes()).await.unwrap(); let mut response = Vec::new(); - tokio::time::timeout(Duration::from_secs(2), client.read_to_end(&mut response)) + // The first authenticated CONNECT performs a full executable hash for + // TOFU identity binding; debug test binaries can be hundreds of MB. + tokio::time::timeout(Duration::from_secs(10), client.read_to_end(&mut response)) .await .unwrap() .unwrap(); @@ -378,11 +371,7 @@ mod tests { #[tokio::test] async fn rejects_non_loopback_bind_addr() { - let result = start_host_proxy(test_config( - ([192, 0, 2, 1], 0).into(), - PathBuf::from("missing-agent.exe"), - )) - .await; + let result = start_host_proxy(test_config(([192, 0, 2, 1], 0).into())).await; let Err(err) = result else { panic!("host proxy should reject non-loopback bind addresses"); @@ -395,10 +384,7 @@ mod tests { #[tokio::test] async fn rejects_middleware_policy_without_registry() { - let mut config = test_config( - ([127, 0, 0, 1], 0).into(), - PathBuf::from("missing-agent.exe"), - ); + let mut config = test_config(([127, 0, 0, 1], 0).into()); config.policy.network_middlewares.insert( "redactor".into(), NetworkMiddlewareConfig { @@ -427,15 +413,9 @@ mod tests { #[tokio::test] async fn starts_loopback_proxy_and_serves_policy_local() { let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); - let binary = tempfile::NamedTempFile::new().unwrap(); - std::fs::write(binary.path(), b"agent").unwrap(); - - let handle = start_host_proxy(test_config( - ([127, 0, 0, 1], 0).into(), - binary.path().to_path_buf(), - )) - .await - .unwrap(); + let handle = start_host_proxy(test_config(([127, 0, 0, 1], 0).into())) + .await + .unwrap(); let addr = handle.http_addr().expect("proxy should report bound addr"); assert!(addr.ip().is_loopback()); @@ -475,9 +455,6 @@ mod tests { #[tokio::test] async fn per_sandbox_credentials_reject_missing_wrong_cross_and_duplicate_auth() { - let binary = tempfile::NamedTempFile::new().unwrap(); - std::fs::write(binary.path(), b"agent").unwrap(); - let auth_a = HostProxyClientAuth::basic("openshell", "sandbox-a-secret"); let auth_b = HostProxyClientAuth::basic("openshell", "sandbox-b-secret"); // Node's EnvHttpProxyAgent currently emits the field name in lower @@ -491,11 +468,11 @@ mod tests { auth_b.expected_proxy_authorization ); - let mut config_a = test_config(([127, 0, 0, 1], 0).into(), binary.path().to_path_buf()); + let mut config_a = test_config(([127, 0, 0, 1], 0).into()); config_a.client_auth = auth_a; let proxy_a = start_host_proxy(config_a).await.unwrap(); - let mut config_b = test_config(([127, 0, 0, 1], 0).into(), binary.path().to_path_buf()); + let mut config_b = test_config(([127, 0, 0, 1], 0).into()); config_b.client_auth = auth_b; let proxy_b = start_host_proxy(config_b).await.unwrap(); diff --git a/crates/openshell-supervisor-network/src/identity.rs b/crates/openshell-supervisor-network/src/identity.rs index b824c58d26..cefa955cea 100644 --- a/crates/openshell-supervisor-network/src/identity.rs +++ b/crates/openshell-supervisor-network/src/identity.rs @@ -11,6 +11,7 @@ use crate::procfs; use miette::Result; use std::collections::HashMap; +#[cfg(not(target_os = "windows"))] use std::fs::Metadata; #[cfg(unix)] use std::os::unix::fs::MetadataExt; @@ -18,6 +19,7 @@ use std::path::{Path, PathBuf}; use std::sync::Mutex; use tracing::debug; +#[cfg(not(target_os = "windows"))] #[derive(Clone)] struct FileFingerprint { len: u64, @@ -29,6 +31,7 @@ struct FileFingerprint { ino: u64, } +#[cfg(not(target_os = "windows"))] impl FileFingerprint { fn from_metadata(metadata: &Metadata) -> Self { #[cfg(unix)] @@ -53,7 +56,7 @@ impl FileFingerprint { } } -#[cfg(not(unix))] +#[cfg(all(not(unix), not(target_os = "windows")))] fn system_time_parts(time: std::time::SystemTime) -> Option<(i64, i64)> { let duration = time.duration_since(std::time::UNIX_EPOCH).ok()?; Some(( @@ -62,6 +65,7 @@ fn system_time_parts(time: std::time::SystemTime) -> Option<(i64, i64)> { )) } +#[cfg(not(target_os = "windows"))] impl PartialEq for FileFingerprint { fn eq(&self, other: &Self) -> bool { self.len == other.len @@ -87,6 +91,7 @@ impl PartialEq for FileFingerprint { #[derive(Clone)] struct CachedBinary { hash: String, + #[cfg(not(target_os = "windows"))] fingerprint: FileFingerprint, } @@ -138,8 +143,10 @@ impl BinaryIdentityCache { let start = std::time::Instant::now(); let metadata = std::fs::metadata(access_path) .map_err(|error| miette::miette!("Failed to stat {}: {error}", cache_path.display()))?; + #[cfg(not(target_os = "windows"))] let fingerprint = FileFingerprint::from_metadata(&metadata); + #[cfg(not(target_os = "windows"))] let cached = self .hashes .lock() @@ -147,6 +154,10 @@ impl BinaryIdentityCache { .get(cache_path) .cloned(); + // Windows creation/modification timestamps and length can be restored + // after an in-place same-length rewrite. Rehash every Windows request + // so writable executables cannot reuse a spoofed cache fingerprint. + #[cfg(not(target_os = "windows"))] if let Some(cached_binary) = &cached && cached_binary.fingerprint == fingerprint { @@ -186,6 +197,7 @@ impl BinaryIdentityCache { cache_path.to_path_buf(), CachedBinary { hash: current_hash.clone(), + #[cfg(not(target_os = "windows"))] fingerprint, }, ); @@ -253,7 +265,7 @@ mod tests { .unwrap(); assert_eq!(hash1, hash2); - assert_eq!(hash_calls, 1); + assert_eq!(hash_calls, if cfg!(target_os = "windows") { 2 } else { 1 }); } #[test] @@ -329,6 +341,34 @@ mod tests { assert_eq!(hash_calls, 2); } + #[cfg(target_os = "windows")] + #[test] + fn same_length_rewrite_with_restored_mtime_is_rehashed() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("binary.exe"); + let original = b"trusted-content!"; + let tampered = b"tampered-content"; + assert_eq!(original.len(), tampered.len()); + std::fs::write(&path, original).unwrap(); + + let original_mtime = std::fs::metadata(&path).unwrap().modified().unwrap(); + let cache = BinaryIdentityCache::new(); + cache.verify_or_cache(&path).unwrap(); + + std::fs::write(&path, tampered).unwrap(); + std::fs::OpenOptions::new() + .write(true) + .open(&path) + .unwrap() + .set_modified(original_mtime) + .unwrap(); + + let error = cache + .verify_or_cache(&path) + .expect_err("Windows must rehash despite a restored metadata fingerprint"); + assert!(error.to_string().contains("integrity violation")); + } + #[test] fn display_path_can_differ_from_access_path() { let mut tmp = tempfile::NamedTempFile::new().unwrap(); diff --git a/crates/openshell-supervisor-network/src/lib.rs b/crates/openshell-supervisor-network/src/lib.rs index b815e826c4..2a3ae8824c 100644 --- a/crates/openshell-supervisor-network/src/lib.rs +++ b/crates/openshell-supervisor-network/src/lib.rs @@ -21,6 +21,8 @@ pub mod run; pub mod sigv4; mod token_grant; pub mod upstream_proxy; +#[cfg(target_os = "windows")] +pub(crate) mod windows_process; #[cfg(test)] pub(crate) mod test_alloc { diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index fb722ad4da..4f3d9af566 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -7,7 +7,7 @@ pub(crate) mod destination; mod egress; mod relay; -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "windows"))] use crate::identity::BinaryIdentityCache; use crate::l7::EndpointObserver; use crate::l7::tls::ProxyTlsState; @@ -175,11 +175,19 @@ pub(crate) enum ProxyIdentityMode { identity_cache: Arc, entrypoint_pid: Arc, }, - /// Host-side mode for platforms where procfs socket ownership is - /// unavailable. MXC uses this on Windows: every connection redirected to - /// the per-sandbox listener is evaluated as the configured sandbox agent - /// identity. - #[cfg(any(not(target_os = "linux"), test))] + /// Windows host-side mode: bind each request to the process that owns the + /// workload side of the accepted TCP connection. + #[cfg(target_os = "windows")] + Windows { + identity_cache: Arc, + required_proxy_authorization: Option>, + /// Per-sandbox context for host-side proxies. The process-wide OCSF + /// context cannot identify one sandbox when a gateway hosts many. + event_context: Option>, + }, + /// Static fallback for platforms without socket-owner resolution and for + /// tests that need to inject a deterministic identity. + #[cfg(any(not(any(target_os = "linux", target_os = "windows")), test))] Static { binary_path: PathBuf, binary_sha256: String, @@ -202,12 +210,21 @@ impl ProxyIdentityMode { } } - #[cfg(any(not(target_os = "linux"), test))] + #[cfg(target_os = "windows")] + pub(crate) fn windows_with_client_auth(required_proxy_authorization: Option>) -> Self { + Self::Windows { + identity_cache: Arc::new(BinaryIdentityCache::new()), + required_proxy_authorization, + event_context: None, + } + } + + #[cfg(any(not(any(target_os = "linux", target_os = "windows")), test))] pub(crate) fn static_binary(path: impl Into) -> Result { Self::static_binary_with_client_auth(path, None) } - #[cfg(any(not(target_os = "linux"), test))] + #[cfg(any(not(any(target_os = "linux", target_os = "windows")), test))] pub(crate) fn static_binary_with_client_auth( path: impl Into, required_proxy_authorization: Option>, @@ -225,8 +242,10 @@ impl ProxyIdentityMode { #[cfg(target_os = "windows")] pub(super) fn with_event_context(mut self, context: EventContext) -> Self { match &mut self { - #[cfg(target_os = "linux")] - Self::Procfs { .. } => {} + Self::Windows { event_context, .. } => { + *event_context = Some(Arc::new(context)); + } + #[cfg(test)] Self::Static { event_context, .. } => { *event_context = Some(Arc::new(context)); } @@ -236,7 +255,12 @@ impl ProxyIdentityMode { fn event_context(&self) -> &EventContext { match self { - #[cfg(any(not(target_os = "linux"), test))] + #[cfg(target_os = "windows")] + Self::Windows { + event_context: Some(context), + .. + } => context, + #[cfg(any(not(any(target_os = "linux", target_os = "windows")), test))] Self::Static { event_context: Some(context), .. @@ -249,7 +273,12 @@ impl ProxyIdentityMode { match self { #[cfg(target_os = "linux")] Self::Procfs { .. } => None, - #[cfg(any(not(target_os = "linux"), test))] + #[cfg(target_os = "windows")] + Self::Windows { + required_proxy_authorization, + .. + } => required_proxy_authorization.as_deref(), + #[cfg(any(not(any(target_os = "linux", target_os = "windows")), test))] Self::Static { required_proxy_authorization, .. @@ -263,7 +292,9 @@ impl ProxyIdentityMode { Self::Procfs { entrypoint_pid, .. } => { entrypoint_pid.load(std::sync::atomic::Ordering::Acquire) } - #[cfg(any(not(target_os = "linux"), test))] + #[cfg(target_os = "windows")] + Self::Windows { .. } => 0, + #[cfg(any(not(any(target_os = "linux", target_os = "windows")), test))] Self::Static { .. } => 0, } } @@ -2989,6 +3020,86 @@ fn sidecar_topology_enabled() -> bool { .is_ok_and(|value| value == SIDECAR_SUPERVISOR_TOPOLOGY) } +#[cfg(target_os = "windows")] +fn authorize_egress_intent_windows( + connection: crate::procfs::WorkloadProxyTcpConnection, + engine: &OpaEngine, + identity_cache: &BinaryIdentityCache, + intent: EgressIntent, +) -> EgressDecision { + let deny = |reason: String, binary: Option, binary_pid: Option| EgressDecision { + intent: intent.clone(), + action: NetworkAction::Deny { reason }, + policy_generation: engine.current_generation(), + identity: ProcessIdentityEvidence::Unavailable(IdentityUnavailableReason::LookupFailed), + endpoint: EndpointDecision::default(), + binary, + binary_pid, + ancestors: Vec::new(), + cmdline_paths: Vec::new(), + binary_match_paths: Vec::new(), + }; + + let (binary_path, binary_pid) = + match crate::windows_process::resolve_tcp_peer_identity(connection) { + Ok(identity) => identity, + Err(error) => { + return deny( + format!("failed to resolve Windows proxy peer identity: {error}"), + None, + None, + ); + } + }; + let binary_sha256 = match identity_cache.verify_or_cache(&binary_path) { + Ok(hash) => hash, + Err(error) => { + return deny( + format!("binary integrity check failed: {error}"), + Some(binary_path), + Some(binary_pid), + ); + } + }; + let input = crate::opa::NetworkInput { + host: intent.destination.host.clone(), + port: intent.destination.port, + binary_path: binary_path.clone(), + binary_sha256, + ancestors: Vec::new(), + cmdline_paths: Vec::new(), + }; + + match engine.authorize_egress(&input) { + Ok(authorization) => EgressDecision { + intent, + action: authorization.action.clone(), + policy_generation: authorization.generation, + identity: ProcessIdentityEvidence::Available, + endpoint: EndpointDecision::from_authorization(&authorization), + binary: Some(binary_path), + binary_pid: Some(binary_pid), + ancestors: Vec::new(), + cmdline_paths: Vec::new(), + binary_match_paths: authorization.binary_match_paths.clone(), + }, + Err(error) => EgressDecision { + intent, + action: NetworkAction::Deny { + reason: format!("policy evaluation error: {error}"), + }, + policy_generation: engine.current_generation(), + identity: ProcessIdentityEvidence::Available, + endpoint: EndpointDecision::default(), + binary: Some(binary_path), + binary_pid: Some(binary_pid), + ancestors: Vec::new(), + cmdline_paths: Vec::new(), + binary_match_paths: Vec::new(), + }, + } +} + fn evaluate_endpoint_only_opa(engine: &OpaEngine, intent: EgressIntent) -> EgressDecision { let input = crate::opa::NetworkInput { host: intent.destination.host.clone(), @@ -3039,7 +3150,7 @@ fn authorize_egress_intent( identity_mode: &ProxyIdentityMode, intent: EgressIntent, ) -> EgressDecision { - #[cfg(not(target_os = "linux"))] + #[cfg(not(any(target_os = "linux", target_os = "windows")))] let _ = &connection; if !crate::opa::network_binary_identity_required() { @@ -3058,7 +3169,11 @@ fn authorize_egress_intent( entrypoint_pid, intent, ), - #[cfg(any(not(target_os = "linux"), test))] + #[cfg(target_os = "windows")] + ProxyIdentityMode::Windows { identity_cache, .. } => { + authorize_egress_intent_windows(connection, engine, identity_cache, intent) + } + #[cfg(any(not(any(target_os = "linux", target_os = "windows")), test))] ProxyIdentityMode::Static { binary_path, binary_sha256, @@ -7399,6 +7514,16 @@ network_policies: validate_required_fields(&sandbox_b, &schema); } + #[cfg(target_os = "windows")] + #[test] + fn windows_socket_owner_identity_keeps_per_proxy_sandbox_attribution() { + let identity = ProxyIdentityMode::windows_with_client_auth(None) + .with_event_context(proxy_event_context("sandbox-a-id", "sandbox-a")); + + assert_eq!(identity.event_context().sandbox_id, "sandbox-a-id"); + assert_eq!(identity.event_context().sandbox_name, "sandbox-a"); + } + #[test] fn forward_l7_parse_rejection_ocsf_includes_denial_context() { let event = build_forward_l7_parse_rejection_ocsf_event( @@ -7768,6 +7893,8 @@ network_policies: } #[cfg(target_os = "linux")] ProxyIdentityMode::Procfs { .. } => panic!("expected static identity mode"), + #[cfg(target_os = "windows")] + ProxyIdentityMode::Windows { .. } => panic!("expected static identity mode"), } } diff --git a/crates/openshell-supervisor-network/src/proxy/relay.rs b/crates/openshell-supervisor-network/src/proxy/relay.rs index 9c5cfb1af8..423d63c7cf 100644 --- a/crates/openshell-supervisor-network/src/proxy/relay.rs +++ b/crates/openshell-supervisor-network/src/proxy/relay.rs @@ -471,8 +471,14 @@ network_policies: }, ); request.request_default_port = Some(80); - let context = prepare_http_relay(Some(&route), &engine, &decision, &request) - .expect("current policy generation should prepare the relay"); + let context = prepare_http_relay( + Some(&route), + &engine, + &decision, + &request, + openshell_ocsf::ctx::ctx(), + ) + .expect("current policy generation should prepare the relay"); relay_http_stream(&mut relay_client, &mut relay_upstream, context).await }); diff --git a/crates/openshell-supervisor-network/src/run.rs b/crates/openshell-supervisor-network/src/run.rs index d1372d8432..622a5b31d3 100644 --- a/crates/openshell-supervisor-network/src/run.rs +++ b/crates/openshell-supervisor-network/src/run.rs @@ -163,7 +163,7 @@ pub struct Networking { _transparent_tcp: Option, } -#[cfg(not(target_os = "linux"))] +#[cfg(not(any(target_os = "linux", target_os = "windows")))] fn current_exe_static_identity_path() -> Result { std::env::current_exe().map_err(|e| { miette::miette!("failed to resolve supervisor executable for static proxy identity: {e}") @@ -441,7 +441,7 @@ pub async fn run_networking( ProxyIdentityMode::procfs(cache, entrypoint_pid.clone()) }; #[cfg(target_os = "windows")] - let identity_mode = ProxyIdentityMode::static_binary(current_exe_static_identity_path()?)?; + let identity_mode = ProxyIdentityMode::windows_with_client_auth(None); #[cfg(all(not(target_os = "linux"), not(target_os = "windows")))] let identity_mode = ProxyIdentityMode::static_binary(current_exe_static_identity_path()?)?; diff --git a/crates/openshell-supervisor-network/src/windows_process.rs b/crates/openshell-supervisor-network/src/windows_process.rs new file mode 100644 index 0000000000..15a1d828e5 --- /dev/null +++ b/crates/openshell-supervisor-network/src/windows_process.rs @@ -0,0 +1,381 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Windows TCP socket-owner and process-image resolution. + +use std::mem::{offset_of, size_of, size_of_val}; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; +use std::path::PathBuf; + +use miette::Result; +use windows::Win32::Foundation::{CloseHandle, ERROR_INSUFFICIENT_BUFFER, HANDLE}; +use windows::Win32::NetworkManagement::IpHelper::{ + GetExtendedTcpTable, MIB_TCP_STATE_ESTAB, MIB_TCP6ROW_OWNER_PID, MIB_TCP6TABLE_OWNER_PID, + MIB_TCPROW_OWNER_PID, MIB_TCPTABLE_OWNER_PID, TCP_TABLE_OWNER_PID_ALL, +}; +use windows::Win32::Networking::WinSock::{AF_INET, AF_INET6}; +use windows::Win32::System::Threading::{ + OpenProcess, PROCESS_NAME_WIN32, PROCESS_QUERY_LIMITED_INFORMATION, QueryFullProcessImageNameW, +}; +use windows::core::PWSTR; + +use crate::procfs::WorkloadProxyTcpConnection; + +struct ProcessHandle(HANDLE); + +impl Drop for ProcessHandle { + fn drop(&mut self) { + // SAFETY: `self.0` is a valid handle returned by `OpenProcess`, and this + // guard is its sole owner. + #[allow(unsafe_code)] + let _ = unsafe { CloseHandle(self.0) }; + } +} + +/// Resolve the process that owns the workload side of an accepted proxy TCP +/// connection and return its PID and executable image path. +pub fn resolve_tcp_peer_identity(connection: WorkloadProxyTcpConnection) -> Result<(PathBuf, u32)> { + let mut owners = match (connection.workload, connection.proxy) { + (SocketAddr::V4(workload), SocketAddr::V4(proxy)) => ipv4_owner_pids(workload, proxy)?, + (SocketAddr::V6(workload), SocketAddr::V6(proxy)) => ipv6_owner_pids(&workload, &proxy)?, + _ => { + return Err(miette::miette!( + "TCP connection address families do not match: {connection}" + )); + } + }; + owners.sort_unstable(); + owners.dedup(); + + let pid = match owners.as_slice() { + [pid] => *pid, + [] => { + return Err(miette::miette!( + "No Windows process owns proxy connection {connection}" + )); + } + pids => { + return Err(miette::miette!( + "Ambiguous Windows proxy connection ownership for {connection}: PIDs [{}]", + pids.iter() + .map(u32::to_string) + .collect::>() + .join(", ") + )); + } + }; + + Ok((process_image_path(pid)?, pid)) +} + +fn ipv4_owner_pids( + workload: std::net::SocketAddrV4, + proxy: std::net::SocketAddrV4, +) -> Result> { + let (buffer, byte_len) = tcp_table(u32::from(AF_INET.0))?; + let rows = table_rows::( + &buffer, + byte_len, + offset_of!(MIB_TCPTABLE_OWNER_PID, table), + size_of::(), + )?; + let established = u32::try_from(MIB_TCP_STATE_ESTAB.0).expect("TCP state constant fits u32"); + Ok(rows + .into_iter() + .filter(|row| { + row.dwState == established + && IpAddr::V4(Ipv4Addr::from(row.dwLocalAddr.to_ne_bytes())) + == IpAddr::V4(*workload.ip()) + && tcp_port(row.dwLocalPort) == workload.port() + && IpAddr::V4(Ipv4Addr::from(row.dwRemoteAddr.to_ne_bytes())) + == IpAddr::V4(*proxy.ip()) + && tcp_port(row.dwRemotePort) == proxy.port() + }) + .map(|row| row.dwOwningPid) + .collect()) +} + +fn ipv6_owner_pids( + workload: &std::net::SocketAddrV6, + proxy: &std::net::SocketAddrV6, +) -> Result> { + let (buffer, byte_len) = tcp_table(u32::from(AF_INET6.0))?; + let rows = table_rows::( + &buffer, + byte_len, + offset_of!(MIB_TCP6TABLE_OWNER_PID, table), + size_of::(), + )?; + let established = u32::try_from(MIB_TCP_STATE_ESTAB.0).expect("TCP state constant fits u32"); + Ok(rows + .into_iter() + .filter(|row| ipv6_row_matches(row, workload, proxy, established)) + .map(|row| row.dwOwningPid) + .collect()) +} + +fn ipv6_row_matches( + row: &MIB_TCP6ROW_OWNER_PID, + workload: &std::net::SocketAddrV6, + proxy: &std::net::SocketAddrV6, + established: u32, +) -> bool { + row.dwState == established + && Ipv6Addr::from(row.ucLocalAddr) == *workload.ip() + && u32::from_be(row.dwLocalScopeId) == workload.scope_id() + && tcp_port(row.dwLocalPort) == workload.port() + && Ipv6Addr::from(row.ucRemoteAddr) == *proxy.ip() + && u32::from_be(row.dwRemoteScopeId) == proxy.scope_id() + && tcp_port(row.dwRemotePort) == proxy.port() +} + +fn tcp_port(raw: u32) -> u16 { + let low_word = u16::try_from(raw & u32::from(u16::MAX)).expect("masked TCP port fits u16"); + u16::from_be(low_word) +} + +fn tcp_table(address_family: u32) -> Result<(Vec, usize)> { + let mut byte_len = 0u32; + // SAFETY: A null table pointer is the documented size-query form. The + // mutable size pointer is valid for the duration of the call. + #[allow(unsafe_code)] + let initial = unsafe { + GetExtendedTcpTable( + None, + &raw mut byte_len, + false, + address_family, + TCP_TABLE_OWNER_PID_ALL, + 0, + ) + }; + if initial != ERROR_INSUFFICIENT_BUFFER.0 && initial != 0 { + return Err(miette::miette!( + "GetExtendedTcpTable size query failed with Win32 error {initial}" + )); + } + + for _ in 0..3 { + let mut buffer = vec![0u32; (byte_len as usize).div_ceil(size_of::()).max(1)]; + let mut actual_len = u32::try_from(buffer.len() * size_of::()) + .map_err(|_| miette::miette!("TCP table buffer is too large"))?; + // SAFETY: The u32-backed buffer has sufficient alignment and capacity + // for the requested byte count. The API writes at most `actual_len` + // bytes and updates it when the table grows concurrently. + #[allow(unsafe_code)] + let status = unsafe { + GetExtendedTcpTable( + Some(buffer.as_mut_ptr().cast()), + &raw mut actual_len, + false, + address_family, + TCP_TABLE_OWNER_PID_ALL, + 0, + ) + }; + if status == 0 { + return Ok((buffer, actual_len as usize)); + } + if status != ERROR_INSUFFICIENT_BUFFER.0 { + return Err(miette::miette!( + "GetExtendedTcpTable failed with Win32 error {status}" + )); + } + byte_len = actual_len; + } + + Err(miette::miette!( + "GetExtendedTcpTable changed size during three consecutive reads" + )) +} + +fn table_rows( + buffer: &[u32], + byte_len: usize, + first_row_offset: usize, + row_stride: usize, +) -> Result> { + if byte_len < size_of::() { + return Err(miette::miette!("Windows TCP table is missing its header")); + } + if row_stride < size_of::() { + return Err(miette::miette!( + "Windows TCP table row stride {row_stride} is smaller than row size {}", + size_of::() + )); + } + let count = buffer[0] as usize; + let required = if count == 0 { + size_of::() + } else { + first_row_offset + .checked_add( + (count - 1) + .checked_mul(row_stride) + .ok_or_else(|| miette::miette!("Windows TCP row count overflow"))?, + ) + .and_then(|last_row| last_row.checked_add(size_of::())) + .ok_or_else(|| miette::miette!("Windows TCP table size overflow"))? + }; + if required > byte_len || required > size_of_val(buffer) { + return Err(miette::miette!( + "Windows TCP table is truncated: {count} rows require {required} bytes, got {byte_len}" + )); + } + + let mut rows = Vec::with_capacity(count); + // SAFETY: Bounds were checked above. `read_unaligned` supports the padding + // permitted before the first row and between generated table rows. + #[allow(unsafe_code)] + unsafe { + let first = buffer.as_ptr().cast::().add(first_row_offset); + for index in 0..count { + rows.push(std::ptr::read_unaligned( + first.add(index * row_stride).cast::(), + )); + } + } + Ok(rows) +} + +fn process_image_path(pid: u32) -> Result { + // SAFETY: The access mask and PID are plain values; the returned handle is + // immediately placed under an RAII guard. + #[allow(unsafe_code)] + let handle = ProcessHandle( + unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) } + .map_err(|error| miette::miette!("Failed to open socket-owning PID {pid}: {error}"))?, + ); + let mut path = vec![0u16; 32_768]; + let mut path_len = u32::try_from(path.len()).expect("Windows path buffer length fits u32"); + // SAFETY: The handle is valid, and the UTF-16 output buffer and in/out + // length pointer remain valid for the call. + #[allow(unsafe_code)] + unsafe { + QueryFullProcessImageNameW( + handle.0, + PROCESS_NAME_WIN32, + PWSTR(path.as_mut_ptr()), + &raw mut path_len, + ) + } + .map_err(|error| { + miette::miette!("Failed to query executable path for socket-owning PID {pid}: {error}") + })?; + path.truncate(path_len as usize); + Ok(PathBuf::from(String::from_utf16(&path).map_err( + |error| miette::miette!("Socket-owning PID {pid} returned an invalid UTF-16 path: {error}"), + )?)) +} + +#[cfg(test)] +mod tests { + use std::io::Read; + use std::net::{TcpListener, TcpStream}; + use std::process::{Command, Stdio}; + + use super::*; + + const CHILD_PORT_ENV: &str = "OPENSHELL_TEST_WINDOWS_SOCKET_OWNER_PORT"; + + #[test] + fn socket_owner_child() { + let Ok(port) = std::env::var(CHILD_PORT_ENV) else { + return; + }; + let mut stream = TcpStream::connect(("127.0.0.1", port.parse::().unwrap())).unwrap(); + let mut byte = [0u8; 1]; + let _ = stream.read(&mut byte); + } + + #[test] + fn resolves_child_that_owns_ipv4_connection() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let proxy = listener.local_addr().unwrap(); + let current_exe = std::env::current_exe().unwrap(); + let mut child = Command::new(¤t_exe) + .args([ + "--exact", + "windows_process::tests::socket_owner_child", + "--nocapture", + ]) + .env(CHILD_PORT_ENV, proxy.port().to_string()) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + + let (accepted, workload) = listener.accept().unwrap(); + let result = resolve_tcp_peer_identity(WorkloadProxyTcpConnection::new(workload, proxy)); + drop(accepted); + let status = child.wait().unwrap(); + assert!(status.success()); + + let (path, pid) = result.unwrap(); + assert_eq!(pid, child.id()); + assert_eq!(path, current_exe); + } + + #[test] + fn parses_table_with_header_and_inter_row_padding() { + #[repr(C)] + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + struct SyntheticRow { + value: u32, + pid: u32, + } + + const FIRST_ROW_OFFSET: usize = 8; + const ROW_STRIDE: usize = 12; + let buffer = vec![ + 2, // dwNumEntries + 0xAAAA_AAAA, // header padding + 11, + 101, + 0xBBBB_BBBB, // inter-row padding + 22, + 202, + ]; + let rows = table_rows::( + &buffer, + size_of_val(buffer.as_slice()), + FIRST_ROW_OFFSET, + ROW_STRIDE, + ) + .expect("padded table should parse using its declared layout"); + + assert_eq!( + rows, + vec![ + SyntheticRow { + value: 11, + pid: 101, + }, + SyntheticRow { + value: 22, + pid: 202, + }, + ] + ); + } + + #[test] + fn matches_ipv6_scope_ids_from_network_byte_order() { + let workload = std::net::SocketAddrV6::new("fe80::1".parse().unwrap(), 51_234, 0, 17); + let proxy = std::net::SocketAddrV6::new("fe80::2".parse().unwrap(), 31_234, 0, 23); + let established = u32::try_from(MIB_TCP_STATE_ESTAB.0).unwrap(); + let row = MIB_TCP6ROW_OWNER_PID { + ucLocalAddr: workload.ip().octets(), + dwLocalScopeId: workload.scope_id().to_be(), + dwLocalPort: u32::from(workload.port().to_be()), + ucRemoteAddr: proxy.ip().octets(), + dwRemoteScopeId: proxy.scope_id().to_be(), + dwRemotePort: u32::from(proxy.port().to_be()), + dwState: established, + dwOwningPid: 42, + }; + + assert!(ipv6_row_matches(&row, &workload, &proxy, established)); + } +} From ab64e84bfc874f40e07045ede6723b57c52b8808 Mon Sep 17 00:00:00 2001 From: pkhodade-NV <84895267+pkhodade-NV@users.noreply.github.com> Date: Wed, 23 Sep 2026 22:43:32 +0530 Subject: [PATCH 19/42] fix(core): enforce owner-only Windows ACLs on sensitive files and dirs (#3495) * fix(core): enforce owner-only Windows ACLs on sensitive files and dirs set_dir_owner_only/set_file_owner_only were unconditional no-ops on Windows, so the CLI's mTLS client private key, OIDC/edge tokens, cached SSH keys, and the gateway's key-encryption key relied entirely on inherited NTFS ACLs with no OpenShell-applied restriction. Apply an owner-only DACL via SetEntriesInAclW/SetNamedSecurityInfoW with PROTECTED_DACL_SECURITY_INFORMATION to strip inherited ACEs, matching the 0700/0600 guarantee already provided on Unix. is_file_permissions_too_open now also works on Windows instead of being Unix-only, closing the detection gap alongside the prevention gap. Signed-off-by: Prashant Khodade (cherry picked from commit 71560e947f85819efbcddf70ddda94befab62b0b) * fix(core): treat a NULL DACL as too open in is_file_permissions_too_open has_foreign_trustee conflated a NULL DACL with an unreadable/invalid ACL and returned Some(false) (not too open) for both. Per the Win32 contract, a NULL DACL means the object grants full access to everyone -- the most permissive state possible -- so it must be flagged as too open. Split the null and invalid-ACL branches: null now returns Some(true), invalid ACL keeps the existing unreadable-ACL fallback (None, which the caller maps to false via unwrap_or). Adds a regression test that constructs a real NULL DACL via a SetNamedSecurityInfoW helper confined to the windows_acl module, consistent with the existing unsafe-FFI confinement in that module. Found by CodeRabbit review on MR !113. Signed-off-by: Prashant Khodade (cherry picked from commit 46e635a4ef1d6937cdb088f46aa85baee3d6ad28) * fix(core): close three false-negative gaps in the Windows ACL audit restrict_to_current_user() updated only the DACL, leaving a foreign owner's implicit WRITE_DAC right intact -- they could later replace the DACL we just set. Query OWNER_SECURITY_INFORMATION and take ownership in the same SetNamedSecurityInfoW call; if the caller can't (a genuinely foreign-owned object), the call now fails instead of silently leaving the object insecure. is_file_permissions_too_open() mapped every Win32 inspection failure (missing READ_CONTROL, an invalid ACL, a token-query failure) to "not too open" via unwrap_or(false). Fail closed instead: an inspection failure is a security false-negative risk, not a green light. has_foreign_trustee()'s ACE loop only recognized plain ACCESS_ALLOWED_ACE_TYPE and treated every other type as non-granting. Windows also defines access-allowed object, callback, and callback-object ACE variants that can grant rights to a foreign trustee; this audit doesn't parse their wider layouts, so their mere presence is now conservatively flagged as too open instead of silently skipped. Also updates architecture/gateway.md, which still described the SQLite file-tightening behavior only in terms of Unix mode 0o600, to distinguish it from the owner-only DACL behavior on Windows. Addresses review comments on PR #3495. Signed-off-by: Prashant Khodade * fix(core): conditional owner claim and audit owner in Windows ACL helpers restrict_to_current_user: query the current owner before calling SetNamedSecurityInfoW. Include OWNER_SECURITY_INFORMATION only when the path has a foreign owner -- requesting it unconditionally fails with ACCESS_DENIED (0x80070005) on standard credentials even when the current user is already the owner, because WRITE_OWNER is not implied by object ownership. A foreign-owned path still triggers an ownership claim and fails hard if the claim is denied, preserving the security contract. has_foreign_trustee: request OWNER_SECURITY_INFORMATION alongside DACL_SECURITY_INFORMATION and reject paths with a foreign owner immediately, before inspecting the DACL. A foreign owner has implicit WRITE_DAC rights and can replace any DACL we set, so a clean DACL is not sufficient evidence of safety on a foreign-owned object. architecture/gateway.md: clarify that the Windows path-hardening behavior sets mode 0o600 on Unix and applies a protected owner-only DACL on Windows, with conditional ownership claim and fail-hard semantics for foreign-owned objects. Signed-off-by: Prashant Khodade --------- Signed-off-by: Prashant Khodade --- Cargo.lock | 1 + Cargo.toml | 4 +- architecture/gateway.md | 10 +- crates/openshell-core/Cargo.toml | 3 + crates/openshell-core/src/paths.rs | 616 +++++++++++++++++- .../openshell-driver-db-credstore/src/lib.rs | 13 + .../src/persistence/sqlite.rs | 3 +- examples/governance-interceptor/Cargo.lock | 105 +++ .../Cargo.lock | 105 +++ 9 files changed, 839 insertions(+), 21 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 182b278170..825cfc48e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4060,6 +4060,7 @@ dependencies = [ "tonic-types", "tracing", "url", + "windows", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index b8e6f599d4..d6066067cb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,8 +56,8 @@ terminal-colorsaurus = "1.0" miette = { version = "7", features = ["fancy"] } thiserror = "2" -# Windows platform APIs (MXC audit and host-proxy process identity; Windows-only) -windows = { version = "0.62", features = ["Wdk_System_Threading", "Win32_Foundation", "Win32_NetworkManagement_IpHelper", "Win32_Networking_WinSock", "Win32_System_Diagnostics_Etw", "Win32_System_Threading", "Win32_System_Time"] } +# Windows platform APIs (MXC audit, host-proxy process identity, ACL enforcement; Windows-only) +windows = { version = "0.62", features = ["Wdk_System_Threading", "Win32_Foundation", "Win32_NetworkManagement_IpHelper", "Win32_Networking_WinSock", "Win32_Security", "Win32_Security_Authorization", "Win32_Storage_FileSystem", "Win32_System_Diagnostics_Etw", "Win32_System_Memory", "Win32_System_SystemServices", "Win32_System_Threading", "Win32_System_Time"] } anyhow = "1" # Logging/Tracing diff --git a/architecture/gateway.md b/architecture/gateway.md index bccebfd9f4..5f0e66da17 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -446,9 +446,13 @@ For in-memory SQLite, the adapter retains a dedicated keepalive connection for the store lifetime. Operational connection replacement therefore preserves the shared in-memory schema and objects instead of creating an empty database. -The SQLite adapter tightens the on-disk database file to mode `0o600` on every -connect so that provider API keys, SSH session tokens, and sandbox metadata are -not readable by other local users on shared hosts. The same restriction is +The SQLite adapter tightens the on-disk database file to owner-only access on +every connect so that provider API keys, SSH session tokens, and sandbox +metadata are not readable by other local users on shared hosts. On Unix the +file mode is set to `0o600`. On Windows a protected, owner-only DACL is +applied (inherited ACEs stripped; if the file has a foreign owner, ownership +is also claimed — the call fails hard if it cannot, rather than leaving the +foreign owner's implicit `WRITE_DAC` right intact). The same restriction is reapplied to the `-wal` and `-shm` sidecars (created by SQLite's default WAL journal mode), which mirror the same sensitive contents. diff --git a/crates/openshell-core/Cargo.toml b/crates/openshell-core/Cargo.toml index 601232def8..ddf1750044 100644 --- a/crates/openshell-core/Cargo.toml +++ b/crates/openshell-core/Cargo.toml @@ -41,6 +41,9 @@ tempfile = { version = "3", optional = true } nix = { workspace = true } rustix = { workspace = true } +[target.'cfg(windows)'.dependencies] +windows = { workspace = true } + [features] default = ["telemetry"] ## Compile in anonymous telemetry emission support. On by default; disable with diff --git a/crates/openshell-core/src/paths.rs b/crates/openshell-core/src/paths.rs index 2501958270..ff47fbc24a 100644 --- a/crates/openshell-core/src/paths.rs +++ b/crates/openshell-core/src/paths.rs @@ -74,8 +74,8 @@ pub fn xdg_data_dir() -> Result { Ok(PathBuf::from(home).join(".local").join("share")) } -/// Create a directory (and parents) with owner-only permissions (`0o700`) on -/// Unix. On non-Unix platforms, falls back to default permissions. +/// Create a directory (and parents) with owner-only permissions (`0o700` on +/// Unix; an owner-only DACL with inheritance disabled on Windows). /// /// This should be used for any directory that contains sensitive material /// (tokens, private keys, certificates). @@ -87,9 +87,9 @@ pub fn create_dir_restricted(path: &Path) -> Result<()> { Ok(()) } -/// Set a directory to owner-only access (`0o700`) on Unix. -/// -/// No-op on non-Unix platforms. +/// Restrict a directory to owner-only access: `0o700` on Unix, or an +/// owner-only DACL (with inherited ACEs stripped, and the ACE propagated to +/// children) on Windows. pub fn set_dir_owner_only(path: &Path) -> Result<()> { #[cfg(unix)] { @@ -98,14 +98,13 @@ pub fn set_dir_owner_only(path: &Path) -> Result<()> { .into_diagnostic() .wrap_err_with(|| format!("failed to set permissions on {}", path.display()))?; } - #[cfg(not(unix))] - let _ = path; + #[cfg(windows)] + windows_acl::restrict_to_current_user(path, true)?; Ok(()) } -/// Set a file to owner-only read/write (`0o600`) on Unix. -/// -/// No-op on non-Unix platforms. +/// Restrict a file to owner-only read/write: `0o600` on Unix, or an +/// owner-only DACL (with inherited ACEs stripped) on Windows. pub fn set_file_owner_only(path: &Path) -> Result<()> { #[cfg(unix)] { @@ -114,8 +113,8 @@ pub fn set_file_owner_only(path: &Path) -> Result<()> { .into_diagnostic() .wrap_err_with(|| format!("failed to set permissions on {}", path.display()))?; } - #[cfg(not(unix))] - let _ = path; + #[cfg(windows)] + windows_acl::restrict_to_current_user(path, false)?; Ok(()) } @@ -130,16 +129,478 @@ pub fn ensure_parent_dir_restricted(path: &Path) -> Result<()> { Ok(()) } -/// Check whether a file has permissions that are too open (group/other readable). +/// Check whether a file has permissions that are too open. /// -/// Returns `true` if the file has group or other read/write/execute bits set. -/// Always returns `false` on non-Unix platforms. +/// On Unix, returns `true` if the file has group or other read/write/execute +/// bits set, and `false` if the file's metadata cannot be read. On Windows, +/// returns `true` if the file's DACL grants access to any trustee other than +/// the current user, and also `true` (fails closed) if the ACL cannot be +/// inspected at all -- see the Windows doc comment below. #[cfg(unix)] pub fn is_file_permissions_too_open(path: &Path) -> bool { use std::os::unix::fs::PermissionsExt; std::fs::metadata(path).is_ok_and(|m| m.permissions().mode() & 0o077 != 0) } +/// Check whether a file has permissions that are too open. +/// +/// See the Unix doc comment above for the cross-platform contract. A Win32 +/// inspection failure (missing `READ_CONTROL`, an invalid ACL, a token-query +/// failure, etc.) is treated as too open rather than safe: `unwrap_or(false)` +/// would turn every such failure into a security false negative, so this +/// fails closed instead. +#[cfg(windows)] +pub fn is_file_permissions_too_open(path: &Path) -> bool { + windows_acl::has_foreign_trustee(path).unwrap_or(true) +} + +/// Windows ACL/DACL implementation of the owner-only permission helpers +/// above. Confined to this submodule so the `unsafe` FFI surface stays out +/// of the rest of the crate, matching the precedent set by +/// `openshell-driver-mxc`'s ETW consumer. +#[cfg(windows)] +mod windows_acl { + #![allow(unsafe_code)] + + use miette::{IntoDiagnostic, Result, WrapErr}; + use std::path::Path; + use windows::Win32::Foundation::{CloseHandle, HANDLE, HLOCAL, LocalFree}; + use windows::Win32::Security::Authorization::{ + EXPLICIT_ACCESS_W, GetNamedSecurityInfoW, SE_FILE_OBJECT, SET_ACCESS, SetEntriesInAclW, + SetNamedSecurityInfoW, TRUSTEE_IS_SID, TRUSTEE_IS_USER, TRUSTEE_W, + }; + use windows::Win32::Security::{ + ACCESS_ALLOWED_ACE, ACE_HEADER, ACL, ACL_SIZE_INFORMATION, AclSizeInformation, + DACL_SECURITY_INFORMATION, EqualSid, GetAce, GetAclInformation, GetTokenInformation, + IsValidAcl, NO_INHERITANCE, OWNER_SECURITY_INFORMATION, + PROTECTED_DACL_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, PSID, + SUB_CONTAINERS_AND_OBJECTS_INHERIT, TOKEN_QUERY, TOKEN_USER, TokenUser, + }; + use windows::Win32::Storage::FileSystem::FILE_ALL_ACCESS; + use windows::Win32::System::SystemServices::{ + ACCESS_ALLOWED_ACE_TYPE, ACCESS_ALLOWED_CALLBACK_ACE_TYPE, + ACCESS_ALLOWED_CALLBACK_OBJECT_ACE_TYPE, ACCESS_ALLOWED_OBJECT_ACE_TYPE, + }; + use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; + use windows::core::{HSTRING, PWSTR}; + + /// An owned, `TOKEN_USER`-aligned buffer (backed by `Vec` purely for + /// its alignment guarantee; the contents are opaque bytes filled in by + /// `GetTokenInformation`). + struct TokenUserBuf(Vec); + + /// Fetch the current process's user token info as an aligned buffer. + /// + /// Callers get the `PSID` out via [`sid_from_token_info`], which borrows + /// from the returned buffer; keep it alive for as long as the `PSID` is + /// used. + fn current_user_token_info() -> Result { + unsafe { + let mut token = HANDLE::default(); + OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &raw mut token) + .into_diagnostic() + .wrap_err("failed to open process token")?; + let _guard = HandleGuard(token); + + let mut needed = 0u32; + // First call is expected to fail with ERROR_INSUFFICIENT_BUFFER; + // we only want the required buffer size out of it. + let _ = GetTokenInformation(token, TokenUser, None, 0, &raw mut needed); + if needed == 0 { + return Err(miette::miette!("GetTokenInformation returned no size")); + } + let words = (needed as usize).div_ceil(size_of::()); + let mut buf = TokenUserBuf(vec![0u64; words]); + GetTokenInformation( + token, + TokenUser, + Some(buf.0.as_mut_ptr().cast()), + needed, + &raw mut needed, + ) + .into_diagnostic() + .wrap_err("failed to read current process token user")?; + Ok(buf) + } + } + + /// Extract the `PSID` from a `TOKEN_USER` buffer produced by + /// [`current_user_token_info`]. The `PSID` borrows from `buf`. + fn sid_from_token_info(buf: &TokenUserBuf) -> PSID { + // SAFETY: `buf` was sized and filled by `GetTokenInformation` for + // `TokenUser` in `current_user_token_info`, and `Vec`'s 8-byte + // alignment satisfies `TOKEN_USER`'s alignment requirement. + let token_user = unsafe { &*buf.0.as_ptr().cast::() }; + token_user.User.Sid + } + + struct HandleGuard(HANDLE); + impl Drop for HandleGuard { + fn drop(&mut self) { + // SAFETY: `self.0` is a valid handle owned by this guard. + let _ = unsafe { CloseHandle(self.0) }; + } + } + + struct LocalFreeGuard(*mut core::ffi::c_void); + impl Drop for LocalFreeGuard { + fn drop(&mut self) { + if !self.0.is_null() { + // SAFETY: `self.0` was allocated by a Win32 API documented to + // return LocalAlloc-owned memory (e.g. `SetEntriesInAclW`). + let _ = unsafe { LocalFree(Some(HLOCAL(self.0))) }; + } + } + } + + /// Overwrite `path`'s DACL with a single, non-inherited ACE granting full + /// control to the current user, stripping any inherited ACEs. `is_dir` + /// controls whether the ACE propagates to children (directories only). + /// When `path` has a foreign owner, ownership is also claimed; an object + /// whose owner we cannot take produces an error rather than silently + /// leaving their implicit `WRITE_DAC` right intact. + pub(super) fn restrict_to_current_user(path: &Path, is_dir: bool) -> Result<()> { + let token_info = current_user_token_info()?; + let sid = sid_from_token_info(&token_info); + + let path_hstring = HSTRING::from(path.as_os_str()); + + // Query the current owner to decide whether to include + // OWNER_SECURITY_INFORMATION in the upcoming SetNamedSecurityInfoW + // call. Requesting it unconditionally fails with ACCESS_DENIED + // (0x80070005) when the current user already owns the file, because + // WRITE_OWNER is not implied by being the object's owner. + let mut current_owner = PSID::default(); + let mut owner_sd = PSECURITY_DESCRIPTOR::default(); + // SAFETY: `path_hstring` is valid for the call; out-params are simple + // pointers filled by the API on success. The security descriptor is + // LocalAlloc-owned and freed below. + unsafe { + GetNamedSecurityInfoW( + PWSTR::from_raw(path_hstring.as_ptr().cast_mut()), + SE_FILE_OBJECT, + OWNER_SECURITY_INFORMATION, + Some(&raw mut current_owner), + None, + None, + None, + &raw mut owner_sd, + ) + } + .ok() + .into_diagnostic() + .wrap_err_with(|| format!("failed to query owner of {}", path.display()))?; + let _owner_sd_guard = LocalFreeGuard(owner_sd.0); + + // SAFETY: both SIDs come from Windows APIs and are valid for this block. + let already_owned = unsafe { EqualSid(sid, current_owner) }.is_ok(); + + let trustee = TRUSTEE_W { + TrusteeForm: TRUSTEE_IS_SID, + TrusteeType: TRUSTEE_IS_USER, + ptstrName: PWSTR(sid.0.cast()), + ..Default::default() + }; + + let entry = EXPLICIT_ACCESS_W { + grfAccessPermissions: FILE_ALL_ACCESS.0, + grfAccessMode: SET_ACCESS, + grfInheritance: if is_dir { + SUB_CONTAINERS_AND_OBJECTS_INHERIT + } else { + NO_INHERITANCE + }, + Trustee: trustee, + }; + + let mut new_acl: *mut ACL = core::ptr::null_mut(); + // SAFETY: `entry` is a valid, fully-initialized EXPLICIT_ACCESS_W + // whose Trustee SID borrows from `token_info`, kept alive for this + // call. `new_acl` receives a LocalAlloc-owned pointer on success. + unsafe { SetEntriesInAclW(Some(&[entry]), None, &raw mut new_acl) } + .ok() + .into_diagnostic() + .wrap_err_with(|| format!("failed to build ACL for {}", path.display()))?; + let _acl_guard = LocalFreeGuard(new_acl.cast()); + + // Include OWNER_SECURITY_INFORMATION only when the path has a foreign + // owner. When the current user is already the owner the flag is + // unnecessary and fails with ACCESS_DENIED on standard credentials. + // When the owner is foreign we must either take ownership or reject + // the path: a DACL-only update leaves the foreign owner's implicit + // WRITE_DAC right intact, letting them later replace this DACL + // (https://learn.microsoft.com/en-us/windows/win32/secauthz/owner-of-a-new-object). + let security_info = if already_owned { + DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION + } else { + OWNER_SECURITY_INFORMATION + | DACL_SECURITY_INFORMATION + | PROTECTED_DACL_SECURITY_INFORMATION + }; + let owner_arg = if already_owned { None } else { Some(sid) }; + + // SAFETY: `path_hstring` is a valid, NUL-terminated wide string for + // the lifetime of this call; `new_acl` is a valid ACL just built + // above; `sid` borrows from `token_info`, kept alive for this call. + // `PROTECTED_DACL_SECURITY_INFORMATION` strips inherited ACEs. + unsafe { + SetNamedSecurityInfoW( + PWSTR::from_raw(path_hstring.as_ptr().cast_mut()), + SE_FILE_OBJECT, + security_info, + owner_arg, + None, + Some(new_acl), + None, + ) + } + .ok() + .into_diagnostic() + .wrap_err_with(|| format!("failed to set owner-only ACL on {}", path.display()))?; + + Ok(()) + } + + /// Test-only: explicitly set a NULL DACL on `path`, the Win32 API's own + /// documented "grant everyone full access" state. Used to regression-test + /// that [`has_foreign_trustee`] treats a NULL DACL as too open rather + /// than conflating it with an unreadable/invalid ACL. + #[cfg(test)] + pub(super) fn set_null_dacl_for_test(path: &Path) -> Result<()> { + let path_hstring = HSTRING::from(path.as_os_str()); + // SAFETY: `path_hstring` is valid for the duration of this call; + // passing `None` for pdacl with `DACL_SECURITY_INFORMATION` set + // explicitly requests a NULL DACL, per the documented Win32 contract. + unsafe { + SetNamedSecurityInfoW( + PWSTR::from_raw(path_hstring.as_ptr().cast_mut()), + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION, + None, + None, + None, + None, + ) + } + .ok() + .into_diagnostic() + .wrap_err_with(|| format!("failed to set a NULL DACL on {}", path.display())) + } + + /// Test-only: set a DACL containing a single `ACCESS_ALLOWED_OBJECT_ACE` + /// (rather than the plain `ACCESS_ALLOWED_ACE` [`restrict_to_current_user`] + /// writes) granting the current user access. Used to regression-test that + /// [`has_foreign_trustee`] conservatively flags the non-basic + /// access-allow ACE layouts (object/callback/callback-object) it doesn't + /// parse, instead of silently skipping them as if they were a + /// non-granting type like deny/audit. + #[cfg(test)] + pub(super) fn set_object_ace_dacl_for_test(path: &Path) -> Result<()> { + use windows::Win32::Security::{ + ACE_FLAGS, ACL_REVISION, AddAccessAllowedObjectAce, InitializeAcl, + }; + + let token_info = current_user_token_info()?; + let sid = sid_from_token_info(&token_info); + + // Oversized fixed buffer: plenty of room for an ACL header plus one + // object ACE (which is wider than a plain ACE but still well under + // 1 KiB even with a SID). Backed by `Vec` purely for its 8-byte + // alignment guarantee, matching `TokenUserBuf` above -- `ACL` has a + // stricter alignment than a `Vec` buffer provides. + let mut acl_buf = vec![0u64; 128]; + let acl_len_bytes = size_of_val(acl_buf.as_slice()); + let acl_ptr = acl_buf.as_mut_ptr().cast::(); + let acl_len = u32::try_from(acl_len_bytes).expect("test buffer size fits in u32"); + // SAFETY: `acl_ptr` points at `acl_len` bytes of writable memory + // that outlives this call (owned by `acl_buf`, alive until this + // function returns). + unsafe { InitializeAcl(acl_ptr, acl_len, ACL_REVISION) } + .into_diagnostic() + .wrap_err("failed to initialize test ACL")?; + // SAFETY: `acl_ptr` was just initialized above and has room for one + // more ACE; `sid` borrows from `token_info`, kept alive for this + // call. Passing `None` for both GUIDs still produces an ACE typed + // `ACCESS_ALLOWED_OBJECT_ACE_TYPE` per the documented Win32 contract, + // which is exactly the non-basic layout under test. + unsafe { + AddAccessAllowedObjectAce( + acl_ptr, + ACL_REVISION, + ACE_FLAGS(0), + FILE_ALL_ACCESS.0, + None, + None, + sid, + ) + } + .into_diagnostic() + .wrap_err("failed to add object ACE to test ACL")?; + + let path_hstring = HSTRING::from(path.as_os_str()); + // SAFETY: `path_hstring` is valid for the duration of this call; + // `acl_ptr` is a valid, fully-built ACL from the calls above. + unsafe { + SetNamedSecurityInfoW( + PWSTR::from_raw(path_hstring.as_ptr().cast_mut()), + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION, + None, + None, + Some(acl_ptr), + None, + ) + } + .ok() + .into_diagnostic() + .wrap_err_with(|| format!("failed to set an object-ACE DACL on {}", path.display())) + } + + /// Returns `true` if `path`'s owner is not the current user, or if its + /// DACL grants access to any trustee other than the current user. + /// Returns `None` if the owner or ACL could not be read. + pub(super) fn has_foreign_trustee(path: &Path) -> Option { + let token_info = current_user_token_info().ok()?; + let user_sid = sid_from_token_info(&token_info); + + let path_hstring = HSTRING::from(path.as_os_str()); + let mut owner: PSID = PSID::default(); + let mut dacl: *mut ACL = core::ptr::null_mut(); + let mut security_descriptor = PSECURITY_DESCRIPTOR::default(); + // SAFETY: `path_hstring` is valid for the call; the out-params are + // simple pointers filled in by the API on success. The security + // descriptor (which both `owner` and `dacl` point into) is + // LocalAlloc-owned and freed below. + let status = unsafe { + GetNamedSecurityInfoW( + PWSTR::from_raw(path_hstring.as_ptr().cast_mut()), + SE_FILE_OBJECT, + OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, + Some(&raw mut owner), + None, + Some(&raw mut dacl), + None, + &raw mut security_descriptor, + ) + }; + status.ok().ok()?; + let _sd_guard = LocalFreeGuard(security_descriptor.0); + + // A foreign owner has implicit WRITE_DAC rights and can replace the + // DACL at any time, so reject the path regardless of what the DACL + // currently says. + // SAFETY: both SIDs come from Windows APIs and are valid for this call. + let owner_is_current_user = unsafe { EqualSid(user_sid, owner) }.is_ok(); + if !owner_is_current_user { + return Some(true); + } + + // A NULL DACL is a real, distinct state from "unreadable ACL": per + // the Win32 contract, it means the object grants full access to + // everyone -- the most permissive state possible -- so it must be + // flagged as too open, not treated as safe. + if dacl.is_null() { + return Some(true); + } + if unsafe { !IsValidAcl(dacl).as_bool() } { + return None; + } + + let mut size_info = ACL_SIZE_INFORMATION::default(); + // SAFETY: `dacl` was just validated above. + unsafe { + GetAclInformation( + dacl, + (&raw mut size_info).cast(), + u32::try_from(size_of::()) + .expect("ACL_SIZE_INFORMATION size fits in u32"), + AclSizeInformation, + ) + } + .ok()?; + + for index in 0..size_info.AceCount { + let mut ace_ptr: *mut core::ffi::c_void = core::ptr::null_mut(); + // SAFETY: `dacl` is valid and `index` is within `AceCount`. + if unsafe { GetAce(dacl, index, &raw mut ace_ptr) }.is_err() { + continue; + } + // SAFETY: `GetAce` returned a pointer to a valid ACE header. + let header = unsafe { &*ace_ptr.cast::() }; + let ace_type = u32::from(header.AceType); + if ace_type == ACCESS_ALLOWED_OBJECT_ACE_TYPE + || ace_type == ACCESS_ALLOWED_CALLBACK_ACE_TYPE + || ace_type == ACCESS_ALLOWED_CALLBACK_OBJECT_ACE_TYPE + { + // Windows also defines access-allowed object, callback, and + // callback-object ACE variants (each with a different, wider + // layout than plain ACCESS_ALLOWED_ACE), any of which may + // grant rights to a foreign trustee. This audit doesn't parse + // their layouts, so treat their mere presence as too open + // rather than silently skip them -- a false positive here is + // an unnecessary re-tightening, but a false negative is a + // security hole. See the ACE type table: + // https://learn.microsoft.com/en-us/windows/win32/secauthz/ace-strings + return Some(true); + } + if ace_type != ACCESS_ALLOWED_ACE_TYPE { + // Deny/audit/alarm ACE types don't grant access; skip them + // for this "is anyone but me granted access" check. + continue; + } + // SAFETY: header.AceType confirms this is an ACCESS_ALLOWED_ACE. + let ace = unsafe { &*ace_ptr.cast::() }; + let ace_sid = PSID((&raw const ace.SidStart).cast_mut().cast()); + // SAFETY: both SIDs come from Windows APIs (`GetTokenInformation` + // and `GetAce`) and are valid for the duration of this call. + let is_owner = unsafe { EqualSid(user_sid, ace_sid) }.is_ok(); + if !is_owner { + return Some(true); + } + } + Some(false) + } + + /// Test-only: returns `true` if `path`'s current owner SID equals the + /// current process's user SID. Used to regression-test that + /// [`restrict_to_current_user`] actually takes ownership of the object, + /// not just its DACL. + #[cfg(test)] + pub(super) fn owner_is_current_user_for_test(path: &Path) -> Result { + let token_info = current_user_token_info()?; + let expected_sid = sid_from_token_info(&token_info); + + let path_hstring = HSTRING::from(path.as_os_str()); + let mut owner = PSID::default(); + let mut security_descriptor = PSECURITY_DESCRIPTOR::default(); + // SAFETY: `path_hstring` is valid for the call; the out-params are + // simple pointers filled in by the API on success. The security + // descriptor `owner` points into is LocalAlloc-owned and freed below. + let status = unsafe { + GetNamedSecurityInfoW( + PWSTR::from_raw(path_hstring.as_ptr().cast_mut()), + SE_FILE_OBJECT, + OWNER_SECURITY_INFORMATION, + Some(&raw mut owner), + None, + None, + None, + &raw mut security_descriptor, + ) + }; + status + .ok() + .into_diagnostic() + .wrap_err_with(|| format!("failed to query owner of {}", path.display()))?; + let _sd_guard = LocalFreeGuard(security_descriptor.0); + + // SAFETY: both SIDs come from Windows APIs (`GetTokenInformation` and + // `GetNamedSecurityInfoW`) and are valid for the duration of this + // call. + Ok(unsafe { EqualSid(expected_sid, owner) }.is_ok()) + } +} + /// Normalize a filesystem path by collapsing redundant separators /// and removing trailing slashes, without requiring the path to exist on disk. /// @@ -252,6 +713,131 @@ mod tests { assert!(!is_file_permissions_too_open(&file)); } + #[cfg(windows)] + #[test] + fn create_dir_restricted_sets_owner_only_acl() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join("restricted"); + create_dir_restricted(&dir).unwrap(); + assert!( + !is_file_permissions_too_open(&dir), + "expected owner-only ACL on {}", + dir.display() + ); + } + + #[cfg(windows)] + #[test] + fn set_file_owner_only_sets_owner_only_acl() { + let tmp = tempfile::tempdir().unwrap(); + let file = tmp.path().join("secret"); + std::fs::write(&file, "secret-data").unwrap(); + set_file_owner_only(&file).unwrap(); + assert!( + !is_file_permissions_too_open(&file), + "expected owner-only ACL on {}", + file.display() + ); + } + + #[cfg(windows)] + #[test] + fn is_file_permissions_too_open_detects_world_readable() { + let tmp = tempfile::tempdir().unwrap(); + let file = tmp.path().join("open-file"); + std::fs::write(&file, "data").unwrap(); + // Grant Everyone read access, mirroring the Unix 0o644 case: an ACE + // for a trustee other than the current user. + let status = std::process::Command::new("icacls") + .arg(&file) + .arg("/grant") + .arg("Everyone:(R)") + .status() + .unwrap(); + assert!(status.success(), "icacls grant failed"); + assert!(is_file_permissions_too_open(&file)); + } + + #[cfg(windows)] + #[test] + fn is_file_permissions_too_open_accepts_restricted() { + let tmp = tempfile::tempdir().unwrap(); + let file = tmp.path().join("restricted-file"); + std::fs::write(&file, "data").unwrap(); + set_file_owner_only(&file).unwrap(); + assert!(!is_file_permissions_too_open(&file)); + } + + #[cfg(windows)] + #[test] + fn is_file_permissions_too_open_detects_null_dacl() { + // A NULL DACL is the Win32 API's own documented "grant everyone full + // access" state -- the most permissive possible -- and is a distinct + // condition from an unreadable/invalid ACL. Regression test for a + // CodeRabbit-flagged bug where the two were conflated and a NULL + // DACL was reported as safe. + let tmp = tempfile::tempdir().unwrap(); + let file = tmp.path().join("null-dacl-file"); + std::fs::write(&file, "data").unwrap(); + + windows_acl::set_null_dacl_for_test(&file).unwrap(); + + assert!( + is_file_permissions_too_open(&file), + "a NULL DACL grants everyone full access and must be flagged as too open" + ); + } + + #[cfg(windows)] + #[test] + fn restrict_to_current_user_also_takes_ownership() { + // A DACL-only update leaves a foreign owner's implicit WRITE_DAC + // right intact, letting them later replace the DACL we just set. + // Regression test for a review comment on the owner-only ACL PR. + let tmp = tempfile::tempdir().unwrap(); + let file = tmp.path().join("owned-file"); + std::fs::write(&file, "data").unwrap(); + + set_file_owner_only(&file).unwrap(); + + assert!( + windows_acl::owner_is_current_user_for_test(&file).unwrap(), + "restrict_to_current_user must take ownership, not just set the DACL" + ); + } + + #[cfg(windows)] + #[test] + fn is_file_permissions_too_open_fails_closed_on_inspection_error() { + // A nonexistent path can't have its ACL read, so GetNamedSecurityInfoW + // fails. `unwrap_or(false)` would turn that failure into "safe"; + // fail closed instead -- an inspection failure is a security false + // negative risk, not a green light. + let tmp = tempfile::tempdir().unwrap(); + let missing = tmp.path().join("does-not-exist"); + assert!(is_file_permissions_too_open(&missing)); + } + + #[cfg(windows)] + #[test] + fn is_file_permissions_too_open_detects_object_ace_type() { + // Windows also defines access-allowed object/callback/callback-object + // ACE types, each wider than the plain ACCESS_ALLOWED_ACE this audit + // parses. Any of them may grant rights to a foreign trustee, so their + // mere presence must be flagged conservatively rather than silently + // skipped as a non-granting (deny/audit) type would be. + let tmp = tempfile::tempdir().unwrap(); + let file = tmp.path().join("object-ace-file"); + std::fs::write(&file, "data").unwrap(); + + windows_acl::set_object_ace_dacl_for_test(&file).unwrap(); + + assert!( + is_file_permissions_too_open(&file), + "an unparsed access-allow ACE layout must be conservatively flagged as too open" + ); + } + #[test] fn normalize_path_collapses_separators() { assert_eq!(normalize_path("/usr//lib"), "/usr/lib"); diff --git a/crates/openshell-driver-db-credstore/src/lib.rs b/crates/openshell-driver-db-credstore/src/lib.rs index c9813870b2..c5c29494b8 100644 --- a/crates/openshell-driver-db-credstore/src/lib.rs +++ b/crates/openshell-driver-db-credstore/src/lib.rs @@ -1261,4 +1261,17 @@ mod tests { & 0o777; assert_eq!(key_encryption_key_mode, 0o600); } + + #[cfg(windows)] + #[test] + fn generated_key_encryption_key_file_is_owner_only() { + let tmp = tempfile::tempdir().unwrap(); + let key_encryption_key_path = tmp.path().join(DEFAULT_KEY_ENCRYPTION_KEY_FILE); + let _crypto = crypto_for_key_encryption_key_path(&key_encryption_key_path); + assert!( + !openshell_core::paths::is_file_permissions_too_open(&key_encryption_key_path), + "expected owner-only ACL on {}", + key_encryption_key_path.display() + ); + } } diff --git a/crates/openshell-server/src/persistence/sqlite.rs b/crates/openshell-server/src/persistence/sqlite.rs index 4c274386ca..93f0e6a318 100644 --- a/crates/openshell-server/src/persistence/sqlite.rs +++ b/crates/openshell-server/src/persistence/sqlite.rs @@ -1652,7 +1652,8 @@ WHERE "object_type" = ?1 AND "scope" = ?2 /// and `-shm` (shared memory index). They mirror the same sensitive data /// as the main file, so they get the same `0o600` treatment whenever they exist on disk. /// -/// Delegates to `set_file_owner_only`, which is a no-op on non-Unix platforms. +/// Delegates to `set_file_owner_only`, which restricts to an owner-only ACL +/// on Windows and `0o600` on Unix. pub(super) fn restrict_db_file_permissions(path: &Path) -> PersistenceResult<()> { set_file_owner_only(path).map_err(|err| PersistenceError::Database(err.to_string()))?; diff --git a/examples/governance-interceptor/Cargo.lock b/examples/governance-interceptor/Cargo.lock index 958e37d814..bcf2e7baa6 100644 --- a/examples/governance-interceptor/Cargo.lock +++ b/examples/governance-interceptor/Cargo.lock @@ -1085,6 +1085,7 @@ dependencies = [ "tonic-types", "tracing", "url", + "windows", ] [[package]] @@ -2365,12 +2366,107 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -2405,6 +2501,15 @@ dependencies = [ "windows_x86_64_msvc", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" diff --git a/examples/supervisor-middleware-content-guard/Cargo.lock b/examples/supervisor-middleware-content-guard/Cargo.lock index f65d9a8318..8fb3844bd5 100644 --- a/examples/supervisor-middleware-content-guard/Cargo.lock +++ b/examples/supervisor-middleware-content-guard/Cargo.lock @@ -972,6 +972,7 @@ dependencies = [ "tonic-types", "tracing", "url", + "windows", ] [[package]] @@ -1948,12 +1949,107 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -1988,6 +2084,15 @@ dependencies = [ "windows_x86_64_msvc", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" From 9e397e8a439c1b0c3284db56c1ada53c65e752da Mon Sep 17 00:00:00 2001 From: pkhodade-NV <84895267+pkhodade-NV@users.noreply.github.com> Date: Wed, 23 Sep 2026 23:46:54 +0530 Subject: [PATCH 20/42] fix(mxc): reject cpu/memory limits instead of silently discarding them (#3548) * fix(mxc): reject cpu/memory limits instead of silently discarding them CreateSandbox accepted --cpu/--memory and reached Ready with no Job Object enforcement and no diagnostic, leaving the SDD's T11 host-exhaustion mitigation silently unmet. MXC's schema does not expose CPU rate control or memory limiting outside the WSLC backend, so reject requests carrying cpu/memory limits synchronously at CreateSandbox, matching the existing fail-closed GPU rejection. NVBug 6782894 Signed-off-by: Prashant Khodade * fix(server): validate sandbox resource quantities Signed-off-by: Shailendra Singh * docs(skill): clarify MXC resource limit behavior Signed-off-by: Shailendra Singh * chore(go): regenerate protobuf bindings Signed-off-by: Shailendra Singh * fix(go): align generated protobuf comments Signed-off-by: Shailendra Singh --------- Signed-off-by: Prashant Khodade Signed-off-by: Shailendra Singh Co-authored-by: Shailendra Singh --- architecture/compute-runtimes.md | 5 +- crates/openshell-driver-mxc/README.md | 1 + crates/openshell-driver-mxc/src/driver.rs | 50 ++++- crates/openshell-server/src/compute/mod.rs | 37 ++++ .../openshell-server/src/grpc/validation.rs | 200 ++++++++++++++++++ docs/reference/sandbox-compute-drivers.mdx | 8 +- proto/openshell.proto | 3 +- sdk/go/proto/openshellv1/openshell.pb.go | 3 +- skills/openshell-cli/SKILL.md | 2 +- 9 files changed, 297 insertions(+), 12 deletions(-) diff --git a/architecture/compute-runtimes.md b/architecture/compute-runtimes.md index e9320323be..fa4ce229b0 100644 --- a/architecture/compute-runtimes.md +++ b/architecture/compute-runtimes.md @@ -318,7 +318,10 @@ delete-and-cleanup path above. Per-sandbox CPU and memory values currently enter the driver layer through template resource limits. Docker and Podman apply them as runtime limits. Kubernetes mirrors each limit into the matching request. VM accepts the fields -but currently ignores them. +but currently ignores them. MXC rejects them because its current backends cannot +enforce them. At the public protobuf boundary, the gateway requires well-known +`cpu` and `memory` entries under `limits` or `requests` to be non-empty strings; +unknown resource entries remain available for platform-specific handling. Reusable sandbox workload templates are resolved before the compute-driver boundary. Drivers do not receive a separate template resource; the gateway diff --git a/crates/openshell-driver-mxc/README.md b/crates/openshell-driver-mxc/README.md index 678632210f..7c9d5c08aa 100644 --- a/crates/openshell-driver-mxc/README.md +++ b/crates/openshell-driver-mxc/README.md @@ -26,6 +26,7 @@ it does not implement the Linux `ConnectSupervisor` protocol. | Network policy | With `egress_proxy = true` on `process_container`, an explicit `network_policies` rule activates MXC 0.8 loopback-only egress plus the full policy enforced by a per-sandbox OpenShell host CONNECT proxy. The driver injects proxy environment variables for proxy-aware clients; direct Internet access remains denied by MXC. A policy without network rules does not activate the proxy. Otherwise rejected synchronously. `isolation_session` remains fail-closed. | | Provider credentials | The child receives revision-scoped placeholders and non-secret provider environment only. The per-sandbox host proxy retains the resolver and substitutes credentials only for their bound endpoints. | | Process policy | Unsupported; MXC supplies OS isolation only. | +| Resource limits | Unsupported; MXC exposes no CPU rate control or memory limiting to non-WSLC backends. `CreateSandbox` rejects any request carrying `cpu_limit`, `cpu_request`, `memory_limit`, or `memory_request` synchronously rather than silently discarding them. | | Dynamic forwarding | Supported through `openshell-supervisor-relay`; interactive exec/connect remain unsupported. | | Network middleware | Rejected before launch until the host proxy receives the gateway middleware registry. | | ETW/OCSF audit | Optional Windows Sandboxing ETW consumer attributes host events to OpenShell sandboxes and emits OCSF records. | diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index 6df81a09e3..19a5d82790 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -1006,12 +1006,22 @@ impl MxcComputeBackend { "mxc driver does not support GPU sandboxes", )); } - if let Some(tmpl) = &spec.template - && !tmpl.agent_socket_path.is_empty() - { - return Err(tonic::Status::invalid_argument( - "mxc driver does not support agent_socket_path (no in-sandbox supervisor)", - )); + if let Some(tmpl) = &spec.template { + if !tmpl.agent_socket_path.is_empty() { + return Err(tonic::Status::invalid_argument( + "mxc driver does not support agent_socket_path (no in-sandbox supervisor)", + )); + } + if let Some(resources) = &tmpl.resources + && (!resources.cpu_limit.is_empty() + || !resources.cpu_request.is_empty() + || !resources.memory_limit.is_empty() + || !resources.memory_request.is_empty()) + { + return Err(tonic::Status::invalid_argument( + "mxc driver does not support cpu/memory resource limits (no Job Object enforcement); omit --cpu/--memory or use a driver that supports them", + )); + } } } sandbox_config(sandbox)?; @@ -2577,7 +2587,9 @@ fn make_sandbox_with_condition( mod lifecycle_tests { use super::*; use futures::StreamExt; - use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; + use openshell_core::proto::compute::v1::{ + DriverResourceRequirements, DriverSandboxSpec, DriverSandboxTemplate, + }; use openshell_core::proto::{ FilesystemPolicy, MiddlewareEndpointSelector, NetworkBinary, NetworkEndpoint, NetworkMiddlewareConfig, NetworkPolicyRule, SandboxPolicy, StaticCredentialBinding, @@ -3786,6 +3798,30 @@ mod lifecycle_tests { assert!(error.message().contains("unrestricted egress fallback")); } + #[test] + fn cpu_and_memory_limits_are_rejected_fail_closed() { + let backend = MxcComputeBackend::new_mocked(MxcComputeConfig::default()); + let mut sandbox = driver_sandbox("sb-cpu-memory-limits"); + sandbox + .spec + .as_mut() + .unwrap() + .template + .as_mut() + .unwrap() + .resources = Some(DriverResourceRequirements { + cpu_limit: "1".into(), + memory_limit: "512Mi".into(), + ..Default::default() + }); + + let error = backend + .validate_sandbox_create(&sandbox) + .expect_err("cpu/memory limits are not enforceable on mxc and must fail closed"); + assert_eq!(error.code(), tonic::Code::InvalidArgument); + assert!(error.message().contains("cpu/memory")); + } + #[tokio::test] async fn processcontainer_live_config_carries_explicit_ui_policy() { let backend = MxcComputeBackend::new_mocked(MxcComputeConfig::default()); diff --git a/crates/openshell-server/src/compute/mod.rs b/crates/openshell-server/src/compute/mod.rs index 5cc050f705..12ddac2e46 100644 --- a/crates/openshell-server/src/compute/mod.rs +++ b/crates/openshell-server/src/compute/mod.rs @@ -7191,6 +7191,43 @@ mod tests { assert!(build_platform_config(&template).is_none()); } + #[test] + fn driver_template_maps_string_cpu_and_memory_resources() { + let template = SandboxTemplate { + resources: Some(prost_types::Struct { + fields: [ + ( + "limits", + struct_value([("cpu", string_value("2")), ("memory", string_value("1Gi"))]), + ), + ( + "requests", + struct_value([ + ("cpu", string_value("500m")), + ("memory", string_value("512Mi")), + ]), + ), + ] + .into_iter() + .map(|(key, value)| (key.to_string(), value)) + .collect(), + }), + ..Default::default() + }; + + let driver = driver_sandbox_template_from_public(&template, "mxc").unwrap(); + assert_eq!( + driver.resources, + Some(DriverResourceRequirements { + cpu_request: "500m".to_string(), + cpu_limit: "2".to_string(), + memory_request: "512Mi".to_string(), + memory_limit: "1Gi".to_string(), + }) + ); + assert!(driver.platform_config.is_none()); + } + #[test] fn build_platform_config_preserves_non_typed_resource_fields() { let template = SandboxTemplate { diff --git a/crates/openshell-server/src/grpc/validation.rs b/crates/openshell-server/src/grpc/validation.rs index 2785b9b09b..7b3e7c4719 100644 --- a/crates/openshell-server/src/grpc/validation.rs +++ b/crates/openshell-server/src/grpc/validation.rs @@ -360,6 +360,7 @@ fn validate_sandbox_template(tmpl: &SandboxTemplate) -> Result<(), Status> { ), )); } + validate_template_resources(s)?; } if let Some(ref s) = tmpl.driver_config { let size = s.encoded_len(); @@ -377,6 +378,57 @@ fn validate_sandbox_template(tmpl: &SandboxTemplate) -> Result<(), Status> { Ok(()) } +/// Validate the well-known Kubernetes-style resource fields before the public +/// `Struct` is split into typed driver requirements and opaque platform data. +/// +/// Unknown resource names remain platform-specific, but CPU and memory must be +/// non-empty strings so malformed requests cannot be silently passed through +/// as opaque data and ignored by a compute driver. +fn validate_template_resources(resources: &prost_types::Struct) -> Result<(), Status> { + use prost_types::value::Kind; + + for section_name in ["limits", "requests"] { + let Some(section_value) = resources.fields.get(section_name) else { + continue; + }; + let field_prefix = format!("spec.template.resources.{section_name}"); + let Some(Kind::StructValue(section)) = section_value.kind.as_ref() else { + return Err(invalid_argument( + &field_prefix, + format!("template.resources.{section_name} must be an object"), + )); + }; + + for resource_name in ["cpu", "memory"] { + let Some(resource_value) = section.fields.get(resource_name) else { + continue; + }; + let field = format!("{field_prefix}.{resource_name}"); + match resource_value.kind.as_ref() { + Some(Kind::StringValue(value)) if !value.is_empty() => {} + Some(Kind::StringValue(_)) => { + return Err(invalid_argument( + &field, + format!( + "template.resources.{section_name}.{resource_name} must not be empty" + ), + )); + } + _ => { + return Err(invalid_argument( + &field, + format!( + "template.resources.{section_name}.{resource_name} must be a string" + ), + )); + } + } + } + } + + Ok(()) +} + /// `driver_config` fields the gateway resolves and writes itself. /// /// A caller who could set these would hand a raw host path straight to a @@ -1412,6 +1464,154 @@ mod tests { assert!(err.message().contains("template.resources")); } + #[test] + fn validate_sandbox_spec_accepts_string_cpu_and_memory_resources() { + use prost_types::{Struct, Value, value::Kind}; + + let section = |fields: [(&str, &str); 2]| Value { + kind: Some(Kind::StructValue(Struct { + fields: fields + .into_iter() + .map(|(name, value)| { + ( + name.to_string(), + Value { + kind: Some(Kind::StringValue(value.to_string())), + }, + ) + }) + .collect(), + })), + }; + let resources = Struct { + fields: [ + ( + "limits".to_string(), + section([("cpu", "1"), ("memory", "1Gi")]), + ), + ( + "requests".to_string(), + section([("cpu", "500m"), ("memory", "512Mi")]), + ), + ] + .into_iter() + .collect(), + }; + let spec = SandboxSpec { + template: Some(SandboxTemplate { + resources: Some(resources), + ..Default::default() + }), + ..Default::default() + }; + + validate_sandbox_spec("ok", &spec).unwrap(); + } + + #[test] + fn validate_sandbox_spec_rejects_non_string_cpu_resource() { + use prost_types::{Struct, Value, value::Kind}; + + let resources = Struct { + fields: std::iter::once(( + "limits".to_string(), + Value { + kind: Some(Kind::StructValue(Struct { + fields: std::iter::once(( + "cpu".to_string(), + Value { + kind: Some(Kind::NumberValue(1.0)), + }, + )) + .collect(), + })), + }, + )) + .collect(), + }; + let spec = SandboxSpec { + template: Some(SandboxTemplate { + resources: Some(resources), + ..Default::default() + }), + ..Default::default() + }; + + let err = validate_sandbox_spec("ok", &spec).unwrap_err(); + assert_eq!(err.code(), Code::InvalidArgument); + let details = err.get_error_details(); + assert_eq!( + details.bad_request().unwrap().field_violations[0].field, + "spec.template.resources.limits.cpu" + ); + assert!(err.message().contains("must be a string")); + } + + #[test] + fn validate_sandbox_spec_rejects_non_object_resource_section() { + use prost_types::{Struct, Value, value::Kind}; + + let resources = Struct { + fields: std::iter::once(( + "requests".to_string(), + Value { + kind: Some(Kind::NumberValue(1.0)), + }, + )) + .collect(), + }; + let spec = SandboxSpec { + template: Some(SandboxTemplate { + resources: Some(resources), + ..Default::default() + }), + ..Default::default() + }; + + let err = validate_sandbox_spec("ok", &spec).unwrap_err(); + assert_eq!(err.code(), Code::InvalidArgument); + let details = err.get_error_details(); + assert_eq!( + details.bad_request().unwrap().field_violations[0].field, + "spec.template.resources.requests" + ); + assert!(err.message().contains("must be an object")); + } + + #[test] + fn validate_sandbox_spec_rejects_empty_memory_resource() { + use prost_types::{Struct, Value, value::Kind}; + + let resources = Struct { + fields: std::iter::once(( + "limits".to_string(), + Value { + kind: Some(Kind::StructValue(Struct { + fields: std::iter::once(( + "memory".to_string(), + Value { + kind: Some(Kind::StringValue(String::new())), + }, + )) + .collect(), + })), + }, + )) + .collect(), + }; + let spec = SandboxSpec { + template: Some(SandboxTemplate { + resources: Some(resources), + ..Default::default() + }), + ..Default::default() + }; + + let err = validate_sandbox_spec("ok", &spec).unwrap_err(); + assert_eq!(err.code(), Code::InvalidArgument); + assert!(err.message().contains("must not be empty")); + } + #[test] fn validate_sandbox_spec_rejects_oversized_policy() { use openshell_core::proto::NetworkPolicyRule; diff --git a/docs/reference/sandbox-compute-drivers.mdx b/docs/reference/sandbox-compute-drivers.mdx index f328909ceb..92d4de0c47 100644 --- a/docs/reference/sandbox-compute-drivers.mdx +++ b/docs/reference/sandbox-compute-drivers.mdx @@ -121,7 +121,13 @@ the gateway uid can access it. Sandbox create supports `--cpu` and `--memory` for per-sandbox compute sizing. Docker and Podman apply them as runtime limits. Kubernetes applies them as both container requests and limits. The VM driver accepts the fields but currently -ignores them. +ignores them. The MXC driver rejects CPU and memory requests because its current +backends cannot enforce them. + +API clients that populate `SandboxTemplate.resources` directly must encode the +`cpu` and `memory` values under `limits` or `requests` as non-empty strings. +The gateway rejects malformed sections and non-string values with +`InvalidArgument` instead of forwarding them as opaque driver configuration. Sandbox create also accepts experimental driver-owned config through `--driver-config-json`. The value is a JSON object keyed by driver name. The diff --git a/proto/openshell.proto b/proto/openshell.proto index 458c87b026..fdccecd1bc 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -979,7 +979,8 @@ message SandboxTemplate { map annotations = 5; // Additional environment variables injected by the template. map environment = 6; - // Platform-specific compute resource requirements and limits. + // Platform-specific compute resource requirements and limits. Well-known + // cpu and memory entries under limits or requests must be non-empty strings. google.protobuf.Struct resources = 7; reserved 9; reserved "volume_claim_templates"; diff --git a/sdk/go/proto/openshellv1/openshell.pb.go b/sdk/go/proto/openshellv1/openshell.pb.go index 0a9455d742..a10c89f6a2 100644 --- a/sdk/go/proto/openshellv1/openshell.pb.go +++ b/sdk/go/proto/openshellv1/openshell.pb.go @@ -1746,7 +1746,8 @@ type SandboxTemplate struct { Annotations map[string]string `protobuf:"bytes,5,rep,name=annotations,proto3" json:"annotations,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` // Additional environment variables injected by the template. Environment map[string]string `protobuf:"bytes,6,rep,name=environment,proto3" json:"environment,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` - // Platform-specific compute resource requirements and limits. + // Platform-specific compute resource requirements and limits. Well-known + // cpu and memory entries under limits or requests must be non-empty strings. Resources *structpb.Struct `protobuf:"bytes,7,opt,name=resources,proto3" json:"resources,omitempty"` // Enable Kubernetes user namespace isolation (hostUsers: false). // When true, container UID 0 maps to a non-root host UID and capabilities diff --git a/skills/openshell-cli/SKILL.md b/skills/openshell-cli/SKILL.md index b44b748e50..3e53e91ed0 100644 --- a/skills/openshell-cli/SKILL.md +++ b/skills/openshell-cli/SKILL.md @@ -248,7 +248,7 @@ Key flags: - `--provider`: Attach configured credential providers for API keys, tokens, and other secrets (repeatable) - `--policy`: Custom policy YAML (otherwise uses built-in default or `OPENSHELL_SANDBOX_POLICY` env var) - `--gpu [COUNT]`: Request the driver's default GPU selection or a specific GPU count -- `--cpu`, `--memory`: Set per-sandbox compute sizing. Docker/Podman apply limits; Kubernetes applies matching requests and limits. +- `--cpu`, `--memory`: Set per-sandbox compute sizing. Docker/Podman apply limits; Kubernetes applies matching requests and limits; VM currently accepts but ignores them; MXC rejects them because its current backends cannot enforce them. - `--driver-config-json`: Pass experimental driver-specific sandbox configuration - `--template NAME`: Create from a named sandbox workload template. Conflicts with inline workload flags such as `--from`, `--gpu`, `--cpu`, `--memory`, `--env`, and `--driver-config-json`. - `--label KEY=VALUE`: Add labels for later selection (repeatable) From a749bc88c82896b5efb64caf4da1f9c29b17396b Mon Sep 17 00:00:00 2001 From: Shailendra Singh Date: Wed, 23 Sep 2026 13:52:34 -0700 Subject: [PATCH 21/42] docs(windows): add runtime architecture overview (#3575) * docs(windows): add runtime architecture overview Signed-off-by: Shailendra Singh * docs(windows): consolidate build documentation Signed-off-by: Shailendra Singh * docs(windows): address architecture review feedback Signed-off-by: Shailendra Singh --------- Signed-off-by: Shailendra Singh --- .../build-openshell-mxc-windows/SKILL.md | 8 +- .../build-openshell-mxc-windows/reference.md | 11 +- CONTRIBUTING.md | 51 +++- architecture/README.md | 46 +-- architecture/windows-msvc-build.md | 209 -------------- architecture/windows.md | 263 ++++++++++++++++++ crates/openshell-driver-mxc/README.md | 10 +- 7 files changed, 355 insertions(+), 243 deletions(-) delete mode 100644 architecture/windows-msvc-build.md create mode 100644 architecture/windows.md diff --git a/.agents/skills/build-openshell-mxc-windows/SKILL.md b/.agents/skills/build-openshell-mxc-windows/SKILL.md index 13ae8f19f8..a7d30f63f7 100644 --- a/.agents/skills/build-openshell-mxc-windows/SKILL.md +++ b/.agents/skills/build-openshell-mxc-windows/SKILL.md @@ -32,7 +32,8 @@ The Windows build lane is implemented by these tracked files: | `tasks/rust.toml`, `tasks/test.toml`, and `tasks/markdown.toml` | Windows routing for compiler-bearing checks, explicit Unix-only test skips, and Markdown dependency setup. | | `tasks/scripts/windows-msvc.ps1` | PowerShell wrapper that enters the Visual Studio developer environment and invokes Cargo. | | `.github/workflows/windows-msvc.yml` | Opt-in PR lint and test plus advisory main/manual cache seeding and dependent binary builds on native x64 and ARM64 runners. | -| `architecture/windows-msvc-build.md` | Design notes and validation contract. | +| `CONTRIBUTING.md` | Human-facing Windows build prerequisites and command summary. | +| `architecture/windows.md` | Stable Windows/MXC runtime and cross-architecture boundaries. | | `.agents/skills/build-openshell-mxc-windows/` | This skill and companion reference material. | Use the code that is already in the repo. Do not generate a parallel Windows @@ -120,6 +121,7 @@ The lane targets a Windows host with Visual Studio Build Tools and rustup. | Windows SDK | `where.exe rc.exe` from a Developer PowerShell | Install an SDK containing target libraries and ARM64 tools. | | Rust via rustup | `rustc --version` | Add each target being validated: `x86_64-pc-windows-msvc` and/or `aarch64-pc-windows-msvc`. The wrapper also adds the selected target. | | mise | `mise --version` | Used as a task runner only. | +| cargo-nextest | `cargo nextest --version` | Required by `windows:test:*` and `windows:ci`. Install the pinned version with `mise install --locked github:nextest-rs/nextest` before running tasks with `--skip-tools`. | | Git | `git --version` | Needed for checkout and sync work. | | PowerShell | `$PSVersionTable.PSVersion` | Windows PowerShell 5.1 works; PowerShell 7 is quieter with mise shell hooks. | @@ -261,8 +263,8 @@ crypto dependency builds. |---|---| | `windows:check:x64` | `cargo check --workspace` for `x86_64-pc-windows-msvc`, excluding unsupported Windows packages as top-level workspace targets. | | `windows:check:arm64` | `cargo check --workspace` for `aarch64-pc-windows-msvc`, with the same top-level exclusions. | -| `windows:build:x64` | Release-builds `openshell-gateway.exe` and `openshell.exe` for x64. | -| `windows:build:arm64` | Release-builds `openshell-gateway.exe` and `openshell.exe` for ARM64. | +| `windows:build:x64` | Release-builds `openshell-gateway.exe`, `openshell.exe`, and `openshell-supervisor-relay.exe` for x64. | +| `windows:build:arm64` | Release-builds `openshell-gateway.exe`, `openshell.exe`, and `openshell-supervisor-relay.exe` for ARM64. | | `windows:test:x64` | Runs native x64 workspace tests with `--no-fail-fast`, excluding unsupported Windows packages as top-level workspace targets. | | `windows:test:arm64` | Runs native ARM64 workspace tests with `--no-fail-fast` and the same package exclusions. Rejects non-ARM64 hosts. | | `windows:test:unsupported:x64` | Re-runs focused `openshell-gateway` tests for unsupported Windows driver behavior. | diff --git a/.agents/skills/build-openshell-mxc-windows/reference.md b/.agents/skills/build-openshell-mxc-windows/reference.md index bc278664dc..4ab0358d87 100644 --- a/.agents/skills/build-openshell-mxc-windows/reference.md +++ b/.agents/skills/build-openshell-mxc-windows/reference.md @@ -9,11 +9,18 @@ maintaining the existing build-only Windows MSVC lane. |---|---| | `tasks/windows.toml` | Mise task definitions for `windows:*`. | | `tasks/scripts/windows-msvc.ps1` | Visual Studio environment discovery, rustup target setup, Cargo invocation, logs, artifact report. | -| `.github/workflows/windows-msvc.yml` | PR/merge-queue lint and test plus main/manual cache seeding and dependent binary builds on native x64 and ARM64 runners. | -| `architecture/windows-msvc-build.md` | Human-readable design contract. | +| `.github/workflows/windows-msvc.yml` | Opt-in PR lint and test plus advisory main/manual cache seeding and dependent binary builds on native x64 and ARM64 runners. | +| `CONTRIBUTING.md` | Human-readable Windows build prerequisites and commands. | +| `architecture/windows.md` | Stable Windows/MXC runtime and cross-architecture boundaries. | ## Commands +Install the pinned test runner once before using test-bearing tasks: + +```powershell +mise install --locked github:nextest-rs/nextest +``` + Use `--skip-tools` for all Windows mise tasks: ```powershell diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 931d97500f..74beb5c6fe 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -361,14 +361,49 @@ just this crate does not require `LIBCLANG_PATH`: cargo build -p openshell-prover --target x86_64-pc-windows-msvc --features bundled-z3 ``` -### Windows full build - -To build the full set of Windows binaries, including `openshell-gateway.exe` -and `openshell.exe`, use the `windows:build:x64` mise task instead of a -single-crate `cargo build`. It downloads the pinned prebuilt Z3 release by default. A -full build also compiles crates that use `bindgen` (e.g. the MXC driver on -Windows), so it requires `libclang.dll`; if LLVM is not on the default search -path, set `LIBCLANG_PATH` to the directory containing `libclang.dll`: +### Windows build and validation + +Use the platform-native `windows:*` mise tasks for Windows MSVC development. +The lane supports x64 and ARM64 and builds `openshell-gateway.exe`, +`openshell.exe`, and `openshell-supervisor-relay.exe`. It does not enable +Docker, Kubernetes, Podman, or VM-backed execution on Windows. + +Install Visual Studio C++ Build Tools, a compatible Windows SDK, Rust through +rustup, mise, and the Visual Studio LLVM tools used by `bindgen`. Test-bearing +tasks also require the pinned `cargo-nextest` tool. Install it once through +mise: + +```powershell +mise install --locked github:nextest-rs/nextest +``` + +Run every task with `--skip-tools`; mise orchestrates the commands but does not +install the Windows compiler toolchain. + +| Task | Purpose | +|---|---| +| `windows:lint:` | Run Clippy for the Windows-supported workspace on the selected target architecture. | +| `windows:build:` | Build the three Windows release executables. | +| `windows:test:` | Run the workspace suite natively; the target must match the host architecture. | +| `windows:test:unsupported:` | Run the focused unsupported-driver contracts. | +| `windows:test:mxc-real:` | Run the probe-gated real-`wxc-exec` integration suite on the matching host. | +| `windows:artifacts` | Report sizes and SHA256 hashes for release artifacts. | +| `windows:ci` | Run the aggregate x64-host check, build, test, contract, and artifact workflow. | + +The task definitions in [`tasks/windows.toml`](tasks/windows.toml), the +PowerShell implementation in +[`tasks/scripts/windows-msvc.ps1`](tasks/scripts/windows-msvc.ps1), and the +hosted workflow in +[`.github/workflows/windows-msvc.yml`](.github/workflows/windows-msvc.yml) are +the executable sources of truth. See +[`architecture/windows.md`](architecture/windows.md) for the stable Windows/MXC +runtime and enforcement boundaries. + +To build x64 locally, use `windows:build:x64` instead of a single-crate Cargo +build. It downloads the pinned prebuilt Z3 release by default. A full build also +compiles crates that use `bindgen`, including the MXC driver, so it requires +`libclang.dll`. If LLVM is not on the default search path, set `LIBCLANG_PATH` +to the directory containing `libclang.dll`: ```powershell $env:LIBCLANG_PATH='C:\Program Files\Microsoft Visual Studio\2022\\VC\Tools\Llvm\x64\bin' diff --git a/architecture/README.md b/architecture/README.md index 706db67b1f..7f30c9a8b1 100644 --- a/architecture/README.md +++ b/architecture/README.md @@ -2,16 +2,17 @@ OpenShell runs autonomous AI agents in sandboxed environments with explicit policy, credential, identity, and network boundaries. The target architecture is -built around three stable runtime components: the **CLI**, the **Gateway**, and -the **Supervisor**. +built around stable user, control-plane, and runtime boundaries. Most runtimes +place the runtime boundary in the **Supervisor**; driver-controlled runtimes such +as Windows MXC enforce it through platform-native isolation and host services. The CLI, SDK, and TUI provide user-facing access. The gateway is the authenticated control plane: it owns API access, durable state, policy and settings delivery, provider configuration and attachments, and relay -coordination. The supervisor runs inside every sandbox workload and is the local -security boundary. It launches the agent as a restricted child process and -enforces policy where process identity, filesystem access, network egress, and -runtime credentials are visible. +coordination. In supervisor-based runtimes, the supervisor runs inside the +sandbox workload and launches the agent as a restricted child process. A +driver-controlled runtime owns the equivalent lifecycle and enforcement duties +without embedding the standard supervisor. Infrastructure-specific work sits behind integration boundaries. Compute, credentials, control-plane identity, and sandbox identity each have a driver or @@ -42,6 +43,7 @@ flowchart TB subgraph INFRA["Integrated Infrastructure"] RUNTIME["Docker / Podman / Kubernetes / VM"] + MXC["Windows MXC"] SECRETSTORE["Eg: Keychain / Secret Service / Vault / Kubernetes Secrets"] IDP["Eg: mTLS / OIDC / Local identity"] WORKLOADID["Eg: SPIFFE / Gateway-issued workload identity"] @@ -54,6 +56,11 @@ flowchart TB AGENT["Restricted agent process"] end + subgraph WINDP["Windows MXC Data Plane"] + MXCAGENT["MXC-isolated agent process"] + MXCPROXY["Host policy proxy (ProcessContainer only)"] + end + CLI -->|"gRPC / HTTP"| GW SDK -->|"gRPC / HTTP"| GW TUI -->|"gRPC / HTTP"| GW @@ -70,10 +77,13 @@ flowchart TB SIDENT -->|"gRPC / UDS"| SIDRV CDRV --> RUNTIME + CDRV --> MXC CRDRV --> SECRETSTORE CPIDRV --> IDP SIDRV --> WORKLOADID RUNTIME -->|"provisions workload"| SUP + MXC -->|"driver-controlled launch"| MXCAGENT + MXCAGENT -. "governed egress" .-> MXCPROXY SUP -->|"outbound control, config, logs, relay"| GW SUP -->|"spawn + restrict"| AGENT @@ -93,8 +103,8 @@ flowchart TB | Credentials subsystem | Logical provider and credential resolution. Secret storage and platform-native credential access belong to credentials drivers. | | Control-plane identity | Authentication and authorization for users, operators, and API clients. External identity verification belongs to identity drivers. | | Sandbox identity | Workload identity for supervisors and sandbox-to-sandbox authorization. Identity issuance or verification belongs to sandbox identity drivers. | -| Supervisor | Sandbox-local security boundary. It prepares isolation, fetches config, injects credentials, runs relay endpoints, starts the proxy, and launches restricted agent processes. | -| Policy proxy | Mandatory egress path for agent traffic. It enforces destination, binary identity, SSRF, TLS/L7, and endpoint-bound provider credential injection. | +| Supervisor | Sandbox-local security boundary for supervisor-based runtimes. It prepares isolation, fetches config, injects credentials, runs relay endpoints, starts the proxy, and launches restricted agent processes. Driver-controlled runtimes must enforce supported behavior and explicitly define or reject unsupported behavior. | +| Policy proxy | Mandatory path for policy-governed egress. It enforces destination, binary identity, SSRF, TLS/L7, and endpoint-bound provider credential injection. | ## Integrating with the Ecosystem @@ -111,10 +121,12 @@ operations. They should stay thin, preserve native behavior by default, and report platform lifecycle events back through the shared contracts. -The supervisor owns OpenShell sandbox semantics. Filesystem policy, process -privilege reduction, network proxying, endpoint-bound credential injection, -security logging, and gateway relay behavior should remain -consistent across runtimes. +The supervisor owns OpenShell sandbox semantics on supervisor-based runtimes. +Driver-controlled runtimes own the corresponding platform-specific semantics +and must explicitly define or reject policy they cannot enforce. Filesystem +policy, process privilege reduction, network proxying, endpoint-bound credential +injection, security logging, and gateway relay behavior should remain consistent +where the runtime advertises those capabilities. This keeps OpenShell usable in local single-player setups, Kubernetes deployments, VM-backed sandboxes, and future third-party environments. A new @@ -142,9 +154,11 @@ The compute-driver capability contract identifies whether a driver reports runtime readiness. Most drivers use the supervisor session model above. A driver that sets `driver_reports_runtime_readiness` may self-report readiness without a supervisor session. Every driver receives the canonical create-time -policy in `DriverSandboxSpec`; drivers without a supervisor use the existing -sandbox configuration API for later revisions. The Windows MXC driver reports -its own readiness and does not expose interactive connect or governed egress. +policy in `DriverSandboxSpec`; drivers without a supervisor may use the sandbox +configuration API when they advertise live-update support. The Windows MXC +driver reports its own readiness, does not support live updates, and does not +expose interactive connect. ProcessContainer can establish governed egress at +create time; IsolationSession rejects governed network policies. The gateway delivers desired state; the sandbox applies it locally. Policy, settings, provider attachments, and credential bindings flow from the gateway @@ -173,7 +187,7 @@ that crate's `README.md`. | [Compute Runtimes](compute-runtimes.md) | Docker, Podman, Kubernetes, VM, sandbox images, and runtime-specific responsibilities. | | [Build](build.md) | Build artifacts, CI/E2E, docs site validation, and release packaging. | | [Google Vertex AI Provider](google-vertex-ai-provider.md) | Implementation reference for the `google-vertex-ai` provider, from CLI through gateway to sandbox. | -| [Windows MSVC Build](windows-msvc-build.md) | Build-only native Windows MSVC lane (x64/ARM64) and unsupported-runtime behavior on Windows. | +| [Windows](windows.md) | Windows/MXC runtime architecture, policy enforcement, networking, relay, audit, and trust boundaries. | ## `rfc/` vs `architecture/` diff --git a/architecture/windows-msvc-build.md b/architecture/windows-msvc-build.md deleted file mode 100644 index cd5fbd0615..0000000000 --- a/architecture/windows-msvc-build.md +++ /dev/null @@ -1,209 +0,0 @@ -# Windows MSVC Build Design - -This page records the design decisions for the native Windows MSVC build lane. -It provides the native build lane and validates the in-process MXC compute -driver. It does not make Windows a Docker, Kubernetes, Podman, or VM runtime host. - -## Goals - -- Compile the OpenShell gateway and CLI for `x86_64-pc-windows-msvc` and `aarch64-pc-windows-msvc`. -- Keep the Linux and macOS build paths unchanged. -- Preserve gateway configuration parsing for all existing compute driver names. -- Build and test the in-process MXC driver on supported Windows hosts. -- Use the ordinary in-process compute-driver composition path; MXC receives the - canonical sandbox policy through `DriverSandboxSpec` and advertises that it - reports runtime readiness. -- Return clear unsupported errors when a Windows gateway is configured to use Docker, Kubernetes, Podman, or VM. -- Keep dedicated `windows:*` validation tasks while allowing the repository-wide - `pre-commit` task to delegate compiler-bearing Rust checks to the native - Windows MSVC environment. - -## Non-Goals - -- Do not support Docker Desktop, WSL, Hyper-V, Podman machine, Podman Desktop, Kubernetes, or VM-backed sandbox execution on Windows. -- Do not ship Windows standalone binaries for Docker, Kubernetes, Podman, or VM drivers. -- Do not implement named-pipe driver IPC, Windows services, MSI packaging, Credential Manager integration, or DPAPI integration in this lane. - -## Unsupported Driver Strategy - -The gateway composition crate installs platform-specific registration stubs on -Windows. These registrations preserve config-file selection and reject -unsupported drivers with a clear error without depending on their runtime -crates. - -Each stub follows its corresponding `compute-driver-*` Cargo feature. -`compute-driver-mxc` independently links and registers MXC, so a gateway built -with only that feature has only the MXC registration. The default -`in-tree-compute-drivers` alias enables all five features and preserves the -existing MXC plus unsupported-driver registrations. The focused Windows -contract tasks cover default, protocol-only, MXC-only, Docker-stub-only, and -MXC plus Docker-stub compositions. - -The Windows lane does not build, release, package, or smoke-test standalone -driver binaries for Docker, Kubernetes, Podman, or VM. Those binaries are Linux -or macOS deliverables only. - -The Kubernetes Secrets and Vault packages are also excluded as top-level -Windows workspace targets because their standalone driver binaries use Unix -domain sockets. Their libraries remain in the gateway dependency graph, so the -gateway's credential-driver configuration and in-process behavior still compile -on Windows. - -| Driver | Windows build behavior | Runtime behavior | -|---|---|---| -| Docker | Driver crate excluded; gateway registration stub retained. | Gateway construction returns unsupported. | -| Kubernetes | Driver crate excluded; gateway registration stub retained. | Gateway construction returns unsupported. | -| Podman | Driver crate excluded; gateway registration stub retained. | Gateway construction returns unsupported. | -| VM | Driver crate excluded; gateway registration stub retained. | Gateway construction returns unsupported. | -| MXC | Driver links into the native gateway and runs in Windows validation. | `process_container` is default-deny; grant-only `isolation_session` requires explicit configuration. | - -This keeps Windows behavior explicit without carrying runtime dependencies or -creating misleading Windows driver artifacts. - -## Mise Lane - -The GitHub Actions workflow runs Clippy for the Windows-supported workspace and -e2e crates plus Rust tests for pull-request mirror branches labeled `test:windows`. -Merge queues do not run this workflow. On -pushes to `main`, a cache-seed job runs the same lint and test commands before a -dependent job builds the release binaries. Manual dispatches exercise the same -seed-then-build path. The binaries remain CI validation artifacts and are not -uploaded or published. - -Each job restores and saves a dedicated Rust cache containing the Cargo -registry and dependency build artifacts, including artifacts from failed runs. -The seed job and pull-request job use the same Cargo target and sccache -namespaces. The release build waits for the seed job, then restores its newly -warmed cache rather than compiling concurrently from a cold cache. - -Windows validation is exposed through `tasks/windows.toml`: - -| Task | Purpose | -|---|---| -| `windows:check:x64` | Check the x64 MSVC gateway/CLI build graph. | -| `windows:check:arm64` | Check the ARM64 MSVC gateway/CLI build graph. | -| `windows:lint:x64` | Run Clippy over the Windows-supported workspace for x64 MSVC. | -| `windows:lint:arm64` | Run Clippy over the Windows-supported workspace for ARM64 MSVC. | -| `windows:build:x64` | Build release x64 `openshell-gateway.exe` and `openshell.exe`. | -| `windows:build:arm64` | Build release ARM64 `openshell-gateway.exe` and `openshell.exe`. | -| `windows:test:x64` | Run native x64 workspace tests with the nextest CI profile and server test support, while excluding unsupported Windows packages as top-level test targets. | -| `windows:test:arm64` | Run the same suite natively on ARM64. | -| `windows:test:unsupported:x64` | Run focused gateway-composition tests for unsupported driver contracts. | -| `windows:test:unsupported:arm64` | Run the same focused contracts natively on ARM64. | -| `windows:test:mxc-real:arm64` | Run the native real-MXC developer suite; missing hardware remains skip-safe. | -| `windows:test:mxc-gb300:arm64` | Run the required real-MXC subset on native ARM64 and fail on any required skip. | -| `windows:qualify:mxc:gb300:contract` | Validate the static NVBug 6643699 scope matrix on any development host. | -| `windows:qualify:mxc:gb300` | Execute the complete native GB300 ARM64 gate and produce hash-bound evidence. | -| `windows:ci` | Run check, build, test, unsupported-contract tests, and artifact reporting. | - -The Windows tasks call `tasks/scripts/windows-msvc.ps1`. The wrapper discovers -Visual Studio's `VsDevCmd.bat` with `vswhere` or by enumerating installed -release directories, validates the requested compiler and ARM64 Spectre -libraries, adds rustup MSVC targets, preserves an inherited `RUSTC_WRAPPER` -when the command is available, and keeps build artifacts under the normal -Cargo target tree. If the wrapper command is unavailable, it warns and clears -the setting so local builds continue without compiler caching. -On Windows, the generic `rust:check`, `rust:lint`, and `test:rust` tasks call -the same wrapper with the host-native MSVC target. The wrapper preserves the -Unix Cargo commands on Linux and macOS, excludes unsupported Windows runtime -packages, and runs the server test-support suite separately. Windows Clippy -continues to deny all warnings except unused imports, dead code, and unused -async functions caused by cfg-gated Windows stubs. Repository-wide pre-commit -skips only Linux-specific installer, build-environment shell-helper, and -packaging-asset tests; its -cross-platform Python, Markdown, license, and documentation checks still run. -Test tasks require the Rust target architecture to match the Windows host, so -an ARM64 test result is native coverage rather than x64 emulation coverage. -By default it enables the `z3-sys` prebuilt-release feature and pins Z3 4.16.0. -On a clean target directory, `z3-sys` downloads the official static library for -the selected Windows architecture instead of compiling Z3 through -CMake/MSBuild. GitHub Actions supplies its read-only workflow token for the -release lookup, and the Cargo target cache preserves the extracted library for -subsequent runs. When `Z3_LIBRARY_PATH_OVERRIDE` points at a directory -containing `libz3.lib`, the wrapper uses that system Z3 instead and requires -`Z3_SYS_Z3_HEADER` to point at the full path to `z3.h`. Local clean builds use -the unauthenticated GitHub API unless `READ_ONLY_GITHUB_TOKEN` is set. - -GitHub Actions layers the Cargo target cache with sccache's GitHub Actions -backend. The target cache lets Cargo skip intact dependency builds; sccache -recovers cacheable Rust compiler outputs when source changes invalidate part of -that target tree. CI enables client-side mode and normalizes the checkout root -for stable compiler cache keys. The target-cache action runs its metadata step -with `RUSTC_WRAPPER` cleared so cache maintenance does not depend on sccache. -Hosted jobs use an isolated `RUSTUP_HOME` containing the pinned toolchain so -unused toolchains in runner images cannot change the target-cache restore key. - -The lane uses `mise run --skip-tools windows:*` because Windows Rust comes from -rustup and linking comes from Visual Studio Build Tools. Mise orchestrates the -tasks; it does not own the Windows toolchain. - -ARM64 validation requires the Visual Studio ARM64 MSVC tools, ARM64 -Spectre-mitigated libraries, host-native Clang tools, CMake tools, and an -ARM64-capable Windows SDK. Clang provides `libclang.dll` for `bindgen` and -`clang-cl.exe` for ARM64 crypto dependencies. During x64-to-ARM64 check/build, -the wrapper discovers and adds the Visual Studio-bundled Ninja to `PATH` for -native dependencies. Z3 uses the official prebuilt ARM64 static library, so it -does not inherit compiler settings from those native dependencies. Artifact -hashing uses .NET SHA256 directly because module autoloading in the -mise-launched Windows PowerShell process is not guaranteed. - -The wrapper defaults Cargo compilation to four jobs. Set -`OPENSHELL_WINDOWS_BUILD_JOBS` to a positive integer to override that limit. -A host-local mutex serializes wrapper-owned Cargo commands so concurrent -pre-commit tasks do not multiply the compiler process count. -The wrapper does not set `CL` or `_CL_`: those variables are also consumed by -`clang-cl`, where MSVC's `/MP` option can be interpreted as an input file and -break ARM64 crypto dependency builds. - -## CI Shape - -The x64 GitHub Actions jobs run on `windows-2025`; native ARM64 jobs run on -`windows-11-arm`. Pull-request mirrors labeled `test:windows` execute the matching -architecture-specific tasks: - -```powershell -mise run --skip-tools windows:lint: -mise run --skip-tools windows:test: -``` - -Pushes to `main` and manual dispatches first seed the shared caches with those -same lint and test commands. Both seed and build jobs use job-level -`continue-on-error: true`, so Windows job failures do not fail the main/manual -workflow. Opt-in PR jobs still report failures normally. After the seed job -finishes, a separate job executes: - -```powershell -mise run --skip-tools windows:build: -``` - -The server test-support suite includes the unsupported-driver contract test, so -CI does not run the focused test task a second time. The focused task remains -available for local diagnosis. - -The hosted workflow uses architecture-specific cache namespaces and does not -cache Cargo-installed binaries. - -The local aggregate `windows:ci` task can still cross-build ARM64 on an x64 -host. Hosted tests use architecture-matched runners, so ARM64 test results are -native rather than emulated coverage. - -## Validation Contract - -The generic real-MXC lanes are diagnostics and deliberately remain skip-safe. -They cannot establish hardware qualification when a prerequisite or backend is -absent. GB300 release evidence uses the separate fail-closed contract in -[`crates/openshell-driver-mxc/qualification/`](../crates/openshell-driver-mxc/qualification/README.md). -That matrix separates native Windows ARM64 evidence from x64-only NemoClaw and -Windows lanes, and records all hardware-dependent exclusions explicitly. - -A successful Windows build report should include: - -- x64 and ARM64 `cargo check` status. -- x64 and ARM64 release build status for `openshell-gateway.exe` and `openshell.exe`. -- x64 test summary. -- Native ARM64 test summary when validation runs on an ARM64 host. -- Focused unsupported-driver contract test status. -- Artifact size and SHA256 for each Windows binary. - -Warnings from Linux-only dead code are acceptable in the native Windows lane when -they come from code paths intentionally disabled on Windows. diff --git a/architecture/windows.md b/architecture/windows.md new file mode 100644 index 0000000000..bcbe9b6220 --- /dev/null +++ b/architecture/windows.md @@ -0,0 +1,263 @@ +# Windows + +OpenShell runs natively on Windows as an MSVC-built CLI and gateway with the +Microsoft MXC compute driver linked into the gateway process. MXC is a +driver-controlled runtime: it applies the canonical policy, launches the agent, +and reports readiness without running the standard `openshell-sandbox` +supervisor inside the workload. + +This document describes the stable Windows boundaries. See +[Gateway](gateway.md) for control-plane behavior, +[Compute Runtimes](compute-runtimes.md) for the shared driver contract, +[Security Policy](security-policy.md) for the canonical policy model, and the +[MXC driver README](../crates/openshell-driver-mxc/README.md) for configuration +and operational details. + +## Runtime Architecture + +The Windows CLI uses the same public gateway API as other platforms. The +gateway owns authentication, persistence, effective-policy composition, +provider attachment, and public sandbox status. The in-process +`MxcComputeBackend` owns MXC configuration, `wxc-exec` lifecycle, host-side +network enforcement, relay state, and runtime watch events. + +The following diagram shows the Windows-specific control and data paths. The +MXC isolation boundary contains the agent and optional supervisor relay; the +gateway-side proxy and ETW consumer remain on the host. + +```mermaid +flowchart LR + CLI["OpenShell CLI"] -->|"gRPC / HTTP"| GW["OpenShell gateway"] + + subgraph HOST["Windows host"] + GW -->|"in-process ComputeDriver"| DRV["MXC driver"] + DRV -->|"ContainerConfig + lifecycle commands"| WXC["wxc-exec"] + PROXY["Per-sandbox CONNECT proxy"] + ETW["Sandboxing ETW consumer"] -->|"attributed events"| OCSF["OCSF trail"] + GW -->|"dynamic forward request"| DRV + end + + subgraph MXC["MXC isolation boundary"] + PC["ProcessContainer"] + ISO["IsolationSession"] + RELAY["openshell-supervisor-relay"] + AGENT["Agent process"] + PC --> AGENT + PC -. "optional wrapper" .-> RELAY + RELAY --> AGENT + ISO --> AGENT + ISO -. "optional wrapper" .-> RELAY + end + + WXC --> PC + WXC --> ISO + PC -. "ProcessContainer governed egress only" .-> PROXY + DRV <-->|"inherited stdin/stdout control channel"| RELAY + WXC -. "OS Sandboxing events" .-> ETW +``` + +`crates/openshell-driver-mxc/src/grpc.rs` adapts the shared `ComputeDriver` +service to the in-process backend. This is a contract boundary, not a network +hop. MXC requests no additional gateway callback listener and does not +authenticate a sandbox principal because there is no standard supervisor +session. + +## Backend Semantics + +The selected backend changes both lifecycle and enforceable policy. Backend +selection belongs to gateway startup configuration, not to an individual +sandbox request. + +| Property | `process_container` | `isolation_session` | +|---|---|---| +| Runtime model | One-shot AppContainer process; default backend | Persistent MXC session used to run one configured process | +| Driver lifecycle | Launch and monitor `wxc-exec` | `provision` -> `start` -> `exec`; stop/delete issue `stop` and `deprovision` | +| Filesystem | Read-only/read-write grants with default-deny behavior | Explicit grant-only compatibility mode; not equivalent to ProcessContainer default deny | +| Portable UI policy | Supported completely | Every explicit `ui` section is rejected before provisioning | +| Governed network policy | Supported through the host proxy when enabled | Rejected because the backend cannot enforce the loopback-only proxy path; without an explicit network policy, the backend retains MXC's default-allow egress | +| Supervisor relay and dynamic forwarding | Optional | Optional | + +Neither backend implements interactive `sandbox connect` or interactive exec +through the standard supervisor protocol. The MXC `StartSandbox` operation is +also unsupported: create performs the initial launch, and a stopped workload is +not restarted in place. + +## Create and Policy Flow + +The gateway composes the effective `SandboxPolicy` before calling the driver and +includes it in `DriverSandboxSpec.policy`. The MXC driver validates and maps that +typed policy before it publishes a registry entry or invokes `wxc-exec`, so an +unsupported policy cannot leave a partially provisioned sandbox. + +The create path is: + +1. The gateway resolves the canonical command, environment, working directory, + effective policy, and attached provider state. +2. The gateway checks driver capabilities. In particular, it rejects an + explicit UI policy when the configured MXC backend does not advertise full + UI support. +3. `MxcComputeBackend::validate_sandbox_create` validates MXC-specific workload + inputs and calls `EmbeddedPolicyMapper`. +4. The mapper produces the MXC filesystem, UI, and network configuration. Any + loss item classified as an error rejects creation; warnings and informational + items do not block mapping. +5. The driver starts any required host proxy, stages public proxy CA material, + launches `wxc-exec`, and publishes lifecycle events through the shared watch + stream. + +The production mapping in +`crates/openshell-driver-mxc/src/policy.rs` and +`crates/openshell-driver-mxc/src/policy_map/` treats policy areas as follows: + +| Policy area | Windows enforcement | +|---|---| +| Filesystem | `read_only` and `read_write` become MXC path grants. `include_workdir` adds the resolved working directory as read-write. The mapper normalizes separators but does not translate Linux-rooted locations into Windows paths. ProcessContainer supplies the default-deny boundary; IsolationSession supplies only the requested grants. | +| Network | An explicit network policy requires governed egress on ProcessContainer. The mapper gives MXC loopback-only egress and returns the complete network policy to a per-sandbox host CONNECT proxy. MXC denies direct Internet access; the proxy evaluates destinations, ports, TLS/L7 rules, credential bindings, and binary rules against the configured agent command as its static process identity. Network middleware configuration is rejected because the host proxy does not receive the gateway middleware registry. IsolationSession rejects explicit network policy; without one, it retains MXC's default-allow egress. | +| UI | ProcessContainer maps graphical UI, directional clipboard access, and input injection into MXC's top-level `ui` object. An absent section maps to the restrictive UI posture. Within an explicit section, omitted fields deny. IsolationSession rejects even an empty explicit section. | +| Process | MXC supplies the Windows process-isolation boundary, but the mapper has no portable equivalent for `run_as_user` or `run_as_group`; callers must not treat those fields as enforced Windows identity controls. The canonical command, environment, and working directory are launch inputs rather than process-policy grants. | +| Landlock | MXC has no equivalent for the Linux Landlock compatibility mode, including `hard_requirement`. The mapper reports a non-blocking warning; Windows filesystem assurance comes from the selected MXC backend's native semantics, not Landlock. | + +The driver reports `supports_live_policy_updates = false`. The gateway therefore +rejects mutations that would change a running MXC sandbox's effective policy or +provider bindings before persistence. Filesystem, UI, network, and credential +changes require sandbox recreation. + +## Governed Egress and Credentials + +Governed egress uses a split enforcement model. MXC blocks direct Internet +traffic and permits host loopback; a unique authenticated proxy listener holds +the full OpenShell network policy. The driver injects standard proxy variables +for proxy-aware clients and stages only public CA certificates under an +authorized sandbox path. The ephemeral CA private key remains in host-proxy +memory. + +The loopback grant is broader than the proxy listener: a governed sandbox can +reach other host services bound to loopback. Per-sandbox proxy credentials +prevent another sandbox from using the OpenShell proxy, but they do not isolate +unrelated host-loopback services or authenticate individual processes within +one sandbox. + +Two gateway-wide ProcessContainer compatibility settings can deliberately +broaden this boundary. `pc_network_allow` permits unrestricted outbound TCP, and +`pc_allow_local_network` enables MXC local-network access. These are operator +configuration choices, not sandbox policy grants, and they must not be treated +as policy-governed egress. + +Provider credentials cross a narrower boundary than ordinary environment +variables: + +1. The gateway stages a create-scoped `ProviderCredentialState` in an + in-process handoff keyed by sandbox ID. +2. The driver consumes that state exactly once, including on validation + failure, and places only revision-scoped placeholders and non-secret provider + environment in the MXC child configuration. +3. The host proxy retains resolver state and substitutes credentials only for + endpoints authorized by the attached provider profile and policy. + +The gateway rejects expiring static provider credentials because MXC has no +live credential refresh channel. Dynamic token grants remain request-time +host-proxy operations. +Provider state that requires credential resolution also requires governed +egress; creation fails closed when the proxy path is unavailable. + +## Relay and Dynamic Forwarding + +Either MXC backend can launch `openshell-supervisor-relay` as a generic wrapper +around the configured agent. The driver computes this wrapping before it enters +the backend-specific lifecycle. The driver and relay exchange newline-delimited +JSON over inherited stdin and stdout, so control traffic does not require +sandbox network access. Startup validates the relay protocol version, transfers +the command and environment after the relay announces readiness, and waits for +the configured target port before publishing runtime readiness. + +Dynamic `openshell forward service` requests use a dedicated MXC path because +there is no `ConnectSupervisor` session. The gateway calls the driver's +`ForwardSink`; the driver creates an authenticated host-loopback listener and +multiplexes `forward_open`, `forward_read`, `forward_write`, and +`forward_close` operations over the inherited control channel. A fresh nonce +authenticates each host-side forward. This capability does not add interactive +shell or general exec support. + +The implementation boundary spans +`crates/openshell-driver-mxc/src/control_channel.rs`, +`crates/openshell-driver-mxc/src/relay.rs`, and +`crates/openshell-supervisor-relay/`. + +## Readiness, Lifecycle, and Persistence + +MXC advertises `driver_reports_runtime_readiness = true`. The gateway therefore +accepts the driver's `Ready=True` event without waiting for a supervisor +session. Direct launches on either backend become ready after the process +starts; relay-wrapped launches additionally require the relay and +target-readiness handshakes. + +The driver serializes startup against stop and delete with a per-sandbox +lifecycle gate. Stop and delete signal the owned process and wait for confirmed +termination before reporting success. IsolationSession delete also +deprovisions the MXC session. Process exit, relay failure, and MXC invocation +errors produce watch or platform events that the gateway folds into persisted +public status. Policy mapping failures reject the create request before the +driver publishes a registry entry, so they do not produce lifecycle events. + +Runtime ownership is not durable. The registry, process handles, proxy handles, +relay channels, and IsolationSession IDs live in gateway memory. A restarted +gateway cannot recover or reconcile an existing MXC workload, so Windows MXC +does not provide restart durability. + +## Audit Boundary + +When enabled, `crates/openshell-driver-mxc/src/etw_consumer.rs` starts a +gateway-owned real-time session for the Windows Sandboxing ETW provider. The +consumer attributes events to a sandbox using the driver-owned `wxc-exec` PID +and kernel process start key, then follows identity, activity, and correlation +links emitted by the provider. It never uses command text as ownership evidence; +records without matching generation evidence remain unattributed and are +dropped after the bounded late-event window. + +The ETW callback copies records into a bounded non-blocking queue. Overflow, +unattributed records, and a session that observes sandbox activity but no +provider events generate explicit coverage-gap warnings or findings. Structured +events flow through the shared OCSF builders. Command-line data and credentials +must not appear in OCSF fields, messages, or raw ETW diagnostics. The optional +gateway-local JSONL sink is specific to the Windows/MXC path; see +[Security Policy](security-policy.md#security-logging) for the logging contract. + +## Supported and Unsupported Runtime Surface + +Native Windows gateway builds link the MXC compute driver. Docker, Podman, +Kubernetes, and VM compute features install explicit Windows rejection stubs; +they do not silently fall back to an unisolated runtime. MXC also rejects GPU +requests and `agent_socket_path` because it has neither GPU integration nor the +standard in-sandbox supervisor. + +The Windows release-profile build lane produces `openshell-gateway.exe`, +`openshell.exe`, and `openshell-supervisor-relay.exe` for x64 and ARM64 as +validation artifacts; the hosted workflow does not publish them. An x64 host +can cross-check and cross-build ARM64, but native runtime tests and MXC +qualification must execute on the matching architecture. See +[Windows build and validation](../CONTRIBUTING.md#windows-build-and-validation) +for contributor prerequisites and commands. + +## Validation and Qualification + +Windows validation separates source correctness from host capability: + +- Mapper parity and matrix tests validate deterministic policy translation and + fail-closed loss handling without requiring MXC. +- The architecture-specific `windows:*` tasks check, lint, build, and run + workspace and unsupported-driver contract tests for x64 and ARM64. +- Mock MXC E2E validates gateway, CLI, driver, lifecycle, and policy wiring but + is not evidence of OS enforcement. +- Real-`wxc-exec` tests validate the installed schema and selected filesystem, + UI, network, and lifecycle behavior. A probe-gated skip is useful + diagnostic output, not qualification evidence. +- Strict platform qualification requires native architecture, a live required + backend, policy E2E scenarios, workload scenarios, and hash-bound artifacts. + The machine-validated contract lives in the + [MXC qualification directory](../crates/openshell-driver-mxc/qualification/README.md). +- Workload qualification scenarios exercise relay behavior, including dynamic + forwarding, on the backends selected by the scenario. + +The [MXC driver README](../crates/openshell-driver-mxc/README.md#real-mxc-test-lane) +defines the test tiers, host prerequisites, and qualification entry points. +These checks validate only the runtime available on the tested host. diff --git a/crates/openshell-driver-mxc/README.md b/crates/openshell-driver-mxc/README.md index 7c9d5c08aa..078157e724 100644 --- a/crates/openshell-driver-mxc/README.md +++ b/crates/openshell-driver-mxc/README.md @@ -49,11 +49,11 @@ backend = "process_container" default_configuration_id = "composable" pc_least_privilege = false pc_capabilities = [] -# processContainer only: launch openshell-supervisor-relay instead of -# the per-sandbox command directly, giving the driver a control -# channel into the sandbox (launch handshake, dynamic `openshell forward -# service` bridging). target_port is the launched command's own listening -# port; 0 disables spawner wrapping (default -- the command runs directly). +# Either backend: launch openshell-supervisor-relay instead of the per-sandbox +# command directly, giving the driver a control channel into the sandbox +# (launch handshake, dynamic `openshell forward service` bridging). target_port +# is the launched command's own listening port; 0 disables spawner wrapping +# (default -- the command runs directly). pc_relay_spawner_path = "" pc_relay_target_port = 0 # processContainer only: env-inheritance tier for the launched process From 3451e72700fa585ba4cdb6edd3c61fc738d1a9b3 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas <34834085+prekshivyas@users.noreply.github.com> Date: Mon, 28 Sep 2026 08:45:16 -0700 Subject: [PATCH 22/42] docs(mxc): correct isolation filesystem support (#3760) Signed-off-by: Prekshi Vyas --- architecture/windows.md | 4 ++-- crates/openshell-driver-mxc/README.md | 5 +++-- crates/openshell-driver-mxc/examples/mxc-gateway.toml | 4 ++-- crates/openshell-driver-mxc/src/driver.rs | 4 ++-- crates/openshell-driver-mxc/src/mxc.rs | 8 ++++---- 5 files changed, 13 insertions(+), 12 deletions(-) diff --git a/architecture/windows.md b/architecture/windows.md index bcbe9b6220..7a14422302 100644 --- a/architecture/windows.md +++ b/architecture/windows.md @@ -72,7 +72,7 @@ sandbox request. |---|---|---| | Runtime model | One-shot AppContainer process; default backend | Persistent MXC session used to run one configured process | | Driver lifecycle | Launch and monitor `wxc-exec` | `provision` -> `start` -> `exec`; stop/delete issue `stop` and `deprovision` | -| Filesystem | Read-only/read-write grants with default-deny behavior | Explicit grant-only compatibility mode; not equivalent to ProcessContainer default deny | +| Filesystem | Read-only/read-write grants with default-deny behavior | OpenShell filesystem-policy grants are unsupported; MXC rejects non-empty read-only/read-write grants | | Portable UI policy | Supported completely | Every explicit `ui` section is rejected before provisioning | | Governed network policy | Supported through the host proxy when enabled | Rejected because the backend cannot enforce the loopback-only proxy path; without an explicit network policy, the backend retains MXC's default-allow egress | | Supervisor relay and dynamic forwarding | Optional | Optional | @@ -111,7 +111,7 @@ The production mapping in | Policy area | Windows enforcement | |---|---| -| Filesystem | `read_only` and `read_write` become MXC path grants. `include_workdir` adds the resolved working directory as read-write. The mapper normalizes separators but does not translate Linux-rooted locations into Windows paths. ProcessContainer supplies the default-deny boundary; IsolationSession supplies only the requested grants. | +| Filesystem | On ProcessContainer, `read_only` and `read_write` become MXC path grants, and `include_workdir` adds the resolved working directory as read-write. The mapper normalizes separators but does not translate Linux-rooted locations into Windows paths. ProcessContainer supplies the default-deny boundary. IsolationSession cannot accept non-empty filesystem grants; without them, its backend defaults determine filesystem visibility. | | Network | An explicit network policy requires governed egress on ProcessContainer. The mapper gives MXC loopback-only egress and returns the complete network policy to a per-sandbox host CONNECT proxy. MXC denies direct Internet access; the proxy evaluates destinations, ports, TLS/L7 rules, credential bindings, and binary rules against the configured agent command as its static process identity. Network middleware configuration is rejected because the host proxy does not receive the gateway middleware registry. IsolationSession rejects explicit network policy; without one, it retains MXC's default-allow egress. | | UI | ProcessContainer maps graphical UI, directional clipboard access, and input injection into MXC's top-level `ui` object. An absent section maps to the restrictive UI posture. Within an explicit section, omitted fields deny. IsolationSession rejects even an empty explicit section. | | Process | MXC supplies the Windows process-isolation boundary, but the mapper has no portable equivalent for `run_as_user` or `run_as_group`; callers must not treat those fields as enforced Windows identity controls. The canonical command, environment, and working directory are launch inputs rather than process-policy grants. | diff --git a/crates/openshell-driver-mxc/README.md b/crates/openshell-driver-mxc/README.md index 078157e724..c4f1bfefdd 100644 --- a/crates/openshell-driver-mxc/README.md +++ b/crates/openshell-driver-mxc/README.md @@ -21,7 +21,7 @@ it does not implement the Linux `ConnectSupervisor` protocol. | Capability | MXC driver | |---|---| -| Filesystem policy | Read-only/read-write grants come only from `SandboxPolicy`. `process_container` enforces default-deny; `isolation_session` is an explicit grant-only compatibility mode. | +| Filesystem policy | Read-only/read-write grants come only from `SandboxPolicy`. `process_container` enforces them with default-deny behavior. `isolation_session` does not support OpenShell filesystem-policy grants; MXC rejects non-empty grants during provisioning. | | UI policy | `process_container` advertises complete support and maps portable graphical UI, clipboard-direction, and input-injection controls to MXC; omitted fields inside an explicit section deny. `isolation_session` advertises no support, so the gateway rejects any explicit section before provisioning. | | Network policy | With `egress_proxy = true` on `process_container`, an explicit `network_policies` rule activates MXC 0.8 loopback-only egress plus the full policy enforced by a per-sandbox OpenShell host CONNECT proxy. The driver injects proxy environment variables for proxy-aware clients; direct Internet access remains denied by MXC. A policy without network rules does not activate the proxy. Otherwise rejected synchronously. `isolation_session` remains fail-closed. | | Provider credentials | The child receives revision-scoped placeholders and non-secret provider environment only. The per-sandbox host proxy retains the resolver and substitutes credentials only for their bound endpoints. | @@ -44,7 +44,8 @@ Gateway configuration contains only host runtime settings: ```toml [openshell.drivers.mxc] wxc_exec_path = "C:\\path\\to\\wxc-exec.exe" -# Default: process_container. isolation_session is grant-only and opt-in. +# Default: process_container. isolation_session is opt-in and does not support +# OpenShell filesystem-policy grants. backend = "process_container" default_configuration_id = "composable" pc_least_privilege = false diff --git a/crates/openshell-driver-mxc/examples/mxc-gateway.toml b/crates/openshell-driver-mxc/examples/mxc-gateway.toml index 601b66bec6..99e1780d50 100644 --- a/crates/openshell-driver-mxc/examples/mxc-gateway.toml +++ b/crates/openshell-driver-mxc/examples/mxc-gateway.toml @@ -16,8 +16,8 @@ version = 2 wxc_exec_path = "C:\\mxc\\wxc-exec.exe" # process_container is the default because AppContainer enforces default-deny -# filesystem access. isolation_session is an explicit grant-only compatibility -# mode and does not deny access to paths omitted from the sandbox policy. +# filesystem access. isolation_session does not support OpenShell filesystem- +# policy grants; MXC rejects non-empty grants during provisioning. backend = "process_container" # process_container only: request a Less-Privileged AppContainer. diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index 19a5d82790..cccb77bd47 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -150,8 +150,8 @@ async fn wait_for_target_listener(port: u16) -> std::io::Result<()> { /// Which MXC backend the driver targets. /// /// - `IsolationSession`: persistent, attachable session -/// (provision → start → exec → stop → deprovision). Grant-only filesystem -/// policy — it has no deny primitive and is NOT default-deny. +/// (provision → start → exec → stop → deprovision). Does not support +/// OpenShell filesystem-policy grants; backend defaults determine visibility. /// - `ProcessContainer` (default): one-shot `AppContainer`. Genuinely default-deny: a /// write to any ungranted path is denied by the OS. No persistent session. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] diff --git a/crates/openshell-driver-mxc/src/mxc.rs b/crates/openshell-driver-mxc/src/mxc.rs index dd65b06358..e0e1598b1f 100644 --- a/crates/openshell-driver-mxc/src/mxc.rs +++ b/crates/openshell-driver-mxc/src/mxc.rs @@ -53,10 +53,10 @@ fn mock_grants() -> &'static Mutex>> { /// Filesystem shares for the sandbox. /// -/// `isolation_session` honors `readwrite`/`readonly` (grant-only — it has no -/// deny primitive). `processContainer` additionally honors `denied_paths` -/// because the `AppContainer` backend can stamp deny ACEs; it is also genuinely -/// default-deny, so anything not granted is already inaccessible. +/// `processContainer` honors `readwrite`/`readonly` and `denied_paths`. The +/// `AppContainer` backend is genuinely default-deny, so anything not granted is +/// already inaccessible. `isolation_session` rejects non-empty filesystem +/// grants; its backend defaults determine visibility when no grants are present. #[derive(Debug, Default)] #[allow(clippy::struct_field_names)] pub struct MxcFilesystem { From 7ba7a39d0979fea2b63ae8716a9b5cbf81926a0c Mon Sep 17 00:00:00 2001 From: Prekshi Vyas <34834085+prekshivyas@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:39:23 -0700 Subject: [PATCH 23/42] ci(windows): exercise MXC inference demos with mock API (#3780) * ci(windows): exercise MXC Ollama demo with mock API Signed-off-by: Prekshi Vyas * ci(windows): cover both MXC inference demos Signed-off-by: Prekshi Vyas * fix(mxc): preserve executable extension resolution Signed-off-by: Prekshi Vyas * test(mxc): use absolute PowerShell in lifecycle checks Signed-off-by: Prekshi Vyas * test(mxc): make lifecycle write probes deterministic Signed-off-by: Prekshi Vyas * test(mxc): assert stable lifecycle completion Signed-off-by: Prekshi Vyas --------- Signed-off-by: Prekshi Vyas --- .../build-openshell-mxc-windows/SKILL.md | 39 ++- .github/workflows/windows-msvc.yml | 6 + architecture/windows.md | 5 + crates/openshell-driver-mxc/README.md | 12 +- .../examples/inference.yaml | 4 +- .../examples/run-inference-test.ps1 | 57 +++- .../examples/run-ollama-test.ps1 | 26 +- crates/openshell-driver-mxc/src/driver.rs | 112 ++++---- crates/openshell-driver-mxc/src/mxc.rs | 36 ++- .../tests/demo_examples.rs | 15 + .../windows-mxc-inference-examples-e2e.ps1 | 258 ++++++++++++++++++ tasks/scripts/windows-mxc-inference-stub.ps1 | 114 ++++++++ tasks/windows.toml | 10 + 13 files changed, 608 insertions(+), 86 deletions(-) create mode 100644 tasks/scripts/windows-mxc-inference-examples-e2e.ps1 create mode 100644 tasks/scripts/windows-mxc-inference-stub.ps1 diff --git a/.agents/skills/build-openshell-mxc-windows/SKILL.md b/.agents/skills/build-openshell-mxc-windows/SKILL.md index a7d30f63f7..18e69d943d 100644 --- a/.agents/skills/build-openshell-mxc-windows/SKILL.md +++ b/.agents/skills/build-openshell-mxc-windows/SKILL.md @@ -1,6 +1,6 @@ --- name: build-openshell-mxc-windows -description: Maintain and validate OpenShell's build-only Windows MSVC lane for x64 and ARM64. Use when working on Windows compilation, `windows:*` mise tasks, unsupported Windows compute-driver contracts, or Windows build reports. This skill does not implement Docker, Kubernetes, Podman, VM, MXC driver, policy translation, MSI, service, or supervisor runtime support on Windows. +description: Maintain and validate OpenShell's Windows MSVC lane for x64 and ARM64. Use when working on Windows compilation, `windows:*` mise tasks, unsupported Windows compute-driver contracts, MXC example wiring checks, or Windows build reports. This skill does not implement Docker, Kubernetes, Podman, VM, MXC driver, policy translation, MSI, service, or supervisor runtime support on Windows. metadata: internal: true --- @@ -12,8 +12,8 @@ OpenShell repository. The Windows lane is already present in `main`; do not treat this skill as a first-time porting recipe unless the user explicitly asks for a new fork or a from-scratch bring-up. -The lane is build-only. It validates that OpenShell can compile and test on -Windows MSVC for the supported deliverables: +The lane validates that OpenShell can compile and test on Windows MSVC for the +supported deliverables: - `openshell-gateway.exe` - `openshell.exe` @@ -51,6 +51,8 @@ In scope: `openshell`. - Running workspace tests on a native x64 or ARM64 host. - Running focused unsupported-driver contract tests. +- Running the shipped Ollama and cloud-inference MXC examples against the + in-process `wxc` mock and a local API stub. - Reporting test counts, skipped/gated areas, warnings, artifacts, and logs. - Keeping Linux and macOS build paths unchanged. - Keeping unsupported Windows compute drivers explicit and testable. @@ -158,8 +160,15 @@ mise run --skip-tools windows:build:x64 mise run --skip-tools windows:build:arm64 mise run --skip-tools windows:test:x64 mise run --skip-tools windows:test:unsupported:x64 +mise run --skip-tools windows:e2e:mxc:inference-mock:x64 ``` +Use the `arm64` form of the inference task on a native ARM64 host. It exercises +the real gateway, CLI, and shipped PowerShell runners with an in-process `wxc` +mock and local HTTP responses. It proves example wiring, request execution, and +sandbox-scoped credential propagation; it does not prove MXC, AppContainer, +filesystem, or network enforcement. + The two `windows:test:mxc-real:*` tasks are host-specific and mutually exclusive on a single host (each rejects the other architecture -- see the table below): run `windows:test:mxc-real:x64` on an x64 host, or @@ -220,14 +229,17 @@ order: The GitHub Actions jobs layer architecture-specific `Swatinem/rust-cache` entries for Cargo registry and dependency target artifacts with sccache's GHA backend for cacheable Rust compiler outputs. Failed runs also save their usable -dependency artifacts. Pull-request mirrors labeled `test:windows` run Clippy for the -Windows-supported workspace and e2e crates plus Rust tests. Pushes to `main` and -manual dispatches run the same lint and test commands in a cache-seed job, -followed by a dependent release-binary build job. The seed and PR jobs use the -same cache namespaces. Merge queues do not run this workflow. Main/manual seed -and build jobs use job-level `continue-on-error: true`; opt-in PR jobs report -failures normally. Applying the label alone does not start a run: re-run all -jobs in the current mirror push run, or push a new mirrored commit. The binaries are not uploaded or published. +dependency artifacts. Pull-request mirrors labeled `test:windows` run Clippy +for the Windows-supported workspace and e2e crates plus Rust tests, build +release binaries, and run both shipped MXC inference examples through the mock +task. Pushes to `main` and manual dispatches run the same lint and test commands +in a cache-seed job, followed by a dependent release-binary build and +mock-example job. The seed and PR jobs use the same cache namespaces. Merge +queues do not run this workflow. Main/manual seed and build jobs use job-level +`continue-on-error: true`; opt-in PR jobs report failures normally. Applying +the label alone does not start a run: re-run all jobs in the current mirror +push run, or push a new mirrored commit. The binaries are not uploaded or +published. The ARM64 check/build steps in this x64-host contract are cross-builds. The wrapper discovers and adds host-native LLVM and Ninja to `PATH`, requires the @@ -272,6 +284,8 @@ crypto dependency builds. | `windows:test:mxc-real:x64` | Runs the serial, ignored real-`wxc-exec` integration suite natively on x64 through the MSVC wrapper. Rejects non-x64 hosts. | | `windows:test:mxc-real:arm64` | Runs the same real-`wxc-exec` suite natively on ARM64. Rejects non-ARM64 hosts. | | `windows:test:mxc-gb300:arm64` | Runs the required native ARM64 ProcessContainer subset and fails when a test skips. | +| `windows:e2e:mxc:inference-mock:x64` | Runs the shipped Ollama and cloud-inference demos on native x64 through the real gateway and CLI, in-process `wxc` mock, and local API stub. This is wiring evidence, not MXC enforcement evidence. | +| `windows:e2e:mxc:inference-mock:arm64` | Runs the same mock-wiring checks on native ARM64 with ARM64 release binaries. | | `windows:qualify:mxc:gb300:contract` | Validates the required/optional/unsupported/architecture-constrained GB300 matrix. | | `windows:qualify:mxc:gb300` | Runs the fail-closed GB300 ARM64 gate and validates hash-bound evidence. | | `windows:artifacts` | Reports size and SHA256 for release artifacts that exist. | @@ -324,6 +338,8 @@ When reporting `windows:ci`, distinguish these categories: - Passed tests from the full ARM64 workspace test log when run on a native ARM64 host. - The focused unsupported-contract re-run. +- The architecture-matched MXC inference-example mock task and its explicit + wiring-only limitation. - Explicit Cargo ignored tests, usually ignored doc examples. - Tests hidden by `#[cfg(not(target_os = "windows"))]`; these often appear as `running 0 tests`, not as ignored tests. @@ -385,6 +401,7 @@ Every substantial Windows build run should report: | ARM64 check/build | Pass/fail/skipped and log path. | | Native tests | Passed/failed/ignored/filtered counts and log path for the host architecture. | | Unsupported contracts | Which focused tests ran and their result. | +| MXC example mock E2E | Architecture, result, artifact directory on failure, and the wiring-only limitation. | | Artifacts | Binary paths, size, and SHA256 when available. | | Skips | Explicitly explain tests not run for a non-native architecture, unsupported driver package exclusions, and Windows cfg-gated tests. | | Follow-ups | Only concrete follow-ups tied to failures or requested scope. | diff --git a/.github/workflows/windows-msvc.yml b/.github/workflows/windows-msvc.yml index 00c41d9ca9..1b7964eb20 100644 --- a/.github/workflows/windows-msvc.yml +++ b/.github/workflows/windows-msvc.yml @@ -98,6 +98,10 @@ jobs: run: mise run --skip-tools windows:lint:${{ matrix.arch }} - name: Test run: mise run --skip-tools windows:test:${{ matrix.arch }} + - name: Build MXC example E2E binaries + run: mise run --skip-tools windows:build:${{ matrix.arch }} + - name: Run shipped MXC inference examples with mock API + run: mise run --skip-tools windows:e2e:mxc:inference-mock:${{ matrix.arch }} - name: sccache stats if: always() run: sccache --show-stats @@ -211,6 +215,8 @@ jobs: cache-bin: "false" - name: Build release binaries run: mise run --skip-tools windows:build:${{ matrix.arch }} + - name: Run shipped MXC inference examples with mock API + run: mise run --skip-tools windows:e2e:mxc:inference-mock:${{ matrix.arch }} - name: sccache stats if: always() run: sccache --show-stats diff --git a/architecture/windows.md b/architecture/windows.md index 7a14422302..abf5043543 100644 --- a/architecture/windows.md +++ b/architecture/windows.md @@ -248,6 +248,11 @@ Windows validation separates source correctness from host capability: workspace and unsupported-driver contract tests for x64 and ARM64. - Mock MXC E2E validates gateway, CLI, driver, lifecycle, and policy wiring but is not evidence of OS enforcement. +- Hosted Windows CI runs the shipped Ollama and cloud-inference demos against + that mock and a local compatible API. This proves both complete demo scripts, + sandbox-scoped credential propagation, and response paths without a model or + external credential, but it does not prove MXC isolation, proxy substitution, + or network enforcement. - Real-`wxc-exec` tests validate the installed schema and selected filesystem, UI, network, and lifecycle behavior. A probe-gated skip is useful diagnostic output, not qualification evidence. diff --git a/crates/openshell-driver-mxc/README.md b/crates/openshell-driver-mxc/README.md index c4f1bfefdd..7e88e24f79 100644 --- a/crates/openshell-driver-mxc/README.md +++ b/crates/openshell-driver-mxc/README.md @@ -59,7 +59,7 @@ pc_relay_spawner_path = "" pc_relay_target_port = 0 # processContainer only: env-inheritance tier for the launched process # (safest first): default is a minimal Windows CreateProcessW bootstrap set -# (SYSTEMROOT/WINDIR/PATH/COMSPEC/LOCALAPPDATA); pc_minimal_env starts from an +# (SYSTEMROOT/WINDIR/PATH/PATHEXT/COMSPEC/LOCALAPPDATA); pc_minimal_env starts from an # EMPTY env for runtimes that need a fully curated per-sandbox environment. pc_minimal_env = false # processContainer only: compatibility fallback for unrestricted outbound TCP. @@ -203,11 +203,11 @@ environment variable, so workloads using it must pass `--cacert %CURL_CA_BUNDLE%` explicitly. Clients that honor the injected trust variables consume the same per-sandbox bundle directly. -The driver seeds only `SYSTEMROOT`, `WINDIR`, `PATH`, `COMSPEC`, and -`LOCALAPPDATA` from the gateway host before applying sandbox and TLS overrides, -so required Windows bootstrap values remain available without exposing the -gateway's full environment unless the gateway explicitly opts into another -environment mode. +The driver seeds only `SYSTEMROOT`, `WINDIR`, `PATH`, `PATHEXT`, `COMSPEC`, and +`LOCALAPPDATA` from the gateway host before applying sandbox and TLS overrides. +These values provide Windows process startup and command-resolution behavior +without exposing the gateway's full environment unless the gateway explicitly +opts into another environment mode. When governed egress is disabled, any network rule fails closed during sandbox creation. diff --git a/crates/openshell-driver-mxc/examples/inference.yaml b/crates/openshell-driver-mxc/examples/inference.yaml index e20f4020b8..820321c0be 100644 --- a/crates/openshell-driver-mxc/examples/inference.yaml +++ b/crates/openshell-driver-mxc/examples/inference.yaml @@ -14,8 +14,8 @@ network_policies: nvidia_inference: name: nvidia-inference endpoints: - - host: integrate.api.nvidia.com - port: 443 + - host: "__INFERENCE_HOST__" + port: __INFERENCE_PORT__ protocol: rest # Chat completions use POST. access: read-write diff --git a/crates/openshell-driver-mxc/examples/run-inference-test.ps1 b/crates/openshell-driver-mxc/examples/run-inference-test.ps1 index 1dc08f5a5f..9b2a39e329 100644 --- a/crates/openshell-driver-mxc/examples/run-inference-test.ps1 +++ b/crates/openshell-driver-mxc/examples/run-inference-test.ps1 @@ -2,6 +2,8 @@ # SPDX-License-Identifier: Apache-2.0 # Cloud inference (T1) demo for OpenShell on MXC. PowerShell 5.1 compatible. +# -Mock runs the workload on the host through the in-process wxc shim. It is for +# CI wiring coverage only and does not provide MXC or AppContainer isolation. [CmdletBinding()] param( @@ -11,8 +13,10 @@ param( [string] $ShareDir, [string] $Model = "nvidia/nemotron-3.5-lightning-30b-a3b", [string] $Prompt = "Say hello in exactly five words.", + [string] $ApiUrl = "https://integrate.api.nvidia.com/v1/chat/completions", [ValidateRange(0, 65535)] [int] $Port = 0, [string] $SandboxName, + [switch] $Mock, [switch] $KeepArtifacts ) @@ -133,6 +137,7 @@ $success = $false $failure = $null $oldGatewayConfig = $env:OPENSHELL_GATEWAY_CONFIG $oldComputeDriver = $env:OPENSHELL_COMPUTE_DRIVER +$oldMockWxc = $env:OPENSHELL_MXC_MOCK_WXC $oldApiKey = $env:NV_API_KEY $apiKey = $env:NV_API_KEY @@ -142,12 +147,29 @@ try { } $gateway = Resolve-Executable $GatewayPath "openshell-gateway.exe" "" $cli = Resolve-Executable $CliPath "openshell.exe" "" - $wxc = Resolve-Executable $WxcExecPath "wxc-exec.exe" "OPENSHELL_WXC_EXEC_PATH" + if ($Mock) { + # The gateway still validates that wxc_exec_path is absolute. The + # in-process mock never launches this placeholder. + $wxc = Join-Path $here "mock-wxc-exec.exe" + } else { + $wxc = Resolve-Executable $WxcExecPath "wxc-exec.exe" "OPENSHELL_WXC_EXEC_PATH" + } foreach ($fixture in @("mxc-inference.toml", "inference.yaml")) { if (-not (Test-Path -LiteralPath (Join-Path $here $fixture) -PathType Leaf)) { throw "required demo fixture '$fixture' is missing beside the runner" } } + if ($ApiUrl.IndexOfAny([char[]]@('"', '%', '!', '&', '|', '<', '>', '^', [char] 13, [char] 10)) -ge 0) { + throw "ApiUrl contains a character that cannot be rendered safely into the sandbox command" + } + try { + $apiUri = [System.Uri] $ApiUrl + } catch { + throw "ApiUrl is not a valid absolute URI: $ApiUrl" + } + if (-not $apiUri.IsAbsoluteUri -or $apiUri.Scheme -notin @("http", "https")) { + throw "ApiUrl must be an absolute HTTP or HTTPS URI" + } if ($Port -eq 0) { $Port = Get-AvailablePort } $endpoint = "http://127.0.0.1:$Port" if ([string]::IsNullOrWhiteSpace($SandboxName)) { $SandboxName = "inference-$PID" } @@ -163,8 +185,9 @@ try { Info "gateway: $gateway" Info "CLI: $cli" - Info "wxc-exec: $wxc" + Info "wxc-exec: $(if ($Mock) { 'in-process mock (no MXC isolation)' } else { $wxc })" Info "share: $ShareDir" + Info "inference API: $ApiUrl" Info "NV_API_KEY: present (value redacted)" $cmdExe = Join-Path $env:SystemRoot "System32\cmd.exe" @@ -187,6 +210,8 @@ try { $policyText = [System.IO.File]::ReadAllText((Join-Path $here "inference.yaml")) $policyText = $policyText.Replace("__OPENSHELL_DEMO_SHARE__", $sharePolicy) $policyText = $policyText.Replace("__CMD_EXE__", $cmdExe) + $policyText = $policyText.Replace("__INFERENCE_HOST__", $apiUri.DnsSafeHost) + $policyText = $policyText.Replace("__INFERENCE_PORT__", [string] $apiUri.Port) Write-Utf8 $policyUsed $policyText $requestPath = Join-Path $ShareDir "inference-request.json" @@ -198,11 +223,21 @@ try { Write-Utf8 $requestPath $requestJson $probePath = Join-Path $ShareDir "inference-probe.cmd" - $probeLines = @( - "@echo off", + $tlsArgs = if ($apiUri.Scheme -eq "https") { # Inbox curl uses Schannel and does not honor CURL_CA_BUNDLE by itself. # Point --cacert at the public bundle staged by the governed proxy. - "`"$curlExe`" --silent --show-error --fail-with-body --ssl-no-revoke --cacert `"%CURL_CA_BUNDLE%`" --max-time 120 -D `"$headersPath`" -H `"Authorization: Bearer %NV_API_KEY%`" -H `"Content-Type: application/json`" --data-binary `"@$requestPath`" -o `"$responsePath`" `"https://integrate.api.nvidia.com/v1/chat/completions`" 2> `"$errorPath`" || exit /b 31", + '--ssl-no-revoke --cacert "%CURL_CA_BUNDLE%"' + } else { + "" + } + $proxyBypassArgs = if ($apiUri.IsLoopback) { + "--noproxy `"$($apiUri.DnsSafeHost)`"" + } else { + "" + } + $probeLines = @( + "@echo off", + "`"$curlExe`" $proxyBypassArgs --silent --show-error --fail-with-body $tlsArgs --max-time 120 -D `"$headersPath`" -H `"Authorization: Bearer %NV_API_KEY%`" -H `"Content-Type: application/json`" --data-binary `"@$requestPath`" -o `"$responsePath`" `"$ApiUrl`" 2> `"$errorPath`" || exit /b 31", "`"$findStrExe`" /C:`"choices`" `"$responsePath`" >nul || exit /b 32", "echo PASS> `"$donePath`"" ) @@ -214,6 +249,11 @@ try { $gwErrLog = Join-Path $resultDir "gateway.err.log" $env:OPENSHELL_GATEWAY_CONFIG = $tomlUsed $env:OPENSHELL_COMPUTE_DRIVER = "mxc" + if ($Mock) { + $env:OPENSHELL_MXC_MOCK_WXC = "1" + } else { + Remove-Item Env:OPENSHELL_MXC_MOCK_WXC -ErrorAction SilentlyContinue + } # The credential belongs to sandbox creation, not gateway configuration. Remove-Item Env:NV_API_KEY -ErrorAction SilentlyContinue try { @@ -280,6 +320,11 @@ try { } $env:OPENSHELL_GATEWAY_CONFIG = $oldGatewayConfig $env:OPENSHELL_COMPUTE_DRIVER = $oldComputeDriver + if ([string]::IsNullOrWhiteSpace($oldMockWxc)) { + Remove-Item Env:OPENSHELL_MXC_MOCK_WXC -ErrorAction SilentlyContinue + } else { + $env:OPENSHELL_MXC_MOCK_WXC = $oldMockWxc + } if ([string]::IsNullOrWhiteSpace($oldApiKey)) { Remove-Item Env:NV_API_KEY -ErrorAction SilentlyContinue } else { $env:NV_API_KEY = $oldApiKey } if (-not $KeepArtifacts -and $createdShare -and $ShareDir -and (Test-Path -LiteralPath $ShareDir)) { Remove-Item -LiteralPath $ShareDir -Recurse -Force -ErrorAction SilentlyContinue @@ -287,7 +332,7 @@ try { } $verdict = if ($success) { "PASS" } else { "FAIL" } -$summary = "verdict=$verdict`r`nbase=cloud-inference`r`nsandbox=$SandboxName`r`ngateway=$endpoint`r`nbackend=process_container`r`nresult=$failure`r`n" +$summary = "verdict=$verdict`r`nbase=cloud-inference`r`nmode=$(if ($Mock) { 'mock-wiring' } else { 'real-mxc' })`r`nsandbox=$SandboxName`r`ngateway=$endpoint`r`nbackend=process_container`r`nresult=$failure`r`n" Write-Utf8 (Join-Path $resultDir "summary.txt") $summary Write-Host "`n$summary" Write-Host "Results: $resultDir" diff --git a/crates/openshell-driver-mxc/examples/run-ollama-test.ps1 b/crates/openshell-driver-mxc/examples/run-ollama-test.ps1 index 59c5bbcbb6..1dbfb77161 100644 --- a/crates/openshell-driver-mxc/examples/run-ollama-test.ps1 +++ b/crates/openshell-driver-mxc/examples/run-ollama-test.ps1 @@ -2,6 +2,8 @@ # SPDX-License-Identifier: Apache-2.0 # Hello World local-inference demo for OpenShell on MXC. PowerShell 5.1 compatible. +# -Mock runs the workload on the host through the in-process wxc shim. It is for +# CI wiring coverage only and does not provide MXC or AppContainer isolation. [CmdletBinding()] param( @@ -15,6 +17,7 @@ param( [string] $Prompt = "Say hello in exactly five words.", [ValidateRange(0, 65535)] [int] $Port = 0, [string] $SandboxName, + [switch] $Mock, [switch] $KeepArtifacts ) @@ -135,11 +138,18 @@ $success = $false $failure = $null $oldGatewayConfig = $env:OPENSHELL_GATEWAY_CONFIG $oldComputeDriver = $env:OPENSHELL_COMPUTE_DRIVER +$oldMockWxc = $env:OPENSHELL_MXC_MOCK_WXC try { $gateway = Resolve-Executable $GatewayPath "openshell-gateway.exe" "" $cli = Resolve-Executable $CliPath "openshell.exe" "" - $wxc = Resolve-Executable $WxcExecPath "wxc-exec.exe" "OPENSHELL_WXC_EXEC_PATH" + if ($Mock) { + # The gateway still validates that wxc_exec_path is absolute. The + # in-process mock never launches this placeholder. + $wxc = Join-Path $here "mock-wxc-exec.exe" + } else { + $wxc = Resolve-Executable $WxcExecPath "wxc-exec.exe" "OPENSHELL_WXC_EXEC_PATH" + } foreach ($fixture in @("mxc-ollama.toml", "ollama.yaml")) { if (-not (Test-Path -LiteralPath (Join-Path $here $fixture) -PathType Leaf)) { throw "required demo fixture '$fixture' is missing beside the runner" @@ -163,7 +173,7 @@ try { Info "gateway: $gateway" Info "CLI: $cli" - Info "wxc-exec: $wxc" + Info "wxc-exec: $(if ($Mock) { 'in-process mock (no MXC isolation)' } else { $wxc })" Info "share: $ShareDir" Info "Ollama: http://${OllamaHost}:$OllamaPort" @@ -226,6 +236,11 @@ try { $gwErrLog = Join-Path $resultDir "gateway.err.log" $env:OPENSHELL_GATEWAY_CONFIG = $tomlUsed $env:OPENSHELL_COMPUTE_DRIVER = "mxc" + if ($Mock) { + $env:OPENSHELL_MXC_MOCK_WXC = "1" + } else { + Remove-Item Env:OPENSHELL_MXC_MOCK_WXC -ErrorAction SilentlyContinue + } $gatewayProcess = Start-Process -FilePath $gateway -ArgumentList @("--disable-tls", "--db-url", "sqlite::memory:", "--port", "$Port", "--log-level", "info") -WorkingDirectory $here -PassThru -WindowStyle Hidden -RedirectStandardOutput $gwLog -RedirectStandardError $gwErrLog $deadline = (Get-Date).AddSeconds(30) while ((Get-Date) -lt $deadline -and -not (Test-Port $Port)) { @@ -269,13 +284,18 @@ try { } $env:OPENSHELL_GATEWAY_CONFIG = $oldGatewayConfig $env:OPENSHELL_COMPUTE_DRIVER = $oldComputeDriver + if ([string]::IsNullOrWhiteSpace($oldMockWxc)) { + Remove-Item Env:OPENSHELL_MXC_MOCK_WXC -ErrorAction SilentlyContinue + } else { + $env:OPENSHELL_MXC_MOCK_WXC = $oldMockWxc + } if (-not $KeepArtifacts -and $createdShare -and $ShareDir -and (Test-Path -LiteralPath $ShareDir)) { Remove-Item -LiteralPath $ShareDir -Recurse -Force -ErrorAction SilentlyContinue } } $verdict = if ($success) { "PASS" } else { "FAIL" } -$summary = "verdict=$verdict`r`nbase=local-ollama`r`nsandbox=$SandboxName`r`ngateway=$endpoint`r`nbackend=process_container`r`nresult=$failure`r`n" +$summary = "verdict=$verdict`r`nbase=local-ollama`r`nmode=$(if ($Mock) { 'mock-wiring' } else { 'real-mxc' })`r`nsandbox=$SandboxName`r`ngateway=$endpoint`r`nbackend=process_container`r`nresult=$failure`r`n" Write-Utf8 (Join-Path $resultDir "summary.txt") $summary Write-Host "`n$summary" Write-Host "Results: $resultDir" diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index cccb77bd47..6c47753ce0 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -151,7 +151,7 @@ async fn wait_for_target_listener(port: u16) -> std::io::Result<()> { /// /// - `IsolationSession`: persistent, attachable session /// (provision → start → exec → stop → deprovision). Does not support -/// OpenShell filesystem-policy grants; backend defaults determine visibility. +/// `OpenShell` filesystem-policy grants; backend defaults determine visibility. /// - `ProcessContainer` (default): one-shot `AppContainer`. Genuinely default-deny: a /// write to any ungranted path is denied by the OS. No persistent session. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] @@ -211,8 +211,8 @@ pub struct MxcComputeConfig { /// passed to the process. /// Use for agents like Node.js that fail with `STATUS_DLL_INIT_FAILED` /// when unrecognised host env vars are present; the caller is then - /// responsible for supplying `SYSTEMROOT`/`WINDIR`/`PATH`/`COMSPEC`/ - /// `LOCALAPPDATA` through `sandbox create --env/--env-from` if needed + /// responsible for supplying `SYSTEMROOT`/`WINDIR`/`PATH`/`PATHEXT`/ + /// `COMSPEC`/`LOCALAPPDATA` through `sandbox create --env/--env-from` if needed /// (`CreateProcessW` itself won't succeed without `LOCALAPPDATA` at /// least -- see `MINIMAL_WINDOWS_BOOTSTRAP_ENV`). /// @@ -665,8 +665,14 @@ fn allocate_sandbox_proxy_addr( /// are secrets, so resolving them from the gateway host is safe; this is /// the per-sandbox environment layers on top of. See `pc_minimal_env` on /// `MxcComputeConfig` for the explicit empty-baseline option. -const MINIMAL_WINDOWS_BOOTSTRAP_ENV: [&str; 5] = - ["SYSTEMROOT", "WINDIR", "PATH", "COMSPEC", "LOCALAPPDATA"]; +const MINIMAL_WINDOWS_BOOTSTRAP_ENV: [&str; 6] = [ + "SYSTEMROOT", + "WINDIR", + "PATH", + "PATHEXT", + "COMSPEC", + "LOCALAPPDATA", +]; const TLS_ENV_KEYS: [&str; 6] = [ "NODE_EXTRA_CA_CERTS", @@ -2612,9 +2618,19 @@ mod lifecycle_tests { } fn driver_sandbox(id: &str) -> DriverSandbox { - let shell = - std::env::var("COMSPEC").unwrap_or_else(|_| r"C:\Windows\System32\cmd.exe".to_string()); - driver_sandbox_with_command(id, "", vec![shell, "/c".into(), "exit 0".into()]) + driver_sandbox_with_command(id, "", vec![inbox_cmd(), "/c".into(), "exit 0".into()]) + } + + fn inbox_cmd() -> String { + std::env::var("COMSPEC").unwrap_or_else(|_| r"C:\Windows\System32\cmd.exe".to_string()) + } + + fn inbox_powershell() -> String { + let system_root = std::env::var("SYSTEMROOT").unwrap_or_else(|_| r"C:\Windows".to_string()); + Path::new(&system_root) + .join(r"System32\WindowsPowerShell\v1.0\powershell.exe") + .to_string_lossy() + .into_owned() } #[tokio::test] @@ -2847,6 +2863,8 @@ mod lifecycle_tests { .replace("__OPENSHELL_DEMO_SHARE__", share) .replace("__OLLAMA_HOST__", "127.0.0.1") .replace("__OLLAMA_PORT__", "11434") + .replace("__INFERENCE_HOST__", "integrate.api.nvidia.com") + .replace("__INFERENCE_PORT__", "443") .replace("__CMD_EXE__", r"C:\Windows\System32\cmd.exe"); parse_sandbox_policy(&rendered).expect("parse rendered shipped demo policy") } @@ -3458,10 +3476,10 @@ mod lifecycle_tests { let share = tmp.path().to_string_lossy().replace('\\', "/"); let hello = format!("{share}/hello.txt"); let cmd = vec![ - "powershell".into(), - "-NoProfile".into(), - "-Command".into(), - format!("Set-Content -LiteralPath {hello} -Value hi"), + inbox_cmd(), + "/d".into(), + "/c".into(), + format!(r#"echo hi>"{hello}""#), ]; let backend = MxcComputeBackend::new_mocked(MxcComputeConfig::default()); @@ -3469,36 +3487,23 @@ mod lifecycle_tests { let sb = with_policy(driver_sandbox_with_command("sb-pos", &share, cmd), policy); backend.create_sandbox(&sb).await.expect("create accepted"); - // Self-reported Ready=True (no supervisor) once the agent exec launches. - let ready = wait_for(&backend, "sb-pos", |s| { - ready_condition(s).is_some_and(|c| c.status == "True" && c.reason == "AgentRunning") - }) - .await; - assert!(ready.is_some(), "sandbox should self-report Ready=True"); - - // Positive proof: the in-policy write materializes the host artifact. - let host_path = tmp.path().join("hello.txt"); - let mut found = false; - for _ in 0..100 { - if host_path.exists() { - found = true; - break; - } - tokio::time::sleep(Duration::from_millis(100)).await; - } - assert!(found, "hello.txt should appear in the granted share folder"); - // A successful one-shot agent (exit 0) must STAY Ready, not demote to // Error. Assert the terminal condition is Ready=True/AgentCompleted so the // positive demo shows a green Ready phase, not a red Error. let completed = wait_for(&backend, "sb-pos", |s| { - ready_condition(s).is_some_and(|c| c.status == "True" && c.reason == "AgentCompleted") + ready_condition(s).is_some_and(|condition| { + condition.status == "True" && condition.reason == "AgentCompleted" + }) }) .await; assert!( completed.is_some(), "sandbox should remain Ready=True (AgentCompleted) after a successful exec, never demote to Error" ); + assert!( + tmp.path().join("hello.txt").is_file(), + "hello.txt should appear in the granted share folder" + ); assert!( !backend .attribution @@ -3519,10 +3524,10 @@ mod lifecycle_tests { let share = tmp.path().to_string_lossy().replace('\\', "/"); let hello = format!("{share}/hello.txt"); let cmd = vec![ - "powershell".into(), - "-NoProfile".into(), - "-Command".into(), - format!("Set-Content -LiteralPath {hello} -Value hi"), + inbox_cmd(), + "/d".into(), + "/c".into(), + format!(r#"echo hi>"{hello}""#), ]; let backend = MxcComputeBackend::new_mocked(MxcComputeConfig::default()); @@ -3530,13 +3535,15 @@ mod lifecycle_tests { let sb = with_policy(driver_sandbox_with_command("sb-pc", &share, cmd), policy); backend.create_sandbox(&sb).await.expect("create accepted"); - let ready = wait_for(&backend, "sb-pc", |s| { - ready_condition(s).is_some_and(|c| c.status == "True" && c.reason == "AgentRunning") + let completed = wait_for(&backend, "sb-pc", |sandbox| { + ready_condition(sandbox).is_some_and(|condition| { + condition.status == "True" && condition.reason == "AgentCompleted" + }) }) .await; assert!( - ready.is_some(), - "processContainer sandbox should self-report Ready=True" + completed.is_some(), + "processContainer sandbox should report Ready=True/AgentCompleted" ); let recorded = crate::mxc::mock_recorded_config("sb-pc").expect("mock recorded config"); assert!( @@ -3547,17 +3554,8 @@ mod lifecycle_tests { assert_eq!(recorded["ui"]["clipboard"], "none"); assert_eq!(recorded["ui"]["injection"], false); - let host_path = tmp.path().join("hello.txt"); - let mut found = false; - for _ in 0..100 { - if host_path.exists() { - found = true; - break; - } - tokio::time::sleep(Duration::from_millis(100)).await; - } assert!( - found, + tmp.path().join("hello.txt").is_file(), "in-policy write should materialize under processContainer" ); } @@ -3878,10 +3876,10 @@ mod lifecycle_tests { out_tmp.path().to_string_lossy().replace('\\', "/") ); let cmd = vec![ - "powershell".into(), - "-NoProfile".into(), - "-Command".into(), - format!("Set-Content -LiteralPath {out_path} -Value hi"), + inbox_cmd(), + "/d".into(), + "/c".into(), + format!(r#"echo hi>"{out_path}""#), ]; let backend = MxcComputeBackend::new_mocked(MxcComputeConfig::default()); @@ -3937,7 +3935,7 @@ mod lifecycle_tests { let tmp = tempfile::tempdir().unwrap(); let share = tmp.path().to_string_lossy().replace('\\', "/"); let command = vec![ - "powershell".into(), + inbox_powershell(), "-NoProfile".into(), "-Command".into(), format!("$null = '{share}'; Start-Sleep -Seconds 60"), @@ -3980,7 +3978,7 @@ mod lifecycle_tests { let tmp = tempfile::tempdir().unwrap(); let share = tmp.path().to_string_lossy().replace('\\', "/"); let command = vec![ - "powershell".into(), + inbox_powershell(), "-NoProfile".into(), "-Command".into(), format!("$null = '{share}'; Start-Sleep -Seconds 60"), @@ -4031,7 +4029,7 @@ mod lifecycle_tests { let tmp = tempfile::tempdir().unwrap(); let share = tmp.path().to_string_lossy().replace('\\', "/"); let command = vec![ - "powershell".into(), + inbox_powershell(), "-NoProfile".into(), "-Command".into(), format!("$null = '{share}'; Start-Sleep -Seconds 60"), diff --git a/crates/openshell-driver-mxc/src/mxc.rs b/crates/openshell-driver-mxc/src/mxc.rs index e0e1598b1f..6f68dc5e3d 100644 --- a/crates/openshell-driver-mxc/src/mxc.rs +++ b/crates/openshell-driver-mxc/src/mxc.rs @@ -660,7 +660,15 @@ impl WxcExecInvoker { let cmd_norm = mock_normalize(&process.command_line); let in_policy = grants.iter().any(|g| !g.is_empty() && cmd_norm.contains(g)); - let mut cmd = Command::new("cmd"); + let command_shell = std::env::var_os("COMSPEC") + .unwrap_or_else(|| std::ffi::OsString::from(r"C:\Windows\System32\cmd.exe")); + let mut cmd = Command::new(command_shell); + cmd.env_clear(); + for entry in &process.env { + if let Some((key, value)) = entry.split_once('=') { + cmd.env(key, value); + } + } cmd.stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::inherit()) .stderr(std::process::Stdio::inherit()) @@ -975,6 +983,32 @@ mod tests { assert!(config.get("ui").is_none()); } + #[tokio::test] + async fn mock_exec_uses_only_the_mxc_process_environment() { + const KEY: &str = "OPENSHELL_MXC_MOCK_ENV_TEST"; + let workdir = tempfile::tempdir().expect("temporary workdir"); + let output = workdir.path().join("mock-env.txt"); + let process = MxcProcess { + command_line: format!("echo %{KEY}%,%SystemRoot% 1> \"{}\"", output.display()), + cwd: workdir.path().to_string_lossy().into_owned(), + env: vec![format!("{KEY}=process-value")], + timeout: 0, + }; + + let mut child = WxcExecInvoker::mock_spawn_with_grants( + &process, + &[mock_normalize(&workdir.path().to_string_lossy())], + ) + .expect("mock process should launch"); + let status = child.wait().await.expect("mock process should finish"); + + assert!(status.success()); + assert_eq!( + std::fs::read_to_string(output).expect("mock output").trim(), + "process-value,%SystemRoot%" + ); + } + #[test] fn oneshot_config_json_emits_typed_ui_policy() { let filesystem = MxcFilesystem::default(); diff --git a/crates/openshell-driver-mxc/tests/demo_examples.rs b/crates/openshell-driver-mxc/tests/demo_examples.rs index 11e72e688d..2b9ab52362 100644 --- a/crates/openshell-driver-mxc/tests/demo_examples.rs +++ b/crates/openshell-driver-mxc/tests/demo_examples.rs @@ -102,6 +102,8 @@ fn shipped_inference_policies_are_narrow_and_valid_after_rendering() { .replace("__OPENSHELL_DEMO_SHARE__", share) .replace("__OLLAMA_HOST__", "127.0.0.1") .replace("__OLLAMA_PORT__", "11434") + .replace("__INFERENCE_HOST__", "integrate.api.nvidia.com") + .replace("__INFERENCE_PORT__", "443") .replace("__CMD_EXE__", r"C:\Windows\System32\cmd.exe"); let policy = parse_sandbox_policy(&rendered) .unwrap_or_else(|error| panic!("failed to parse rendered {name}: {error}")); @@ -156,6 +158,19 @@ fn shipped_runners_supply_sandbox_scoped_workload_configuration() { assert!(!cloud.contains("pass -ApiKey")); assert!(cloud.contains("nvidia/nemotron-3.5-lightning-30b-a3b")); assert!(!cloud.contains("nvidia/nvidia-nemotron-nano-9b-v2")); + + let local = read_example("run-ollama-test.ps1"); + assert!(local.contains("[switch] $Mock")); + assert!(local.contains("in-process wxc shim")); + assert!(local.contains("does not provide MXC or AppContainer isolation")); + + let cloud = read_example("run-inference-test.ps1"); + assert!(cloud.contains("[switch] $Mock")); + assert!(cloud.contains("[string] $ApiUrl")); + assert!(cloud.contains("$apiUri.IsLoopback")); + assert!(cloud.contains("--noproxy")); + assert!(cloud.contains("in-process wxc shim")); + assert!(cloud.contains("does not provide MXC or AppContainer isolation")); } #[cfg(target_os = "windows")] diff --git a/tasks/scripts/windows-mxc-inference-examples-e2e.ps1 b/tasks/scripts/windows-mxc-inference-examples-e2e.ps1 new file mode 100644 index 0000000000..f0f3f95b78 --- /dev/null +++ b/tasks/scripts/windows-mxc-inference-examples-e2e.ps1 @@ -0,0 +1,258 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Runs both shipped MXC inference demos through the real gateway and CLI +# against the in-process wxc mock and a local HTTP API stub. This proves demo +# wiring only; it is not evidence of MXC or AppContainer enforcement. + +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [ValidateSet("x86_64-pc-windows-msvc", "aarch64-pc-windows-msvc")] + [string] $Target, + + [string] $GatewayPath, + [string] $CliPath, + [string] $ArtifactRoot, + [switch] $KeepArtifacts +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" +$PSNativeCommandUseErrorActionPreference = $false + +if (-not [System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([System.Runtime.InteropServices.OSPlatform]::Windows)) { + throw "windows-mxc-inference-examples-e2e.ps1 requires Windows." +} + +$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path +$ExamplesRoot = Join-Path $RepoRoot "crates\openshell-driver-mxc\examples" +$TargetDir = if ([string]::IsNullOrWhiteSpace($env:CARGO_TARGET_DIR)) { + Join-Path $RepoRoot "target" +} else { + [System.IO.Path]::GetFullPath($env:CARGO_TARGET_DIR) +} + +if ([string]::IsNullOrWhiteSpace($GatewayPath)) { + $GatewayPath = Join-Path $TargetDir "$Target\release\openshell-gateway.exe" +} +if ([string]::IsNullOrWhiteSpace($CliPath)) { + $CliPath = Join-Path $TargetDir "$Target\release\openshell.exe" +} +$GatewayPath = [System.IO.Path]::GetFullPath($GatewayPath) +$CliPath = [System.IO.Path]::GetFullPath($CliPath) + +foreach ($artifact in @($GatewayPath, $CliPath)) { + if (-not (Test-Path -LiteralPath $artifact -PathType Leaf)) { + throw "required release artifact is missing: $artifact" + } +} + +if ([string]::IsNullOrWhiteSpace($ArtifactRoot)) { + $ArtifactRoot = if ([string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)) { + [System.IO.Path]::GetTempPath() + } else { + $env:RUNNER_TEMP + } +} +$ArtifactRoot = [System.IO.Path]::GetFullPath($ArtifactRoot) +$StageDir = [System.IO.Path]::GetFullPath((Join-Path $ArtifactRoot "openshell-mxc-inference-examples-e2e-$Target")) +$expectedPrefix = $ArtifactRoot.TrimEnd('\', '/') + [System.IO.Path]::DirectorySeparatorChar +if (-not $StageDir.StartsWith($expectedPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "refusing to use staging path outside artifact root: $StageDir" +} +if (Test-Path -LiteralPath $StageDir) { + Remove-Item -LiteralPath $StageDir -Recurse -Force +} +New-Item -ItemType Directory -Path $StageDir | Out-Null + +foreach ($fixture in @( + "run-ollama-test.ps1", + "mxc-ollama.toml", + "ollama.yaml", + "run-inference-test.ps1", + "mxc-inference.toml", + "inference.yaml" +)) { + Copy-Item -LiteralPath (Join-Path $ExamplesRoot $fixture) -Destination $StageDir +} + +function Get-AvailablePort { + $listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0) + try { + $listener.Start() + return ([System.Net.IPEndPoint] $listener.LocalEndpoint).Port + } finally { + $listener.Stop() + } +} + +function Test-Listener([int] $Port) { + $client = [System.Net.Sockets.TcpClient]::new() + try { + $pending = $client.BeginConnect("127.0.0.1", $Port, $null, $null) + if (-not $pending.AsyncWaitHandle.WaitOne(250)) { return $false } + $client.EndConnect($pending) + return $true + } catch { + return $false + } finally { + $client.Dispose() + } +} + +$ApiPort = Get-AvailablePort +$MockToken = "openshell-ci-mock-token" +$RequestLog = Join-Path $StageDir "mock-inference-requests.log" +$ServerReady = Join-Path $StageDir "mock-inference.ready" +$ServerOutLog = Join-Path $StageDir "mock-inference.out.log" +$ServerErrLog = Join-Path $StageDir "mock-inference.err.log" +$ServerScript = Join-Path $PSScriptRoot "windows-mxc-inference-stub.ps1" +$serverProcess = $null +$passed = $false +$oldAppData = $env:APPDATA +$oldLocalAppData = $env:LOCALAPPDATA +$oldNvApiKey = $env:NV_API_KEY + +try { + $serverProcess = Start-Process -FilePath "powershell.exe" -ArgumentList @( + "-NoProfile", + "-ExecutionPolicy", "Bypass", + "-File", "`"$ServerScript`"", + "-Port", "$ApiPort", + "-RequestLog", "`"$RequestLog`"", + "-ReadyPath", "`"$ServerReady`"", + "-ExpectedBearerToken", $MockToken + ) -PassThru -WindowStyle Hidden -RedirectStandardOutput $ServerOutLog -RedirectStandardError $ServerErrLog + + $deadline = (Get-Date).AddSeconds(15) + while ((Get-Date) -lt $deadline -and (-not (Test-Path -LiteralPath $ServerReady) -or -not (Test-Listener $ApiPort))) { + if ($serverProcess.HasExited) { + $details = (Get-Content $ServerOutLog, $ServerErrLog -ErrorAction SilentlyContinue) -join [Environment]::NewLine + throw "mock inference server stopped before listening: $details" + } + Start-Sleep -Milliseconds 200 + } + if (-not (Test-Listener $ApiPort)) { + throw "mock inference server did not listen on port $ApiPort within 15 seconds" + } + + $env:APPDATA = Join-Path $StageDir "appdata" + $env:LOCALAPPDATA = Join-Path $StageDir "localappdata" + New-Item -ItemType Directory -Force -Path $env:APPDATA, $env:LOCALAPPDATA | Out-Null + + $ollamaRunner = Join-Path $StageDir "run-ollama-test.ps1" + $ollamaShare = Join-Path $StageDir "ollama-share" + $ollamaArgs = @( + "-NoProfile", + "-ExecutionPolicy", "Bypass", + "-File", $ollamaRunner, + "-Mock", + "-GatewayPath", $GatewayPath, + "-CliPath", $CliPath, + "-ShareDir", $ollamaShare, + "-OllamaPort", "$ApiPort", + "-Model", "openshell-ci-mock", + "-Prompt", "Return the CI mock response.", + "-KeepArtifacts" + ) + $output = & powershell.exe @ollamaArgs 2>&1 + $exitCode = $LASTEXITCODE + $output | ForEach-Object { Write-Host $_ } + if ($exitCode -ne 0) { + throw "shipped Ollama demo failed in mock mode (exit $exitCode)" + } + + $ollamaResultDir = Get-ChildItem -LiteralPath $StageDir -Directory -Filter "results-ollama-*" | + Sort-Object LastWriteTimeUtc -Descending | + Select-Object -First 1 + if ($null -eq $ollamaResultDir) { throw "Ollama demo did not produce a results directory" } + + $ollamaSummary = Get-Content -LiteralPath (Join-Path $ollamaResultDir.FullName "summary.txt") -Raw + if ($ollamaSummary -notmatch '(?m)^verdict=PASS\s*$' -or $ollamaSummary -notmatch '(?m)^mode=mock-wiring\s*$') { + throw "Ollama demo summary did not report a mock-wiring PASS: $ollamaSummary" + } + $ollamaResponse = Get-Content -LiteralPath (Join-Path $ollamaResultDir.FullName "ollama-response.json") -Raw | ConvertFrom-Json + if ($ollamaResponse.response -ne "Hello from the CI mock.") { + throw "Ollama demo did not retain the expected mock completion" + } + + $env:NV_API_KEY = $MockToken + $cloudRunner = Join-Path $StageDir "run-inference-test.ps1" + $cloudShare = Join-Path $StageDir "cloud-share" + $cloudApiUrl = "http://127.0.0.1:$ApiPort/v1/chat/completions" + $cloudArgs = @( + "-NoProfile", + "-ExecutionPolicy", "Bypass", + "-File", $cloudRunner, + "-Mock", + "-GatewayPath", $GatewayPath, + "-CliPath", $CliPath, + "-ShareDir", $cloudShare, + "-ApiUrl", $cloudApiUrl, + "-Model", "openshell-ci-mock", + "-Prompt", "Return the CI mock response.", + "-KeepArtifacts" + ) + $output = & powershell.exe @cloudArgs 2>&1 + $exitCode = $LASTEXITCODE + $output | ForEach-Object { Write-Host $_ } + if ($exitCode -ne 0) { + throw "shipped cloud-inference demo failed in mock mode (exit $exitCode)" + } + + $cloudResultDir = Get-ChildItem -LiteralPath $StageDir -Directory -Filter "results-inference-*" | + Sort-Object LastWriteTimeUtc -Descending | + Select-Object -First 1 + if ($null -eq $cloudResultDir) { throw "cloud-inference demo did not produce a results directory" } + + $cloudSummary = Get-Content -LiteralPath (Join-Path $cloudResultDir.FullName "summary.txt") -Raw + if ($cloudSummary -notmatch '(?m)^verdict=PASS\s*$' -or $cloudSummary -notmatch '(?m)^mode=mock-wiring\s*$') { + throw "cloud-inference demo summary did not report a mock-wiring PASS: $cloudSummary" + } + $cloudResponse = Get-Content -LiteralPath (Join-Path $cloudResultDir.FullName "inference-response.json") -Raw | ConvertFrom-Json + if ($cloudResponse.choices[0].message.content -ne "Hello from the CI mock.") { + throw "cloud-inference demo did not retain the expected mock completion" + } + + $requestLines = @([System.IO.File]::ReadAllLines($RequestLog)) + $expectedRequests = @( + @{ Pattern = '^GET /api/tags HTTP/\S+ authorization=absent$'; Count = 2; Description = 'host prerequisite and sandbox Ollama tag requests' }, + @{ Pattern = '^POST /api/generate HTTP/\S+ authorization=absent$'; Count = 1; Description = 'sandbox Ollama generation request' }, + @{ Pattern = '^POST /v1/chat/completions HTTP/\S+ authorization=synthetic$'; Count = 1; Description = 'sandbox cloud-inference request with the synthetic CI credential' } + ) + foreach ($expected in $expectedRequests) { + $actualCount = @($requestLines | Where-Object { $_ -match $expected.Pattern }).Count + if ($actualCount -ne $expected.Count) { + throw "mock inference server observed $actualCount $($expected.Description); expected $($expected.Count): $($requestLines -join '; ')" + } + } + + $passed = $true + Write-Host "MXC inference examples mock E2E passed for $Target" +} catch { + Write-Host "MXC inference examples mock E2E failed: $($_.Exception.Message)" -ForegroundColor Red + Get-ChildItem -LiteralPath $StageDir -File -Recurse -Include "*.log", "summary.txt" -ErrorAction SilentlyContinue | + ForEach-Object { + Write-Host "--- $($_.FullName) ---" + Get-Content -LiteralPath $_.FullName -ErrorAction SilentlyContinue | ForEach-Object { Write-Host $_ } + } + throw +} finally { + $env:APPDATA = $oldAppData + $env:LOCALAPPDATA = $oldLocalAppData + if ([string]::IsNullOrWhiteSpace($oldNvApiKey)) { + Remove-Item Env:NV_API_KEY -ErrorAction SilentlyContinue + } else { + $env:NV_API_KEY = $oldNvApiKey + } + if ($serverProcess -and -not $serverProcess.HasExited) { + Stop-Process -Id $serverProcess.Id -Force -ErrorAction SilentlyContinue + try { [void] $serverProcess.WaitForExit(5000) } catch {} + } + if ($passed -and -not $KeepArtifacts -and (Test-Path -LiteralPath $StageDir)) { + Remove-Item -LiteralPath $StageDir -Recurse -Force + } else { + Write-Host "MXC inference example artifacts: $StageDir" + } +} diff --git a/tasks/scripts/windows-mxc-inference-stub.ps1 b/tasks/scripts/windows-mxc-inference-stub.ps1 new file mode 100644 index 0000000000..a62d881e9b --- /dev/null +++ b/tasks/scripts/windows-mxc-inference-stub.ps1 @@ -0,0 +1,114 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [ValidateRange(1, 65535)] + [int] $Port, + + [Parameter(Mandatory = $true)] + [string] $RequestLog, + + [Parameter(Mandatory = $true)] + [string] $ReadyPath, + + [Parameter(Mandatory = $true)] + [string] $ExpectedBearerToken +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$utf8 = [System.Text.UTF8Encoding]::new($false) +$listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, $Port) +$listener.Start() +[System.IO.File]::WriteAllText($ReadyPath, "$Port`r`n", $utf8) + +try { + while ($true) { + $client = $listener.AcceptTcpClient() + try { + $stream = $client.GetStream() + $reader = [System.IO.StreamReader]::new( + $stream, + [System.Text.Encoding]::ASCII, + $false, + 1024, + $true + ) + $requestLine = $reader.ReadLine() + if ([string]::IsNullOrWhiteSpace($requestLine)) { continue } + + $contentLength = 0 + $authorization = "" + while ($true) { + $line = $reader.ReadLine() + if ([string]::IsNullOrEmpty($line)) { break } + if ($line -match '^Content-Length:\s*(\d+)\s*$') { + $contentLength = [int] $Matches[1] + } elseif ($line -match '^Authorization:\s*(.+)\s*$') { + $authorization = $Matches[1] + } + } + if ($contentLength -gt 0) { + $buffer = New-Object char[] $contentLength + $read = 0 + while ($read -lt $contentLength) { + $count = $reader.Read($buffer, $read, $contentLength - $read) + if ($count -le 0) { break } + $read += $count + } + } + + $parts = $requestLine.Split(' ') + $path = if ($parts.Count -ge 2) { $parts[1] } else { "/" } + $authorizationStatus = if ([string]::IsNullOrWhiteSpace($authorization)) { + "absent" + } elseif ($authorization -ceq "Bearer $ExpectedBearerToken") { + "synthetic" + } else { + "mismatch" + } + [System.IO.File]::AppendAllText( + $RequestLog, + "$requestLine authorization=$authorizationStatus`r`n", + $utf8 + ) + switch ($path) { + "/api/tags" { + $status = "200 OK" + $body = '{"models":[{"name":"openshell-ci-mock"}]}' + } + "/api/generate" { + $status = "200 OK" + $body = '{"model":"openshell-ci-mock","response":"Hello from the CI mock.","done":true}' + } + "/v1/chat/completions" { + if ($authorizationStatus -eq "synthetic") { + $status = "200 OK" + $body = '{"choices":[{"message":{"role":"assistant","content":"Hello from the CI mock."}}]}' + } else { + $status = "401 Unauthorized" + $body = '{"error":{"message":"invalid mock credential"}}' + } + } + default { + $status = "404 Not Found" + $body = '{"error":"not found"}' + } + } + + $bodyBytes = $utf8.GetBytes($body) + $headers = "HTTP/1.1 $status`r`nContent-Type: application/json`r`nContent-Length: $($bodyBytes.Length)`r`nConnection: close`r`n`r`n" + $headerBytes = [System.Text.Encoding]::ASCII.GetBytes($headers) + $stream.Write($headerBytes, 0, $headerBytes.Length) + $stream.Write($bodyBytes, 0, $bodyBytes.Length) + $stream.Flush() + } finally { + $client.Dispose() + } + } +} finally { + $listener.Stop() +} diff --git a/tasks/windows.toml b/tasks/windows.toml index 5b8bab4008..c533dd53a9 100644 --- a/tasks/windows.toml +++ b/tasks/windows.toml @@ -108,3 +108,13 @@ run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File crates/opensh description = "Run MXC Tier-3 e2e scenario runner in mock/wiring-only mode (no real wxc-exec required)" run = "echo 'windows:* tasks require a Windows MSVC host' && exit 1" run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 -Mock" + +["windows:e2e:mxc:inference-mock:x64"] +description = "Run the shipped MXC inference demos against the in-process wxc mock and a local API stub on Windows x64" +run = "echo 'windows:* tasks require a Windows MSVC host' && exit 1" +run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-mxc-inference-examples-e2e.ps1 -Target x86_64-pc-windows-msvc" + +["windows:e2e:mxc:inference-mock:arm64"] +description = "Run the shipped MXC inference demos against the in-process wxc mock and a local API stub on Windows ARM64" +run = "echo 'windows:* tasks require a Windows MSVC host' && exit 1" +run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-mxc-inference-examples-e2e.ps1 -Target aarch64-pc-windows-msvc" From d6fea62e762198c333a238d3fb2b045e0c888e74 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas <34834085+prekshivyas@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:08:48 -0700 Subject: [PATCH 24/42] fix(mxc): add schema version to test configs (#3891) Signed-off-by: Prekshi Vyas --- .../examples/mxc-ocsf-audit.toml | 3 +++ .../examples/mxc-ws-gateway.toml | 3 +++ .../tests/demo_examples.rs | 18 ++++++++++++++++++ 3 files changed, 24 insertions(+) diff --git a/crates/openshell-driver-mxc/examples/mxc-ocsf-audit.toml b/crates/openshell-driver-mxc/examples/mxc-ocsf-audit.toml index a828264a5e..52b1fbf4e6 100644 --- a/crates/openshell-driver-mxc/examples/mxc-ocsf-audit.toml +++ b/crates/openshell-driver-mxc/examples/mxc-ocsf-audit.toml @@ -13,6 +13,9 @@ # egress-proxy switch into a disposable copy of this file. Workload command and # cwd are sandbox-scoped and passed separately through --driver-config-json. +[openshell] +version = 2 + [openshell.drivers.mxc] # Path to wxc-exec.exe on the box (patched by the run script; default is the # location observed on the MXC test boxes). diff --git a/crates/openshell-driver-mxc/examples/mxc-ws-gateway.toml b/crates/openshell-driver-mxc/examples/mxc-ws-gateway.toml index 8a2aa4e9b8..5f534bc48c 100644 --- a/crates/openshell-driver-mxc/examples/mxc-ws-gateway.toml +++ b/crates/openshell-driver-mxc/examples/mxc-ws-gateway.toml @@ -32,6 +32,9 @@ # openshell-supervisor-relay kills the server directly and exits # driver also kills wxc-exec as a backstop regardless +[openshell] +version = 2 + [openshell.drivers.mxc] # Path to wxc-exec.exe. Patched at runtime by run-ws-agent-test.ps1. # Leave commented for mock-mode smoke tests (pass -Mock to the script). diff --git a/crates/openshell-driver-mxc/tests/demo_examples.rs b/crates/openshell-driver-mxc/tests/demo_examples.rs index 2b9ab52362..9fe867f4ba 100644 --- a/crates/openshell-driver-mxc/tests/demo_examples.rs +++ b/crates/openshell-driver-mxc/tests/demo_examples.rs @@ -38,6 +38,24 @@ fn read_example(name: &str) -> String { .unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display())) } +#[test] +fn shipped_audit_and_websocket_configs_use_current_schema() { + for name in ["mxc-ocsf-audit.toml", "mxc-ws-gateway.toml"] { + let source = read_example(name); + let parsed: Value = toml::from_str(&source) + .unwrap_or_else(|error| panic!("failed to parse {name}: {error}")); + let openshell = parsed + .get("openshell") + .and_then(Value::as_table) + .unwrap_or_else(|| panic!("{name} is missing [openshell]")); + assert_eq!( + openshell.get("version").and_then(Value::as_integer), + Some(2), + "{name} must use schema version 2" + ); + } +} + #[test] fn shipped_inference_configs_declare_required_mxc_settings() { for name in ["mxc-ollama.toml", "mxc-inference.toml"] { From 20b0ebdea496e1a8859ae3230661e7f5da4048f5 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas <34834085+prekshivyas@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:10:52 -0700 Subject: [PATCH 25/42] docs(mxc): clarify mapper schema versions (#3894) Signed-off-by: Prekshi Vyas --- crates/openshell-driver-mxc/src/lib.rs | 6 ++--- crates/openshell-driver-mxc/src/policy.rs | 14 ++++++----- .../src/policy_map/config.rs | 14 +++++++++-- .../src/policy_map/map.rs | 9 +++---- .../src/policy_map/mod.rs | 14 ++++++++--- .../src/policy_map/report.rs | 5 +++- .../tests/policy_mapper_matrix.rs | 24 +++++++++++++++++-- 7 files changed, 65 insertions(+), 21 deletions(-) diff --git a/crates/openshell-driver-mxc/src/lib.rs b/crates/openshell-driver-mxc/src/lib.rs index dce22b3b49..fe12c4253c 100644 --- a/crates/openshell-driver-mxc/src/lib.rs +++ b/crates/openshell-driver-mxc/src/lib.rs @@ -51,7 +51,7 @@ pub use relay::RelayHandle; pub use policy::{EmbeddedPolicyMapper, MapCtx, MapError, MappedConfig, PolicyMapper}; #[cfg(target_os = "windows")] pub use policy_map::{ - DEFAULT_COMMAND, DEFAULT_CONTAINMENT, DEFAULT_MXC_VERSION, LossItem, MxcMappingOptions, - MxcMappingResult, OPEN_SHELL_SUPERSET_GAPS, SplitPolicyResult, build_loss_report, map_to_mxc, - render_readme, split_policy, + DEFAULT_COARSE_MXC_VERSION, DEFAULT_COMMAND, DEFAULT_CONTAINMENT, DEFAULT_MXC_VERSION, + LossItem, MxcMappingOptions, MxcMappingResult, OPEN_SHELL_SUPERSET_GAPS, SplitPolicyResult, + build_loss_report, map_to_mxc, render_readme, split_policy, }; diff --git a/crates/openshell-driver-mxc/src/policy.rs b/crates/openshell-driver-mxc/src/policy.rs index 2a5c9883aa..3d823c3fc0 100644 --- a/crates/openshell-driver-mxc/src/policy.rs +++ b/crates/openshell-driver-mxc/src/policy.rs @@ -8,11 +8,12 @@ //! the standalone `openshell-policy-mapper` crate). This file defines the trait //! seam plus: //! -//! - [`EmbeddedPolicyMapper`] — the **primary** impl. Calls -//! [`crate::policy_map::map_to_mxc`] directly on the typed `SandboxPolicy` -//! proto (no YAML bridge), extracts the MXC filesystem shares, normalizes -//! their paths to Windows form, and rejects the create on any `error`-severity -//! loss. +//! - [`EmbeddedPolicyMapper`] — the **primary** impl. Calls the coarse mapper or +//! governed-egress split directly on the typed `SandboxPolicy` proto (no YAML +//! bridge), extracts the MXC filesystem/UI fragment, normalizes paths, and +//! rejects the create on any `error`-severity loss. The generated mapping JSON +//! is an intermediate representation: live requests are rebuilt by +//! [`crate::mxc`] using its active schema version. //! //! **Rule: never silently drop policy.** Unmappable rules surface as //! `MapError::Unsupported` and are rejected by `CreateSandbox` before lifecycle side effects. @@ -181,7 +182,8 @@ impl PolicyMapper for EmbeddedPolicyMapper { // Map directly off the typed proto. The default MXC driver path runs // an isolation session, so use that containment: its network branch // yields an `error` loss for any host allowlist, which rejects - // network policy below. + // network policy below. This coarse JSON is never sent to wxc-exec; + // only the extracted filesystem/UI policy enters the live request. let opts = crate::policy_map::MxcMappingOptions { containment: ctx.containment.clone(), container_id: ctx.sandbox_id.clone(), diff --git a/crates/openshell-driver-mxc/src/policy_map/config.rs b/crates/openshell-driver-mxc/src/policy_map/config.rs index f64b64a54a..fba9593d0d 100644 --- a/crates/openshell-driver-mxc/src/policy_map/config.rs +++ b/crates/openshell-driver-mxc/src/policy_map/config.rs @@ -12,8 +12,18 @@ use super::loss::{LossItem, add_loss}; /// not supply a real workload command. pub const DEFAULT_COMMAND: &str = "sh -lc \"echo OpenShell policy mapped to MXC; replace process.commandLine before running a real workload\""; -/// Default MXC schema version emitted in `version`. -pub const DEFAULT_MXC_VERSION: &str = "0.7.0-alpha"; +/// Default schema for the standalone coarse mapper's host-list config. +/// +/// This is deliberately independent from [`crate::mxc::MXC_SCHEMA_VERSION`]: +/// the coarse artifact uses the MXC 0.7 `allowedHosts` shape, while live driver +/// requests and the governed-egress split use MXC 0.8 directional networking. +pub const DEFAULT_COARSE_MXC_VERSION: &str = "0.7.0-alpha"; + +/// Compatibility name for [`DEFAULT_COARSE_MXC_VERSION`]. +/// +/// This value belongs only to [`super::map_to_mxc`] output. It is not the +/// schema version used for live `wxc-exec` requests. +pub const DEFAULT_MXC_VERSION: &str = DEFAULT_COARSE_MXC_VERSION; /// Default MXC containment backend for the coarse mapping. pub const DEFAULT_CONTAINMENT: &str = "bubblewrap"; diff --git a/crates/openshell-driver-mxc/src/policy_map/map.rs b/crates/openshell-driver-mxc/src/policy_map/map.rs index 4af3290715..47a159c33c 100644 --- a/crates/openshell-driver-mxc/src/policy_map/map.rs +++ b/crates/openshell-driver-mxc/src/policy_map/map.rs @@ -17,7 +17,7 @@ use openshell_core::proto::{ use serde_json::{Value, json}; use super::config::{ - DEFAULT_COMMAND, DEFAULT_CONTAINMENT, DEFAULT_MXC_VERSION, add_backend_network_loss, + DEFAULT_COARSE_MXC_VERSION, DEFAULT_COMMAND, DEFAULT_CONTAINMENT, add_backend_network_loss, add_backend_specific_config, default_enforcement_mode, filesystem_default_deny_message, }; use super::loss::{LossItem, add_loss}; @@ -27,8 +27,9 @@ use crate::mxc::MXC_SCHEMA_VERSION; /// coarse map (e.g. `proxy_redirect`) are reserved for the governed-egress split. #[derive(Clone, Debug)] pub struct MxcMappingOptions { - /// MXC schema version written into coarse-map output. The governed-egress - /// split uses the driver's MXC 0.8 schema. + /// Caller-selectable schema version written only into standalone coarse-map + /// output. The governed-egress split and live driver requests instead use + /// [`MXC_SCHEMA_VERSION`]. pub mxc_version: String, /// MXC containment backend. pub containment: String, @@ -52,7 +53,7 @@ pub struct MxcMappingOptions { impl Default for MxcMappingOptions { fn default() -> Self { Self { - mxc_version: DEFAULT_MXC_VERSION.to_owned(), + mxc_version: DEFAULT_COARSE_MXC_VERSION.to_owned(), containment: DEFAULT_CONTAINMENT.to_owned(), command: DEFAULT_COMMAND.to_owned(), container_id: "openshell-policy".to_owned(), diff --git a/crates/openshell-driver-mxc/src/policy_map/mod.rs b/crates/openshell-driver-mxc/src/policy_map/mod.rs index 670e31a617..a184fb353e 100644 --- a/crates/openshell-driver-mxc/src/policy_map/mod.rs +++ b/crates/openshell-driver-mxc/src/policy_map/mod.rs @@ -17,11 +17,17 @@ //! policy is flattened into an MXC host allowlist (`network.allowedHosts`), //! and anything MXC cannot express (ports, protocol, L7 rules, binary scope) //! is recorded in the loss report. Use this when MXC enforces network on its -//! own, with no `OpenShell` proxy in the loop. +//! own, with no `OpenShell` proxy in the loop. Its default schema is MXC 0.7, +//! matching that host-list shape; the caller can override the coarse target. //! - [`split_policy`] — the *lossless* split for the Windows MXC compute //! driver: MXC handles filesystem + containment + loopback-only egress, //! while the full `OpenShell` network policy is preserved in a trimmed policy -//! enforced by the host CONNECT proxy. +//! enforced by the host CONNECT proxy. This path uses the live driver's MXC +//! 0.8 directional-network schema. +//! +//! The coarse mapper's schema version describes its generated artifact, not the +//! live driver's operating schema. The embedded driver may use a coarse mapping +//! as an intermediate policy translation but does not send that JSON to MXC. //! //! The report/loss-report helpers are only exercised by the example and the //! integration tests, so the Windows lib build would otherwise warn on them; @@ -36,7 +42,9 @@ mod loss; mod map; mod report; -pub use config::{DEFAULT_COMMAND, DEFAULT_CONTAINMENT, DEFAULT_MXC_VERSION}; +pub use config::{ + DEFAULT_COARSE_MXC_VERSION, DEFAULT_COMMAND, DEFAULT_CONTAINMENT, DEFAULT_MXC_VERSION, +}; pub use loss::{LossItem, OPEN_SHELL_SUPERSET_GAPS}; pub use map::{MxcMappingOptions, MxcMappingResult, SplitPolicyResult, map_to_mxc, split_policy}; pub use report::{build_loss_report, render_readme}; diff --git a/crates/openshell-driver-mxc/src/policy_map/report.rs b/crates/openshell-driver-mxc/src/policy_map/report.rs index 0b1ec37b77..fdea60efa7 100644 --- a/crates/openshell-driver-mxc/src/policy_map/report.rs +++ b/crates/openshell-driver-mxc/src/policy_map/report.rs @@ -7,7 +7,10 @@ use serde_json::{Value, json}; use super::loss::{LossItem, OPEN_SHELL_SUPERSET_GAPS, summarize_missing_mxc}; -/// Build the structured `loss-report.json` value. +/// Build the structured `loss-report.json` value for a generated mapper artifact. +/// +/// `target.schemaVersion` describes that artifact's caller-selected schema. It +/// must not be interpreted as the live MXC driver's request schema. pub fn build_loss_report( source_policy: &str, generated_config: &str, diff --git a/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs b/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs index 3d2b8647f8..2a82546773 100644 --- a/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs +++ b/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs @@ -27,8 +27,8 @@ use openshell_core::proto::{ NetworkPolicyRule, ProcessPolicy, SandboxPolicy, UiClipboardAccess, UiPolicy, }; use openshell_driver_mxc::{ - EmbeddedPolicyMapper, MapCtx, MapError, MxcMappingOptions, PolicyMapper, map_to_mxc, - split_policy, + DEFAULT_COARSE_MXC_VERSION, DEFAULT_MXC_VERSION, EmbeddedPolicyMapper, MapCtx, MapError, + MxcMappingOptions, PolicyMapper, map_to_mxc, split_policy, }; use openshell_policy::{serialize_sandbox_policy, validate_sandbox_policy}; use serde_json::Value; @@ -132,6 +132,26 @@ fn assert_single_loss( // ─── QUADRANT A: mappable fields, assert exact MXC output ─────────────────── +/// The standalone coarse mapper and live governed-egress path intentionally +/// target different schema shapes. Keep the compatibility constant scoped to +/// the coarse artifact and prevent either side from silently drifting. +#[test] +fn a_schema_versions_match_their_distinct_network_shapes() { + let policy = SandboxPolicy::default(); + let coarse = map_to_mxc(&policy, &default_opts()).config; + assert_eq!(DEFAULT_MXC_VERSION, DEFAULT_COARSE_MXC_VERSION); + assert_eq!(coarse["version"], DEFAULT_COARSE_MXC_VERSION); + assert!(coarse["network"].get("allowedHosts").is_some()); + assert!(coarse["network"].get("egress").is_none()); + + let governed = split_policy(&policy, &pc_split_opts()) + .expect("governed split must exist when a proxy redirect is configured") + .mxc_config; + assert_eq!(governed["version"], "0.8.0-alpha"); + assert!(governed["network"].get("allowedHosts").is_none()); + assert!(governed["network"].get("egress").is_some()); +} + /// filesystem.read_write → readwritePaths verbatim, order preserved. #[test] fn a_rw_paths_verbatim() { From 4cb6054cfeaafb4c0b488293a76ad7d660705d02 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas <34834085+prekshivyas@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:11:01 -0700 Subject: [PATCH 26/42] fix(mxc): preserve JSON arguments in PowerShell 5.1 (#3893) Signed-off-by: Prekshi Vyas --- .../examples/run-openclaw-forward-test.ps1 | 70 ++++++++++++-- .../examples/run-ws-agent-test.ps1 | 71 ++++++++++++-- .../tests/powershell_native_arguments.rs | 95 +++++++++++++++++++ 3 files changed, 220 insertions(+), 16 deletions(-) create mode 100644 crates/openshell-driver-mxc/tests/powershell_native_arguments.rs diff --git a/crates/openshell-driver-mxc/examples/run-openclaw-forward-test.ps1 b/crates/openshell-driver-mxc/examples/run-openclaw-forward-test.ps1 index 7c3a7c6e80..60d9fec9b6 100644 --- a/crates/openshell-driver-mxc/examples/run-openclaw-forward-test.ps1 +++ b/crates/openshell-driver-mxc/examples/run-openclaw-forward-test.ps1 @@ -137,6 +137,60 @@ function Copy-ItemRetry([string]$src, [string]$dst, [int]$attempts = 10, [int]$d function Ok([string]$m) { Write-Host "[OK] $m" -ForegroundColor Green } function Bad([string]$m) { Write-Host "[FAIL] $m" -ForegroundColor Red } +# Build one CreateProcess-compatible command-line argument. Windows PowerShell +# 5.1 removes embedded quotes and can split JSON values at embedded spaces when +# invoking native commands through the call operator. +function Quote-NativeArgument([string]$value) { + if ($value.Length -gt 0 -and $value -notmatch '[\s"]') { return $value } + + $quoted = New-Object System.Text.StringBuilder + [void]$quoted.Append('"') + $backslashes = 0 + foreach ($ch in $value.ToCharArray()) { + if ($ch -eq '\') { + $backslashes++ + continue + } + if ($ch -eq '"') { + [void]$quoted.Append(('\' * (2 * $backslashes + 1))) + [void]$quoted.Append('"') + } else { + if ($backslashes -gt 0) { [void]$quoted.Append(('\' * $backslashes)) } + [void]$quoted.Append($ch) + } + $backslashes = 0 + } + if ($backslashes -gt 0) { [void]$quoted.Append(('\' * (2 * $backslashes))) } + [void]$quoted.Append('"') + return $quoted.ToString() +} + +function Invoke-NativeCaptured([string]$filePath, [string[]]$argumentList) { + $startInfo = New-Object System.Diagnostics.ProcessStartInfo + $startInfo.FileName = $filePath + $startInfo.Arguments = (($argumentList | ForEach-Object { Quote-NativeArgument $_ }) -join ' ') + $startInfo.UseShellExecute = $false + $startInfo.CreateNoWindow = $true + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + + $process = New-Object System.Diagnostics.Process + $process.StartInfo = $startInfo + if (-not $process.Start()) { throw "failed to start $filePath" } + $stdout = $process.StandardOutput.ReadToEndAsync() + $stderr = $process.StandardError.ReadToEndAsync() + $process.WaitForExit() + $output = @($stdout.Result, $stderr.Result) | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | + ForEach-Object { $_ -split "`r?`n" } | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + + return @{ + ExitCode = $process.ExitCode + Output = @($output) + } +} + function Grant-AppContainerWritableDirectory([string]$Path) { # AppContainer access is a dual check: the generated package SID grant from # MXC is necessary, but OpenClaw's SQLite staging also needs the two built-in @@ -453,14 +507,14 @@ try { "--env", "NEMOCLAW_MXC_EGRESS_LOOPBACK_PORT=29999", "--no-tty", "--", "exit" ) - # Windows PowerShell 5.1 wraps native stderr as ErrorRecord objects. Keep - # warnings in the captured diagnostic without letting them terminate the - # command before its real exit code and output are collected. - $createPrevEAP = $ErrorActionPreference - $ErrorActionPreference = "Continue" - try { $createOut = & $cli @createArgs 2>&1; $createCode = $LASTEXITCODE } - catch { $createOut = $_.Exception.Message; $createCode = 1 } - finally { $ErrorActionPreference = $createPrevEAP } + try { + $createResult = Invoke-NativeCaptured $cli $createArgs + $createOut = $createResult.Output + $createCode = $createResult.ExitCode + } catch { + $createOut = $_.Exception.Message + $createCode = 1 + } $createBenign = Show-SandboxCreate $createOut $SandboxName if ($createCode -ne 0 -and -not $createBenign) { throw "sandbox create '$SandboxName' failed (exit $createCode): $($createOut | Out-String)" diff --git a/crates/openshell-driver-mxc/examples/run-ws-agent-test.ps1 b/crates/openshell-driver-mxc/examples/run-ws-agent-test.ps1 index 0372864928..7d1be2e5f6 100644 --- a/crates/openshell-driver-mxc/examples/run-ws-agent-test.ps1 +++ b/crates/openshell-driver-mxc/examples/run-ws-agent-test.ps1 @@ -112,6 +112,60 @@ function Esc([string]$p) { return $p.Replace('\', '\\') } # Convert Windows path to forward-slash form (TOML values). function Fwd([string]$p) { return $p.Replace('\', '/') } +# Build one CreateProcess-compatible command-line argument. Windows PowerShell +# 5.1 removes embedded quotes and can split JSON values at embedded spaces when +# invoking native commands through the call operator. +function Quote-NativeArgument([string]$value) { + if ($value.Length -gt 0 -and $value -notmatch '[\s"]') { return $value } + + $quoted = New-Object System.Text.StringBuilder + [void]$quoted.Append('"') + $backslashes = 0 + foreach ($ch in $value.ToCharArray()) { + if ($ch -eq '\') { + $backslashes++ + continue + } + if ($ch -eq '"') { + [void]$quoted.Append(('\' * (2 * $backslashes + 1))) + [void]$quoted.Append('"') + } else { + if ($backslashes -gt 0) { [void]$quoted.Append(('\' * $backslashes)) } + [void]$quoted.Append($ch) + } + $backslashes = 0 + } + if ($backslashes -gt 0) { [void]$quoted.Append(('\' * (2 * $backslashes))) } + [void]$quoted.Append('"') + return $quoted.ToString() +} + +function Invoke-NativeCaptured([string]$filePath, [string[]]$argumentList) { + $startInfo = New-Object System.Diagnostics.ProcessStartInfo + $startInfo.FileName = $filePath + $startInfo.Arguments = (($argumentList | ForEach-Object { Quote-NativeArgument $_ }) -join ' ') + $startInfo.UseShellExecute = $false + $startInfo.CreateNoWindow = $true + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + + $process = New-Object System.Diagnostics.Process + $process.StartInfo = $startInfo + if (-not $process.Start()) { throw "failed to start $filePath" } + $stdout = $process.StandardOutput.ReadToEndAsync() + $stderr = $process.StandardError.ReadToEndAsync() + $process.WaitForExit() + $output = @($stdout.Result, $stderr.Result) | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | + ForEach-Object { $_ -split "`r?`n" } | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + + return @{ + ExitCode = $process.ExitCode + Output = @($output) + } +} + # -WsPort is NOT actually wired through end to end: the in-sandbox server's # port is a compile-time const (WS_PORT = 22000 in mxc-ws-agent.rs) -- the # TOML generation below doesn't patch it. Rather than silently accept an @@ -485,14 +539,15 @@ try { # Use the same pattern as run-mxc-e2e.ps1: pass --no-tty with a no-op # command so the CLI fires the SSH attempt, fails quickly (connection # refused), and returns. Do NOT gate on exit code here. - $createOut = & $cli sandbox create ` - --name $sandboxName ` - --policy $policyUsed ` - --driver-config-json $driverConfigJson ` - --no-tty ` - -- cmd.exe /c exit 0 ` - 2>&1 - $createExitCode = $LASTEXITCODE + $createResult = Invoke-NativeCaptured $cli @( + "sandbox", "create", + "--name", $sandboxName, + "--policy", $policyUsed, + "--driver-config-json", $driverConfigJson, + "--no-tty", "--", "cmd.exe", "/c", "exit", "0" + ) + $createOut = $createResult.Output + $createExitCode = $createResult.ExitCode } catch { $createOut = $_.Exception.Message; $createExitCode = 1 } diff --git a/crates/openshell-driver-mxc/tests/powershell_native_arguments.rs b/crates/openshell-driver-mxc/tests/powershell_native_arguments.rs new file mode 100644 index 0000000000..27c33b3837 --- /dev/null +++ b/crates/openshell-driver-mxc/tests/powershell_native_arguments.rs @@ -0,0 +1,95 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#[cfg(windows)] +#[test] +fn shipped_runners_preserve_driver_config_json_in_windows_powershell() { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + let directory = tempfile::tempdir().expect("create native-argument test directory"); + let receiver = directory.path().join("capture native arguments.ps1"); + std::fs::write( + &receiver, + r#" +param( + [Parameter(Mandatory = $true, Position = 0)] [string] $Before, + [Parameter(Mandatory = $true, Position = 1)] [string] $DriverConfigJson, + [Parameter(Mandatory = $true, Position = 2)] [string] $After +) + +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 +Write-Output "BEFORE=$Before" +Write-Output "JSON=$DriverConfigJson" +Write-Output "AFTER=$After" +"#, + ) + .expect("write native-argument receiver"); + + let verifier = r#" +$ErrorActionPreference = "Stop" +$tokens = $null +$errors = $null +$ast = [System.Management.Automation.Language.Parser]::ParseFile( + $env:OPENSHELL_RUNNER_PATH, + [ref] $tokens, + [ref] $errors +) +if ($errors.Count -gt 0) { + throw "runner has PowerShell syntax errors: $($errors.Message -join '; ')" +} + +foreach ($name in @("Quote-NativeArgument", "Invoke-NativeCaptured")) { + $functionAst = $ast.Find({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -eq $name + }, $true) + if ($null -eq $functionAst) { throw "$name is missing" } + Invoke-Expression $functionAst.Extent.Text +} + +$expected = @{ + mxc = @{ + command = @("C:/Program Files/OpenShell/agent.exe", "server mode") + cwd = "C:/work/path with spaces" + } +} | ConvertTo-Json -Compress -Depth 4 +$powershell = Join-Path $env:SystemRoot "System32/WindowsPowerShell/v1.0/powershell.exe" +$result = Invoke-NativeCaptured $powershell @( + "-NoLogo", "-NoProfile", "-NonInteractive", "-File", + $env:OPENSHELL_ARGUMENT_RECEIVER, + "before value", $expected, "after value" +) +if ($result.ExitCode -ne 0) { + throw "argument receiver exited $($result.ExitCode): $($result.Output -join [Environment]::NewLine)" +} + +$lines = @(($result.Output -join "`n") -split "`r?`n") +$before = $lines | Where-Object { $_ -like "BEFORE=*" } | Select-Object -First 1 +$json = $lines | Where-Object { $_ -like "JSON=*" } | Select-Object -First 1 +$after = $lines | Where-Object { $_ -like "AFTER=*" } | Select-Object -First 1 +if ($before -ne "BEFORE=before value") { throw "leading argument changed: $before" } +if ($null -eq $json -or $json.Substring(5) -cne $expected) { + throw "driver config JSON changed: expected '$expected', captured '$json'" +} +if ($after -ne "AFTER=after value") { throw "trailing argument changed: $after" } +"#; + + for runner in ["run-ws-agent-test.ps1", "run-openclaw-forward-test.ps1"] { + let runner_path = root.join("examples").join(runner); + let output = std::process::Command::new("powershell.exe") + .args(["-NoLogo", "-NoProfile", "-NonInteractive", "-Command"]) + .arg(verifier) + .env("OPENSHELL_RUNNER_PATH", &runner_path) + .env("OPENSHELL_ARGUMENT_RECEIVER", &receiver) + .output() + .unwrap_or_else(|error| { + panic!("failed to launch Windows PowerShell for {runner}: {error}") + }); + assert!( + output.status.success(), + "{runner} corrupted a native argument:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + } +} From bd49d45cb4ce5bf86eb941e52b3dd2ee42572122 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas <34834085+prekshivyas@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:30:25 -0700 Subject: [PATCH 27/42] docs(mxc): document Windows host preparation (NVBug 6842834) (#3892) Document standalone MXC executable placement and condition system-drive ACL preparation on the AppContainer + DACL tier and probe recommendation. Explain the persistent metadata-only grant and link upstream verification and rollback guidance. NVBug: 6842834 Signed-off-by: Prekshi Vyas Signed-off-by: Shailendra Singh --- crates/openshell-driver-mxc/README.md | 32 ++++++++++++++++++ .../examples/README-ocsf-audit.txt | 21 +++++++++++- .../examples/README-openclaw-forward.txt | 21 +++++++++++- .../openshell-driver-mxc/examples/README.txt | 21 +++++++++++- docs/reference/gateway-config.mdx | 33 +++++++++++++++++++ 5 files changed, 125 insertions(+), 3 deletions(-) diff --git a/crates/openshell-driver-mxc/README.md b/crates/openshell-driver-mxc/README.md index 7e88e24f79..32b2908ad3 100644 --- a/crates/openshell-driver-mxc/README.md +++ b/crates/openshell-driver-mxc/README.md @@ -168,6 +168,38 @@ The MXC credential handoff is also fixed at sandbox creation. The gateway reject `openshell.exe`. Diagnose executable blocks with event 3077 in the `Microsoft-Windows-CodeIntegrity/Operational` log. +Place `wxc-exec.exe` in a regular directory outside any installed MSIX or +`WindowsApps` package directory. A package directory's ACLs can prevent the +gateway from starting the executable outside its package context, even when +the same binary runs after it is copied elsewhere. If sandbox creation fails +with `wxc-exec spawn failed: Access is denied. (os error 5)`, verify that +`wxc_exec_path` points to the standalone copy. + +Before the first live `process_container` run, check the selected isolation +tier and host-preparation warnings in PowerShell: + +```powershell +& "C:\path\to\wxc-exec.exe" --probe +``` + +If the probe selects AppContainer + DACL (`appcontainer-dacl`) and recommends +`prepare-system-drive`, obtain `wxc-host-prep.exe` from the MXC binaries +distribution and run this command once in an elevated PowerShell session. +BaseContainer and AppContainer + BFS do not require this preparation. + +```powershell +& "C:\path\to\wxc-host-prep.exe" prepare-system-drive +``` + +This persistent, host-wide change adds non-inheriting ACL entries that give +the AppContainer well-known SIDs metadata access to the system-drive root. It +does not grant directory listing or write access, or change descendant ACLs. +If `wxc-exec.exe` starts but the agent exits with +`wxc-exec stderr: Access is denied.` or exit code 1, check the probe warnings +before changing host ACLs; these errors alone do not identify missing host +preparation. See Microsoft's [MXC host-preparation guide](https://github.com/microsoft/mxc/blob/main/docs/host-prep.md) +for verification, other host prerequisites, and rollback instructions. + For off-box smoke tests against the in-process mock shim (no `wxc-exec`, no isolation session needed), set `OPENSHELL_MXC_MOCK_WXC=1`. diff --git a/crates/openshell-driver-mxc/examples/README-ocsf-audit.txt b/crates/openshell-driver-mxc/examples/README-ocsf-audit.txt index 51bedd26a7..c81a047aed 100644 --- a/crates/openshell-driver-mxc/examples/README-ocsf-audit.txt +++ b/crates/openshell-driver-mxc/examples/README-ocsf-audit.txt @@ -21,7 +21,26 @@ WHAT THIS PROVES / PRODUCES [2004] Detection Finding - MXC setup activity errors (informational) PREREQUISITES (on this test box) - - wxc-exec.exe present (default expected: C:\mxc-kit\bin\wxc-exec.exe) + - wxc-exec.exe present (default expected: C:\mxc-kit\bin\wxc-exec.exe). + Use a standalone copy in a regular directory, not a copy inside an + installed MSIX or WindowsApps package directory. If CreateSandbox reports + "wxc-exec spawn failed: Access is denied. (os error 5)", check this path. + - Before the first process_container run, check the selected isolation tier + and host-preparation warnings in PowerShell: + & "C:\path\to\wxc-exec.exe" --probe + Only if the probe selects AppContainer + DACL (appcontainer-dacl) and + recommends prepare-system-drive, obtain wxc-host-prep.exe from the MXC + binaries distribution and run this once in elevated PowerShell: + & "C:\path\to\wxc-host-prep.exe" prepare-system-drive + BaseContainer and AppContainer + BFS do not require this preparation. + The persistent, host-wide ACL change grants the AppContainer well-known + SIDs metadata access to the system-drive root only; it does not grant + directory listing or write access, or change descendant ACLs. + If wxc-exec starts but the agent exits with "wxc-exec stderr: Access is + denied." or exit code 1, check the probe warnings before changing host + ACLs; these errors alone do not identify missing host preparation. + For verification, other host prerequisites, and rollback, see: + https://github.com/microsoft/mxc/blob/main/docs/host-prep.md - process_container backend live (it was for our earlier runs) - Run ELEVATED (Run as administrator) OR from an account in the 'Performance Log Users' group. Opening the real-time ETW session needs this; diff --git a/crates/openshell-driver-mxc/examples/README-openclaw-forward.txt b/crates/openshell-driver-mxc/examples/README-openclaw-forward.txt index c3aa149896..810cd2a9b8 100644 --- a/crates/openshell-driver-mxc/examples/README-openclaw-forward.txt +++ b/crates/openshell-driver-mxc/examples/README-openclaw-forward.txt @@ -40,7 +40,26 @@ PREREQUISITES (on this test box) ownership. Non-elevated fails both with ERROR_ACCESS_DENIED (0x5), the second as "Network proxy error: Failed to set loopback exemption: 0x00000005". -Backend isolation_session does not hit either path. - - wxc-exec.exe present (default expected: C:\mxc-kit\bin\wxc-exec.exe) + - wxc-exec.exe present (default expected: C:\mxc-kit\bin\wxc-exec.exe). + Use a standalone copy in a regular directory, not a copy inside an + installed MSIX or WindowsApps package directory. If CreateSandbox reports + "wxc-exec spawn failed: Access is denied. (os error 5)", check this path. + - Before the first process_container run, check the selected isolation tier + and host-preparation warnings in PowerShell: + & "C:\path\to\wxc-exec.exe" --probe + Only if the probe selects AppContainer + DACL (appcontainer-dacl) and + recommends prepare-system-drive, obtain wxc-host-prep.exe from the MXC + binaries distribution and run this once in elevated PowerShell: + & "C:\path\to\wxc-host-prep.exe" prepare-system-drive + BaseContainer and AppContainer + BFS do not require this preparation. + The persistent, host-wide ACL change grants the AppContainer well-known + SIDs metadata access to the system-drive root only; it does not grant + directory listing or write access, or change descendant ACLs. + If wxc-exec starts but the agent exits with "wxc-exec stderr: Access is + denied." or exit code 1, check the probe warnings before changing host + ACLs; these errors alone do not identify missing host preparation. + For verification, other host prerequisites, and rollback, see: + https://github.com/microsoft/mxc/blob/main/docs/host-prep.md - process_container or isolation_session backend live (whichever -Backend you pass) - Your own OpenClaw install: a node.exe binary + the openclaw npm package diff --git a/crates/openshell-driver-mxc/examples/README.txt b/crates/openshell-driver-mxc/examples/README.txt index 659124f9e5..6e010dbd00 100644 --- a/crates/openshell-driver-mxc/examples/README.txt +++ b/crates/openshell-driver-mxc/examples/README.txt @@ -17,7 +17,26 @@ Prerequisites - openshell-gateway.exe and openshell.exe beside these files, or explicit -GatewayPath and -CliPath arguments. - wxc-exec.exe beside these files, on PATH, named by - OPENSHELL_WXC_EXEC_PATH, or passed with -WxcExecPath. + OPENSHELL_WXC_EXEC_PATH, or passed with -WxcExecPath. Use a standalone + copy in a regular directory. Do not use a copy from an installed MSIX or + WindowsApps package directory; its package ACLs can make CreateSandbox + fail with "wxc-exec spawn failed: Access is denied. (os error 5)". + - Before the first process_container run, check the selected isolation tier + and host-preparation warnings in PowerShell: + & "C:\path\to\wxc-exec.exe" --probe + Only if the probe selects AppContainer + DACL (appcontainer-dacl) and + recommends prepare-system-drive, obtain wxc-host-prep.exe from the MXC + binaries distribution and run this once in elevated PowerShell: + & "C:\path\to\wxc-host-prep.exe" prepare-system-drive + BaseContainer and AppContainer + BFS do not require this preparation. + The persistent, host-wide ACL change grants the AppContainer well-known + SIDs metadata access to the system-drive root only; it does not grant + directory listing or write access, or change descendant ACLs. + If wxc-exec starts but the agent exits with "wxc-exec stderr: Access is + denied." or exit code 1, check the probe warnings before changing host + ACLs; these errors alone do not identify missing host preparation. + For verification, other host prerequisites, and rollback, see: + https://github.com/microsoft/mxc/blob/main/docs/host-prep.md - Local demo: an Ollama-compatible service on 127.0.0.1:11434 by default. Override -OllamaHost, -OllamaPort, and -Model when needed. - Cloud demo: NV_API_KEY and outbound HTTPS to integrate.api.nvidia.com. diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index dce1b313a5..ab1702a896 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -933,6 +933,39 @@ mount setup to bypass that profile. The MXC driver is Windows-only and opt-in. It links into the gateway, invokes Microsoft MXC through `wxc-exec.exe`, and runs each sandbox's configured command in-driver instead of using the Linux sandbox supervisor. +#### Prepare the Windows host + +Copy `wxc-exec.exe` to a regular directory outside any installed MSIX or +`WindowsApps` package directory. A package directory's ACLs can prevent the +gateway from starting the executable outside its package context. If sandbox +creation fails with `wxc-exec spawn failed: Access is denied. (os error 5)`, +verify that `wxc_exec_path` points to the standalone copy. + +Before the first live `process_container` run, check the selected isolation +tier and host-preparation warnings in PowerShell: + +```shell +& "C:\path\to\wxc-exec.exe" --probe +``` + +If the probe selects AppContainer + DACL (`appcontainer-dacl`) and recommends +`prepare-system-drive`, obtain `wxc-host-prep.exe` from the MXC binaries +distribution and run this command once in an elevated PowerShell session. +BaseContainer and AppContainer + BFS do not require this preparation. + +```shell +& "C:\path\to\wxc-host-prep.exe" prepare-system-drive +``` + +This persistent, host-wide change adds non-inheriting ACL entries that give +the AppContainer well-known SIDs metadata access to the system-drive root. It +does not grant directory listing or write access, or change descendant ACLs. +If `wxc-exec.exe` starts but the agent exits with +`wxc-exec stderr: Access is denied.` or exit code 1, check the probe warnings +before changing host ACLs; these errors alone do not identify missing host +preparation. See Microsoft's [MXC host-preparation guide](https://github.com/microsoft/mxc/blob/main/docs/host-prep.md) +for verification, other host prerequisites, and rollback instructions. + ```toml [openshell] version = 1 From d46a814141d8f2b19b5eaf6daa12c4c9680814f8 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas <34834085+prekshivyas@users.noreply.github.com> Date: Tue, 29 Sep 2026 19:50:53 -0700 Subject: [PATCH 28/42] ci(windows): exercise MXC credential, audit, and aggregate E2E flows (#3787) * ci(windows): exercise MXC provider credential example Signed-off-by: Prekshi Vyas * ci(windows): exercise MXC OCSF audit example Signed-off-by: Prekshi Vyas * ci(windows): enable aggregate MXC example E2E Signed-off-by: Prekshi Vyas * ci(windows): select native MXC mock target Signed-off-by: Prekshi Vyas * fix(windows): isolate MXC example CI harnesses Signed-off-by: Prekshi Vyas --------- Signed-off-by: Prekshi Vyas --- .../build-openshell-mxc-windows/SKILL.md | 28 ++- .github/workflows/windows-msvc.yml | 12 ++ architecture/windows.md | 11 ++ .../mxc-provider-credential-probe.ps1 | 8 +- .../examples/mxc-provider-credential.toml | 3 + .../examples/run-mxc-e2e.ps1 | 78 ++++++++- .../examples/run-ocsf-audit.ps1 | 161 ++++++++++++++--- .../examples/run-provider-credential-test.ps1 | 107 ++++++++---- crates/openshell-driver-mxc/src/mxc.rs | 55 +++++- .../tests/demo_examples.rs | 110 +++++++++++- .../tests/windows_e2e_harness.rs | 141 +++++++++++++++ tasks/scripts/windows-mxc-aggregate-e2e.ps1 | 150 ++++++++++++++++ tasks/scripts/windows-mxc-e2e-environment.ps1 | 76 ++++++++ tasks/scripts/windows-mxc-ocsf-audit-e2e.ps1 | 161 +++++++++++++++++ .../windows-mxc-provider-credential-e2e.ps1 | 164 ++++++++++++++++++ tasks/windows.toml | 15 ++ 16 files changed, 1200 insertions(+), 80 deletions(-) create mode 100644 crates/openshell-driver-mxc/tests/windows_e2e_harness.rs create mode 100644 tasks/scripts/windows-mxc-aggregate-e2e.ps1 create mode 100644 tasks/scripts/windows-mxc-e2e-environment.ps1 create mode 100644 tasks/scripts/windows-mxc-ocsf-audit-e2e.ps1 create mode 100644 tasks/scripts/windows-mxc-provider-credential-e2e.ps1 diff --git a/.agents/skills/build-openshell-mxc-windows/SKILL.md b/.agents/skills/build-openshell-mxc-windows/SKILL.md index 18e69d943d..a6d86395bb 100644 --- a/.agents/skills/build-openshell-mxc-windows/SKILL.md +++ b/.agents/skills/build-openshell-mxc-windows/SKILL.md @@ -53,6 +53,8 @@ In scope: - Running focused unsupported-driver contract tests. - Running the shipped Ollama and cloud-inference MXC examples against the in-process `wxc` mock and a local API stub. +- Running the shipped provider-credential, OCSF audit, and aggregate MXC + examples against the in-process `wxc` mock. - Reporting test counts, skipped/gated areas, warnings, artifacts, and logs. - Keeping Linux and macOS build paths unchanged. - Keeping unsupported Windows compute drivers explicit and testable. @@ -161,6 +163,9 @@ mise run --skip-tools windows:build:arm64 mise run --skip-tools windows:test:x64 mise run --skip-tools windows:test:unsupported:x64 mise run --skip-tools windows:e2e:mxc:inference-mock:x64 +mise run --skip-tools windows:e2e:mxc:provider-mock +mise run --skip-tools windows:e2e:mxc:ocsf-mock +mise run --skip-tools windows:e2e:mxc:aggregate-mock ``` Use the `arm64` form of the inference task on a native ARM64 host. It exercises @@ -169,6 +174,13 @@ mock and local HTTP responses. It proves example wiring, request execution, and sandbox-scoped credential propagation; it does not prove MXC, AppContainer, filesystem, or network enforcement. +The provider mock proves provider/profile/policy attachment and placeholder +propagation without exposing the synthetic credential. The OCSF mock proves +the durable zero-provider-events diagnostic path. The aggregate mock exercises +the read-write, read-only, default-deny, and unsupported-network-policy +scenarios, including a staging path containing spaces. These remain wiring +checks; native MXC qualification is required for enforcement evidence. + The two `windows:test:mxc-real:*` tasks are host-specific and mutually exclusive on a single host (each rejects the other architecture -- see the table below): run `windows:test:mxc-real:x64` on an x64 host, or @@ -231,10 +243,11 @@ entries for Cargo registry and dependency target artifacts with sccache's GHA backend for cacheable Rust compiler outputs. Failed runs also save their usable dependency artifacts. Pull-request mirrors labeled `test:windows` run Clippy for the Windows-supported workspace and e2e crates plus Rust tests, build -release binaries, and run both shipped MXC inference examples through the mock -task. Pushes to `main` and manual dispatches run the same lint and test commands -in a cache-seed job, followed by a dependent release-binary build and -mock-example job. The seed and PR jobs use the same cache namespaces. Merge +release binaries, and run the inference, provider-credential, OCSF audit, and +aggregate shipped examples through their mock tasks. Pushes to `main` and +manual dispatches run the same lint and test commands in a cache-seed job, +followed by a dependent release-binary build and mock-example job. The seed and +PR jobs use the same cache namespaces. Merge queues do not run this workflow. Main/manual seed and build jobs use job-level `continue-on-error: true`; opt-in PR jobs report failures normally. Applying the label alone does not start a run: re-run all jobs in the current mirror @@ -286,6 +299,9 @@ crypto dependency builds. | `windows:test:mxc-gb300:arm64` | Runs the required native ARM64 ProcessContainer subset and fails when a test skips. | | `windows:e2e:mxc:inference-mock:x64` | Runs the shipped Ollama and cloud-inference demos on native x64 through the real gateway and CLI, in-process `wxc` mock, and local API stub. This is wiring evidence, not MXC enforcement evidence. | | `windows:e2e:mxc:inference-mock:arm64` | Runs the same mock-wiring checks on native ARM64 with ARM64 release binaries. | +| `windows:e2e:mxc:provider-mock` | Runs the shipped provider-credential demo with a synthetic credential and verifies placeholder propagation without leaking the credential. | +| `windows:e2e:mxc:ocsf-mock` | Runs the shipped OCSF audit demo and verifies the durable zero-provider-events finding. | +| `windows:e2e:mxc:aggregate-mock` | Runs the four aggregate MXC scenarios through the mock, including a staging path containing spaces. | | `windows:qualify:mxc:gb300:contract` | Validates the required/optional/unsupported/architecture-constrained GB300 matrix. | | `windows:qualify:mxc:gb300` | Runs the fail-closed GB300 ARM64 gate and validates hash-bound evidence. | | `windows:artifacts` | Reports size and SHA256 for release artifacts that exist. | @@ -338,8 +354,8 @@ When reporting `windows:ci`, distinguish these categories: - Passed tests from the full ARM64 workspace test log when run on a native ARM64 host. - The focused unsupported-contract re-run. -- The architecture-matched MXC inference-example mock task and its explicit - wiring-only limitation. +- The architecture-matched MXC inference, provider-credential, OCSF audit, and + aggregate mock tasks and their explicit wiring-only limitation. - Explicit Cargo ignored tests, usually ignored doc examples. - Tests hidden by `#[cfg(not(target_os = "windows"))]`; these often appear as `running 0 tests`, not as ignored tests. diff --git a/.github/workflows/windows-msvc.yml b/.github/workflows/windows-msvc.yml index 1b7964eb20..34ebd9499f 100644 --- a/.github/workflows/windows-msvc.yml +++ b/.github/workflows/windows-msvc.yml @@ -100,6 +100,12 @@ jobs: run: mise run --skip-tools windows:test:${{ matrix.arch }} - name: Build MXC example E2E binaries run: mise run --skip-tools windows:build:${{ matrix.arch }} + - name: Run shipped MXC provider-credential example in mock mode + run: mise run --skip-tools windows:e2e:mxc:provider-mock + - name: Run shipped MXC OCSF audit example in mock mode + run: mise run --skip-tools windows:e2e:mxc:ocsf-mock + - name: Run shipped aggregate MXC scenarios in mock mode + run: mise run --skip-tools windows:e2e:mxc:aggregate-mock - name: Run shipped MXC inference examples with mock API run: mise run --skip-tools windows:e2e:mxc:inference-mock:${{ matrix.arch }} - name: sccache stats @@ -215,6 +221,12 @@ jobs: cache-bin: "false" - name: Build release binaries run: mise run --skip-tools windows:build:${{ matrix.arch }} + - name: Run shipped MXC provider-credential example in mock mode + run: mise run --skip-tools windows:e2e:mxc:provider-mock + - name: Run shipped MXC OCSF audit example in mock mode + run: mise run --skip-tools windows:e2e:mxc:ocsf-mock + - name: Run shipped aggregate MXC scenarios in mock mode + run: mise run --skip-tools windows:e2e:mxc:aggregate-mock - name: Run shipped MXC inference examples with mock API run: mise run --skip-tools windows:e2e:mxc:inference-mock:${{ matrix.arch }} - name: sccache stats diff --git a/architecture/windows.md b/architecture/windows.md index abf5043543..8180f25762 100644 --- a/architecture/windows.md +++ b/architecture/windows.md @@ -248,6 +248,17 @@ Windows validation separates source correctness from host capability: workspace and unsupported-driver contract tests for x64 and ARM64. - Mock MXC E2E validates gateway, CLI, driver, lifecycle, and policy wiring but is not evidence of OS enforcement. +- Hosted Windows CI runs the shipped provider-credential example with a + synthetic value. It verifies provider/profile/policy attachment and that the + mock workload receives only a revision-scoped placeholder. It makes no + external request and does not prove proxy substitution or MXC enforcement. +- The shipped OCSF audit example also runs with mock MXC. It verifies ETW + consumer startup, mock workload wiring, durable JSONL output, and the + zero-provider-events finding. Real provider events and attribution still + require native MXC qualification. +- The aggregate example runs its read-write, read-only, default-deny, and + unsupported-network-policy scenarios in mock mode. This validates the + shipped runner and expected policy paths without claiming native enforcement. - Hosted Windows CI runs the shipped Ollama and cloud-inference demos against that mock and a local compatible API. This proves both complete demo scripts, sandbox-scoped credential propagation, and response paths without a model or diff --git a/crates/openshell-driver-mxc/examples/mxc-provider-credential-probe.ps1 b/crates/openshell-driver-mxc/examples/mxc-provider-credential-probe.ps1 index 56477c2342..eaa37d4e5e 100644 --- a/crates/openshell-driver-mxc/examples/mxc-provider-credential-probe.ps1 +++ b/crates/openshell-driver-mxc/examples/mxc-provider-credential-probe.ps1 @@ -7,7 +7,8 @@ [CmdletBinding()] param( - [string] $OutputDir = "C:\work\openshell-mxc-provider" + [string] $OutputDir = "C:\work\openshell-mxc-provider", + [switch] $Mock ) $ErrorActionPreference = "Stop" @@ -127,6 +128,11 @@ try { } $checks += "[PASS] MXC received only a revision-scoped GITHUB_TOKEN placeholder" + if ($Mock) { + $checks += "[PASS] no external network request was made in mock mode" + Complete-Probe $checks + } + $curlPath = Join-Path $env:SystemRoot "System32\curl.exe" if (-not (Test-Path $curlPath)) { $checks += "[FAIL] inbox curl.exe is unavailable" diff --git a/crates/openshell-driver-mxc/examples/mxc-provider-credential.toml b/crates/openshell-driver-mxc/examples/mxc-provider-credential.toml index 65cd4a21c1..150064cc65 100644 --- a/crates/openshell-driver-mxc/examples/mxc-provider-credential.toml +++ b/crates/openshell-driver-mxc/examples/mxc-provider-credential.toml @@ -4,6 +4,9 @@ # Gateway configuration for run-provider-credential-test.ps1. The runner writes # a disposable copy with the requested wxc-exec path. +[openshell] +version = 2 + [openshell.drivers.mxc] wxc_exec_path = "C:\\mxc-kit\\bin\\wxc-exec.exe" backend = "process_container" diff --git a/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 b/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 index 0cefca119b..060b7c6f79 100644 --- a/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 +++ b/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 @@ -40,6 +40,8 @@ param( [string] $DemoDir = "C:\work\openshell-mxc-e2e", [string] $WxcExecPath = "C:\mxc-kit\bin\wxc-exec.exe", + [string] $GatewayPath, + [string] $CliPath, [ValidateSet("isolation_session", "process_container")] [string] $Backend = "process_container", [string] $Scenario, @@ -105,6 +107,14 @@ function Quote-NativeArgument([string]$value) { return $quoted.ToString() } +function New-CmdWriteCommand([string]$value, [string]$path) { + if ($path.Contains('"')) { + throw "cmd.exe write target contains an invalid quote: $path" + } + $normalizedPath = $path.Replace('\', '/') + return "echo $value 1> `"$normalizedPath`"" +} + function Invoke-NativeCaptured([string]$filePath, [string[]]$argumentList) { $startInfo = New-Object System.Diagnostics.ProcessStartInfo $startInfo.FileName = $filePath @@ -139,8 +149,16 @@ function Invoke-NativeCaptured([string]$filePath, [string[]]$argumentList) { # --- Path variables ----------------------------------------------------------- -$gateway = Join-Path $here "openshell-gateway.exe" -$cli = Join-Path $here "openshell.exe" +$gateway = if ([string]::IsNullOrWhiteSpace($GatewayPath)) { + Join-Path $here "openshell-gateway.exe" +} else { + [System.IO.Path]::GetFullPath($GatewayPath) +} +$cli = if ([string]::IsNullOrWhiteSpace($CliPath)) { + Join-Path $here "openshell.exe" +} else { + [System.IO.Path]::GetFullPath($CliPath) +} $toml = Join-Path $here "mxc-gateway.toml" $policyDir = Join-Path $here "e2e-policies" @@ -534,18 +552,27 @@ try { $gwErrLog = Join-Path $resultDir "gateway.$($sc.Name).err.log" # Build the per-sandbox workload command and clean prior artifacts. + $mockDenySandboxName = $null + $mockDenyAttempted = $true if ($sc.Kind -eq "positive") { Remove-Item $sc.PosTarget -Force -ErrorAction SilentlyContinue - $command = @($cmdExe, "/c", "echo ok 1> $($sc.PosTarget.Replace('\', '/'))") + $command = @($cmdExe, "/c", (New-CmdWriteCommand "ok" $sc.PosTarget)) } elseif ($sc.Kind -eq "deny") { Remove-Item $sc.DenyTarget -Force -ErrorAction SilentlyContinue - $denied = $sc.DenyTarget.Replace('\', '/') + $deniedCommand = New-CmdWriteCommand "denied" $sc.DenyTarget if ($sc.ControlTarget) { Remove-Item $sc.ControlTarget -Force -ErrorAction SilentlyContinue - $control = $sc.ControlTarget.Replace('\', '/') - $command = @($cmdExe, "/c", "echo ok 1> $control & echo denied 1> $denied") + $controlCommand = New-CmdWriteCommand "ok" $sc.ControlTarget + if ($Mock) { + # The in-process shim authorizes or denies a whole command. + # Use a separate denied-only launch below so mock CI exercises + # both branches instead of allowing both redirects together. + $command = @($cmdExe, "/c", $controlCommand) + } else { + $command = @($cmdExe, "/c", "$controlCommand & $deniedCommand") + } } else { - $command = @($cmdExe, "/c", "echo denied 1> $denied") + $command = @($cmdExe, "/c", $deniedCommand) } } else { $command = @($cmdExe, "/c", "exit 0") @@ -592,6 +619,34 @@ try { $gwText = (Get-Content $gwLog, $gwErrLog -Raw -ErrorAction SilentlyContinue) -join "`n" + if ($Mock -and $sc.Kind -eq "deny" -and $sc.ControlTarget) { + # Run the negative half separately because the mock shim makes a + # whole-command decision. A new launch message proves the denied + # command reached the shim; absence of its artifact scores denial. + $controlReady = Wait-File $sc.ControlTarget 30 + $launchCountBefore = ([regex]::Matches($gwText, 'MXC agent launched')).Count + $denyDriverConfig = @{ + mxc = @{ + command = @($cmdExe, "/c", $deniedCommand) + cwd = $demoDirFwd + } + } | ConvertTo-Json -Compress -Depth 4 + $mockDenySandboxName = "mxc-rd-$runId" + $denyCreateResult = Invoke-NativeCaptured $cli @( + "sandbox", "create", "--name", $mockDenySandboxName, + "--policy", [string]$policyUsed, + "--driver-config-json", $denyDriverConfig, + "--no-tty" + ) + Start-Sleep -Seconds 3 + $gwText = (Get-Content $gwLog, $gwErrLog -Raw -ErrorAction SilentlyContinue) -join "`n" + $launchCountAfter = ([regex]::Matches($gwText, 'MXC agent launched')).Count + $mockDenyAttempted = $controlReady -and + $denyCreateResult.ExitCode -eq 0 -and + $launchCountAfter -gt $launchCountBefore + Info "mock deny create exit: $($denyCreateResult.ExitCode); launch observed: $($launchCountAfter -gt $launchCountBefore)" + } + # Evaluate. if ($sc.Kind -eq "create-fail") { # A non-zero exit alone is NOT sufficient: gateway-registration, @@ -638,7 +693,7 @@ try { # late denied write (enforcement regression racing the control write) # cannot be recorded as PASS. $denyPresent = Test-Path $sc.DenyTarget - if ($controlPresent -and -not $denyPresent) { + if ($controlPresent -and $mockDenyAttempted -and -not $denyPresent) { Ok "$($sc.Name): control write succeeded; denied write correctly blocked" $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "PASS"; Reason = "control present, deny absent" } } elseif (-not $controlPresent) { @@ -646,6 +701,9 @@ try { Info "createOut: $createOutStr" if (Launch-Failed $gwText) { Info "gateway log shows an agent-launch failure" } $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "FAIL"; Reason = "control absent (agent did not run)" } + } elseif (-not $mockDenyAttempted) { + Bad "$($sc.Name): mock deny launch was not observed - denial is inconclusive" + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "FAIL"; Reason = "mock deny launch not confirmed" } } else { Bad "$($sc.Name): denied write was NOT blocked (artifact present)" $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "FAIL"; Reason = "deny target present (not enforced)" } @@ -683,6 +741,9 @@ try { # Real MXC startup is asynchronous and can otherwise be canceled # before the workload writes its positive/control proof. try { Invoke-NativeCaptured $cli @("sandbox", "delete", $sandboxName) | Out-Null } catch {} + if ($mockDenySandboxName) { + try { Invoke-NativeCaptured $cli @("sandbox", "delete", $mockDenySandboxName) | Out-Null } catch {} + } Stop-Gw $gw $gw = $null } @@ -735,6 +796,7 @@ Files in this bundle ($resultDir): What PASS means: every non-skipped scenario met its expected verdict - positive writes produced their artifact, deny writes were blocked with either a control write or driver-launch evidence, and network-reject was refused by policy. +$(if ($Mock) { 'Mock mode validates runner/gateway/driver wiring against the in-process shim; it is not evidence of native MXC or OS enforcement.' }) "@ Set-Content -Path (Join-Path $resultDir "summary.txt") -Value $summary -Encoding UTF8 Write-Host $summary -ForegroundColor ($(if ($verdict -eq "PASS") { "Green" } else { "Red" })) diff --git a/crates/openshell-driver-mxc/examples/run-ocsf-audit.ps1 b/crates/openshell-driver-mxc/examples/run-ocsf-audit.ps1 index 00d32e10ef..c410b1df8e 100644 --- a/crates/openshell-driver-mxc/examples/run-ocsf-audit.ps1 +++ b/crates/openshell-driver-mxc/examples/run-ocsf-audit.ps1 @@ -23,6 +23,11 @@ # powershell -NoProfile -ExecutionPolicy Bypass -File .\run-ocsf-audit.ps1 ` # -WxcExecPath C:\mxc-kit\bin\wxc-exec.exe # +# Hosted CI can pass -Mock. That mode runs the complete gateway/CLI/script path +# with the in-process wxc shim, then requires the ETW consumer's durable +# mxc-etw-zero-events OCSF finding. It does not prove real provider events, +# attribution, or MXC enforcement. +# # By default the per-sandbox egress proxy is ON so the full event set (including # SandboxProxyConfigured) is produced. Pass -NoProxy to omit only that one event. # @@ -34,6 +39,8 @@ param( # Real wxc-exec on the test box. [string] $WxcExecPath = "C:\mxc-kit\bin\wxc-exec.exe", + [string] $GatewayPath, + [string] $CliPath, # Host folder granted read-write in the disposable sandbox policy. [string] $ShareDir = "C:\work\openshell-mxc-demo", # How many sandboxes to create (each drives a full event burst). @@ -48,6 +55,10 @@ param( # Optional: copy the results bundle to this path (e.g. a shared drive) for # pickup. Empty by default (no copy); pass -ShareOut '\\server\share' to enable. [string] $ShareOut = "", + # Run with the in-process wxc shim and score the zero-event audit diagnostic. + [switch] $Mock, + [ValidateRange(31, 120)] + [int] $MockAuditWaitSeconds = 35, # Leave the gateway running afterward (for inspection). [switch] $KeepRunning ) @@ -71,6 +82,63 @@ function Info([string]$m) { Write-Host " $m" } function Ok([string]$m) { Write-Host "[OK] $m" -ForegroundColor Green } function Bad([string]$m) { Write-Host "[FAIL] $m" -ForegroundColor Red } +function Quote-NativeArgument([string]$value) { + if ($value.Length -gt 0 -and $value -notmatch '[\s"]') { return $value } + + $quoted = New-Object System.Text.StringBuilder + [void]$quoted.Append('"') + $backslashes = 0 + foreach ($ch in $value.ToCharArray()) { + if ($ch -eq '\') { + $backslashes++ + continue + } + if ($ch -eq '"') { + [void]$quoted.Append(('\' * (2 * $backslashes + 1))) + [void]$quoted.Append('"') + } else { + if ($backslashes -gt 0) { [void]$quoted.Append(('\' * $backslashes)) } + [void]$quoted.Append($ch) + } + $backslashes = 0 + } + if ($backslashes -gt 0) { [void]$quoted.Append(('\' * (2 * $backslashes))) } + [void]$quoted.Append('"') + return $quoted.ToString() +} + +function Invoke-Cli([string[]]$CommandArgs, [switch]$AllowFailure) { + $startInfo = New-Object System.Diagnostics.ProcessStartInfo + $startInfo.FileName = $cli + $startInfo.Arguments = (($CommandArgs | ForEach-Object { Quote-NativeArgument $_ }) -join ' ') + $startInfo.UseShellExecute = $false + $startInfo.CreateNoWindow = $true + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + + $process = New-Object System.Diagnostics.Process + $process.StartInfo = $startInfo + if (-not $process.Start()) { throw "failed to start $cli" } + $stdout = $process.StandardOutput.ReadToEndAsync() + $stderr = $process.StandardError.ReadToEndAsync() + $process.WaitForExit() + $text = (@($stdout.Result, $stderr.Result) | Where-Object { + -not [string]::IsNullOrWhiteSpace($_) + }) -join [Environment]::NewLine + $text = $text.Trim() + if (-not $AllowFailure -and $process.ExitCode -ne 0) { + throw "openshell $($CommandArgs -join ' ') failed (exit $($process.ExitCode)): $text" + } + return @{ ExitCode = $process.ExitCode; Text = $text } +} + +function Resolve-Artifact([string]$explicit, [string]$leaf) { + if (-not [string]::IsNullOrWhiteSpace($explicit)) { + return $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($explicit) + } + return (Join-Path $here $leaf) +} + function Get-MxcEtwSessions { $pattern = [regex]::Escape($SessionName) + '-(?[0-9]+)-[0-9a-fA-F]{32}-[0-9a-fA-F]{8}' foreach ($line in @(logman query -ets 2>$null)) { @@ -84,8 +152,8 @@ function Get-MxcEtwSessions { } } -$gateway = Join-Path $here "openshell-gateway.exe" -$cli = Join-Path $here "openshell.exe" +$gateway = Resolve-Artifact $GatewayPath "openshell-gateway.exe" +$cli = Resolve-Artifact $CliPath "openshell.exe" $policySrc = Join-Path $here "ocsf-audit.yaml" $policy = Join-Path $resultDir "ocsf-audit.used.yaml" # disposable policy matching -ShareDir $tomlSrc = Join-Path $here "mxc-ocsf-audit.toml" @@ -96,6 +164,7 @@ $gw = $null $gatewayEtwSessions = @() $passed = $true $proxyOn = -not $NoProxy +$oldMockWxc = $env:OPENSHELL_MXC_MOCK_WXC try { # 1. Validate artifacts + privilege. @@ -116,7 +185,10 @@ try { throw "This run must open a real-time ETW session, which needs elevation. Re-run from an elevated shell (Run as administrator) or add this account to 'Performance Log Users'." } - if (-not (Test-Path $WxcExecPath)) { + if ($Mock) { + $WxcExecPath = Join-Path $here "mock-wxc-exec.exe" + Info "mock mode: using the in-process wxc shim" + } elseif (-not (Test-Path $WxcExecPath)) { throw "wxc-exec not found at '$WxcExecPath'. Pass -WxcExecPath pointing at the real binary." } Info "wxc-exec: $WxcExecPath" @@ -163,14 +235,6 @@ try { cwd = $shareDirPolicy } } | ConvertTo-Json -Compress -Depth 4 - # Windows PowerShell 5.1 strips embedded quotes when constructing a native - # command line. Escape them so openshell.exe receives valid JSON. - $driverConfigArg = if ($PSVersionTable.PSVersion.Major -lt 7) { - $driverConfig.Replace('"', '\"') - } else { - $driverConfig - } - Info "backend=process_container etw_audit=true egress_proxy=$proxyVal" Info "workload cwd=$shareDirPolicy policy grant=$shareDirPolicy" @@ -218,14 +282,18 @@ try { # --config token: Start-Process -ArgumentList does not quote array elements, so a # config path containing a space gets split and the gateway's arg parser rejects it. $env:OPENSHELL_GATEWAY_CONFIG = $toml - Remove-Item Env:OPENSHELL_MXC_MOCK_WXC -ErrorAction SilentlyContinue + if ($Mock) { + $env:OPENSHELL_MXC_MOCK_WXC = "1" + } else { + Remove-Item Env:OPENSHELL_MXC_MOCK_WXC -ErrorAction SilentlyContinue + } # 7. Start the gateway (background, TLS disabled on the loopback control plane). Step "Start gateway (OCSF audit on)" $gwLog = Join-Path $resultDir "gateway.log" $gwErrLog = Join-Path $resultDir "gateway.err.log" $gw = Start-Process -FilePath $gateway ` - -ArgumentList @("--disable-tls", "--port", $Port, "--log-level", "info") ` + -ArgumentList @("--disable-tls", "--port", $Port, "--db-url", "sqlite::memory:", "--log-level", "info") ` -WorkingDirectory $here -PassThru -NoNewWindow ` -RedirectStandardOutput $gwLog -RedirectStandardError $gwErrLog Info "gateway pid $($gw.Id); logs -> $(Split-Path $gwLog -Leaf) (+ .err)" @@ -252,10 +320,13 @@ try { # 9. Register CLI -> gateway. Step "Register CLI -> gateway" $env:OPENSHELL_GATEWAY = "" - try { & $cli gateway add "http://127.0.0.1:$Port" --local --name $GatewayName 2>&1 | ForEach-Object { Info $_ } } - catch { Info "gateway add: $($_.Exception.Message) (continuing - likely already registered)" } - try { & $cli gateway select $GatewayName 2>&1 | ForEach-Object { Info $_ } } - catch { Info "gateway select: $($_.Exception.Message) (continuing)" } + $gatewayAdd = Invoke-Cli @("gateway", "add", "http://127.0.0.1:$Port", "--local", "--name", $GatewayName) -AllowFailure + if ($gatewayAdd.Text) { Info $gatewayAdd.Text } + if ($gatewayAdd.ExitCode -ne 0 -and $gatewayAdd.Text -notmatch '(?i)already exists') { + throw "gateway registration failed (exit $($gatewayAdd.ExitCode)): $($gatewayAdd.Text)" + } + $gatewaySelect = Invoke-Cli @("gateway", "select", $GatewayName) + if ($gatewaySelect.Text) { Info $gatewaySelect.Text } Ok "selected gateway '$GatewayName'" # 10. Create N sandboxes. Each drives the Sandboxing provider -> a full OCSF @@ -265,10 +336,21 @@ try { for ($i = 1; $i -le $SandboxCount; $i++) { $name = "ocsf$i" Info "-- creating $name --" - try { & $cli sandbox create --name $name --policy $policy --driver-config-json $driverConfigArg --no-tty 2>&1 | ForEach-Object { Info $_ } } - catch { Info "sandbox create attach: $($_.Exception.Message) (expected on MXC - agent ran in-driver; continuing)" } + $create = Invoke-Cli @( + "sandbox", "create", "--name", $name, "--policy", $policy, + "--driver-config-json", $driverConfig, "--no-tty" + ) -AllowFailure + if ($create.Text) { Info $create.Text } + if ($create.ExitCode -ne 0) { + Info "sandbox create returned exit $($create.ExitCode); workload and audit evidence will determine the verdict" + } Start-Sleep -Seconds 3 - try { & $cli sandbox delete $name 2>&1 | Out-Null } catch {} + try { Invoke-Cli @("sandbox", "delete", $name) -AllowFailure | Out-Null } catch {} + } + if ($Mock) { + Step "Wait for the ETW zero-event audit diagnostic" + Info "waiting $MockAuditWaitSeconds seconds for mxc-etw-zero-events" + Start-Sleep -Seconds $MockAuditWaitSeconds } } catch { @@ -295,6 +377,11 @@ finally { logman stop $session.Name -ets 2>&1 | Out-Null } } + if ($null -eq $oldMockWxc) { + Remove-Item Env:OPENSHELL_MXC_MOCK_WXC -ErrorAction SilentlyContinue + } else { + $env:OPENSHELL_MXC_MOCK_WXC = $oldMockWxc + } # ---- summarise the OCSF audit trail -------------------------------------- $logText = @() @@ -314,7 +401,7 @@ finally { $jsonlPath = if ($jsonlFiles.Count) { $jsonlFiles[0].FullName } else { $null } $classNames = @{ 6002 = "Application Lifecycle"; 5019 = "Device Config State Change"; 1007 = "Process Activity"; 2004 = "Detection Finding" } $classCounts = @{ 6002 = 0; 5019 = 0; 1007 = 0; 2004 = 0 } - $jsonlCount = 0; $jsonlBad = 0; $sids = @(); $hosts = @() + $jsonlCount = 0; $jsonlBad = 0; $sids = @(); $hosts = @(); $zeroEventFinding = $false if ($jsonlPath) { $raw = @(Get-Content $jsonlPath -ErrorAction SilentlyContinue | Where-Object { $_.Trim() -ne "" }) $jsonlCount = $raw.Count @@ -322,8 +409,9 @@ finally { try { $o = $line | ConvertFrom-Json if ($o.class_uid -ne $null -and $classCounts.ContainsKey([int]$o.class_uid)) { $classCounts[[int]$o.class_uid]++ } - if ($o.metadata -and $o.metadata.uid) { $sids += [string]$o.metadata.uid } + if ($o.container -and $o.container.uid) { $sids += [string]$o.container.uid } if ($o.device -and $o.device.hostname) { $hosts += [string]$o.device.hostname } + if ($o.finding_info -and $o.finding_info.uid -eq "mxc-etw-zero-events") { $zeroEventFinding = $true } } catch { $jsonlBad++ } } $sids = @($sids | Select-Object -Unique) @@ -359,7 +447,17 @@ finally { $findingsObserved = @($findingEvents.Values | Where-Object { $_ }).Count $classesSeen = @($classCounts.Keys | Where-Object { $classCounts[$_] -gt 0 }).Count $workloadCompleted = Test-Path $helloPath -PathType Leaf - if ($passed) { $passed = $consumerStarted -and (-not $consumerOverloaded) -and $workloadCompleted -and ($jsonlCount -gt 0) -and ($jsonlBad -eq 0) -and ($coreObserved -eq $coreExpected) } + $zeroEventWarning = Seen "(?i)MXC ETW->OCSF consumer has received zero events" + if ($passed) { + if ($Mock) { + $passed = $consumerStarted -and (-not $consumerFailed) -and (-not $consumerOverloaded) -and + $workloadCompleted -and ($jsonlCount -gt 0) -and ($jsonlBad -eq 0) -and + $zeroEventWarning -and $zeroEventFinding + } else { + $passed = $consumerStarted -and (-not $consumerOverloaded) -and $workloadCompleted -and + ($jsonlCount -gt 0) -and ($jsonlBad -eq 0) -and ($coreObserved -eq $coreExpected) + } + } $verdict = if ($passed) { "PASS" } else { "FAIL" } $classLines = foreach ($uid in @(6002, 5019, 1007, 2004)) { " [{0}] {1,-28} : {2}" -f $uid, $classNames[$uid], $classCounts[$uid] } @@ -374,7 +472,9 @@ timestamp : $stamp machine : $env:COMPUTERNAME user : $env:USERNAME (admin=$admin perfLogUsers=$plu) verdict : $verdict +mode : $(if ($Mock) { 'mock-wiring' } else { 'real-mxc' }) event coverage : $coreObserved of $coreExpected expected event types fired (+ $findingsObserved anomaly finding(s)) +zero-event audit : $(if ($zeroEventWarning -and $zeroEventFinding) { 'warning + durable finding observed' } else { 'not observed' }) queue overload : $(if ($consumerOverloaded) { 'YES - ETW records dropped; audit coverage gap' } else { 'no dropped ETW records observed' }) proxy : $(if ($proxyOn) { 'on (full event set)' } else { 'off (-NoProxy; omits egress proxy event)' }) workload output : $(if ($workloadCompleted) { $helloPath } else { '(missing)' }) @@ -403,12 +503,19 @@ Files in this bundle ($resultDir): mxc-ocsf-audit.used.toml the exact gateway config used (wxc path patched) ocsf-audit.used.yaml the exact sandbox policy used -What PASS means: the gateway launched sandbox(es), the in-process ETW consumer +$(if ($Mock) { +"What PASS means: the gateway launched the mock workload, the ETW consumer +started, detected that sandbox activity produced zero provider events, and +wrote the mxc-etw-zero-events finding to durable OCSF JSONL. This does not +validate real Sandboxing-provider events, sandbox attribution, or MXC enforcement." +} else { +"What PASS means: the gateway launched sandbox(es), the in-process ETW consumer started, decoded the Sandboxing provider, attributed each event to a sandbox_id, reported no callback-queue overload, mapped events to OCSF, and wrote a durable -JSONL audit log covering all $coreExpected -expected event types across $classesSeen OCSF class(es) - the full Windows OCSF path -end-to-end, at parity with the Linux pipeline. +JSONL audit log covering all $coreExpected expected event types across +$classesSeen OCSF class(es) - the full Windows OCSF path end-to-end, at parity +with the Linux pipeline." +}) "@ Set-Content -Path (Join-Path $resultDir "summary.txt") -Value $summary -Encoding UTF8 Write-Host $summary -ForegroundColor ($(if ($passed) { "Green" } else { "Red" })) diff --git a/crates/openshell-driver-mxc/examples/run-provider-credential-test.ps1 b/crates/openshell-driver-mxc/examples/run-provider-credential-test.ps1 index daffcbe424..7738737317 100644 --- a/crates/openshell-driver-mxc/examples/run-provider-credential-test.ps1 +++ b/crates/openshell-driver-mxc/examples/run-provider-credential-test.ps1 @@ -24,6 +24,10 @@ # -GatewayPath .\target\x86_64-pc-windows-msvc\release\openshell-gateway.exe ` # -CliPath .\target\x86_64-pc-windows-msvc\release\openshell.exe # +# Hosted CI can pass -Mock with a synthetic GITHUB_TOKEN. That mode proves the +# provider/profile/policy wiring and placeholder-only child environment without +# making an external request. It is not proxy-substitution or MXC evidence. +# # PowerShell 5.1-compatible. The script never prints GITHUB_TOKEN and scans all # result artifacts for accidental raw-token leakage before creating the bundle. @@ -34,7 +38,8 @@ param( [string] $GatewayPath, [string] $CliPath, [int] $Port = 17670, - [string] $GatewayName = "openshell-mxc-provider-e2e" + [string] $GatewayName = "openshell-mxc-provider-e2e", + [switch] $Mock ) $ErrorActionPreference = "Stop" @@ -212,13 +217,16 @@ $artifactScanFailed = $false $failureReason = "" $failureStage = "initialization" $githubToken = $env:GITHUB_TOKEN +$oldMockWxc = $env:OPENSHELL_MXC_MOCK_WXC try { Step "Validate prerequisites" if ([string]::IsNullOrWhiteSpace($env:GITHUB_TOKEN)) { throw "GITHUB_TOKEN is not set. Set it in this PowerShell session; do not pass it on the command line." } - foreach ($file in @($gateway, $cli, $powerShellExe, $probeTemplate, $tomlTemplate, $policyTemplate, $profileTemplate, $WxcExecPath)) { + $requiredFiles = @($gateway, $cli, $powerShellExe, $probeTemplate, $tomlTemplate, $policyTemplate, $profileTemplate) + if (-not $Mock) { $requiredFiles += $WxcExecPath } + foreach ($file in $requiredFiles) { if (-not (Test-Path $file)) { throw "missing artifact: $file" } Info "found $file" } @@ -227,24 +235,30 @@ try { if (Get-NetTCPConnection -State Listen -LocalPort $Port -ErrorAction SilentlyContinue) { throw "gateway port $Port is already in use" } - $previous = $ErrorActionPreference - $ErrorActionPreference = "Continue" - try { - $wxcProbeLines = & $WxcExecPath --probe 2>&1 - $wxcProbeExit = $LASTEXITCODE - } finally { - $ErrorActionPreference = $previous - } - $wxcProbeText = (($wxcProbeLines | ForEach-Object { - if ($_ -is [System.Management.Automation.ErrorRecord]) { - $_.Exception.Message - } else { - $_.ToString() + if ($Mock) { + $WxcExecPath = Join-Path $here "mock-wxc-exec.exe" + [System.IO.File]::WriteAllText($wxcProbeFile, "mock mode: wxc-exec was not invoked`r`n", $utf8NoBom) + Info "mock mode: using the in-process wxc shim" + } else { + $previous = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { + $wxcProbeLines = & $WxcExecPath --probe 2>&1 + $wxcProbeExit = $LASTEXITCODE + } finally { + $ErrorActionPreference = $previous + } + $wxcProbeText = (($wxcProbeLines | ForEach-Object { + if ($_ -is [System.Management.Automation.ErrorRecord]) { + $_.Exception.Message + } else { + $_.ToString() + } + }) -join [Environment]::NewLine).Trim() + [System.IO.File]::WriteAllText($wxcProbeFile, $wxcProbeText, $utf8NoBom) + if ($wxcProbeExit -ne 0) { + throw "wxc-exec --probe failed (exit $wxcProbeExit); inspect $wxcProbeFile" } - }) -join [Environment]::NewLine).Trim() - [System.IO.File]::WriteAllText($wxcProbeFile, $wxcProbeText, $utf8NoBom) - if ($wxcProbeExit -ne 0) { - throw "wxc-exec --probe failed (exit $wxcProbeExit); inspect $wxcProbeFile" } Ok "prerequisites available; token value was not printed" @@ -276,18 +290,20 @@ try { $profileText = [System.IO.File]::ReadAllText($profileTemplate, [System.Text.Encoding]::UTF8) $profileText = $profileText.Replace("C:/Windows/System32/WindowsPowerShell/v1.0/powershell.exe", $powerShellFwd) [System.IO.File]::WriteAllText($profileUsed, $profileText, $utf8NoBom) + $probeCommand = @( + $powerShellFwd, + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-File", + "$shareFwd/mxc-provider-credential-probe.ps1", + $shareFwd + ) + if ($Mock) { $probeCommand += "-Mock" } $driverConfig = @{ mxc = @{ - command = @( - $powerShellFwd, - "-NoProfile", - "-NonInteractive", - "-ExecutionPolicy", - "Bypass", - "-File", - "$shareFwd/mxc-provider-credential-probe.ps1", - $shareFwd - ) + command = $probeCommand cwd = $shareFwd } } | ConvertTo-Json -Compress -Depth 4 @@ -296,7 +312,11 @@ try { Step "Start gateway" $env:OPENSHELL_DRIVERS = "mxc" $env:OPENSHELL_GATEWAY_CONFIG = $tomlUsed - Remove-Item Env:OPENSHELL_MXC_MOCK_WXC -ErrorAction SilentlyContinue + if ($Mock) { + $env:OPENSHELL_MXC_MOCK_WXC = "1" + } else { + Remove-Item Env:OPENSHELL_MXC_MOCK_WXC -ErrorAction SilentlyContinue + } # The CLI, not the gateway process environment, supplies the provider # credential. Temporarily remove GITHUB_TOKEN while spawning the gateway so # a successful test cannot be attributed to gateway environment inheritance. @@ -304,7 +324,7 @@ try { try { try { $gw = Start-Process -FilePath $gateway ` - -ArgumentList @("--disable-tls", "--db-url", "sqlite::memory:", "--log-level", "info") ` + -ArgumentList @("--disable-tls", "--port", "$Port", "--db-url", "sqlite::memory:", "--log-level", "info") ` -WorkingDirectory $here -PassThru -NoNewWindow ` -RedirectStandardOutput $gwLog -RedirectStandardError $gwErrLog } catch { @@ -380,7 +400,11 @@ try { throw "effective policy did not contain the attached provider's GitHub rule" } $passed = $true - Ok "placeholder isolation, authorized rewrite, and endpoint mismatch all passed" + if ($Mock) { + Ok "provider attachment and placeholder-only child environment passed" + } else { + Ok "placeholder isolation, authorized rewrite, and endpoint mismatch all passed" + } } catch { $failureReason = ($_.Exception.Message -replace '\r?\n', ' | ').Trim() @@ -399,6 +423,12 @@ finally { try { $gw.WaitForExit(5000) | Out-Null } catch {} } + if ($null -eq $oldMockWxc) { + Remove-Item Env:OPENSHELL_MXC_MOCK_WXC -ErrorAction SilentlyContinue + } else { + $env:OPENSHELL_MXC_MOCK_WXC = $oldMockWxc + } + # Preserve probe output on both success and failure. These files contain # response bodies and redacted diagnostics, never the raw provider token. try { Copy-ProbeArtifacts } catch { Info "could not collect probe artifacts: $($_.Exception.GetType().Name)" } @@ -431,6 +461,7 @@ finally { OpenShell MXC provider credential example ========================================= verdict : $verdict +mode : $(if ($Mock) { "mock-wiring" } else { "real-mxc" }) sandbox : $sandboxName backend : process_container provider : $providerName @@ -438,11 +469,19 @@ share_path : $ShareDir stage : $failureStage failure : $(if ([string]::IsNullOrWhiteSpace($failureReason)) { "none" } else { $failureReason }) -PASS proves: +$(if ($Mock) { +"PASS proves: + - provider/profile/policy attachment produced a revision-scoped placeholder. + - the mock child received the placeholder instead of the raw token. + - the raw synthetic token did not appear in collected result artifacts. + - no external request was made; proxy substitution and MXC enforcement remain untested." +} else { +"PASS proves: - MXC received a revision-scoped GITHUB_TOKEN placeholder, not the token. - api.github.com accepted the credential after host-proxy substitution. - policy-allowed github.com could not resolve the api.github.com-bound placeholder. - - the raw token did not appear in collected result artifacts. + - the raw token did not appear in collected result artifacts." +}) "@ [System.IO.File]::WriteAllText((Join-Path $resultDir "summary.txt"), $summary, $utf8NoBom) Write-Host "`n$summary" -ForegroundColor ($(if ($passed) { "Green" } else { "Red" })) diff --git a/crates/openshell-driver-mxc/src/mxc.rs b/crates/openshell-driver-mxc/src/mxc.rs index 6f68dc5e3d..2de7d26fa5 100644 --- a/crates/openshell-driver-mxc/src/mxc.rs +++ b/crates/openshell-driver-mxc/src/mxc.rs @@ -36,11 +36,33 @@ fn mock_enabled() -> bool { } /// Normalize a path/command fragment to lowercase backslash form for the mock's -/// in-policy substring check. +/// in-policy path check. fn mock_normalize(s: &str) -> String { s.replace('/', "\\").to_lowercase() } +fn mock_command_references_grant(command: &str, grant: &str) -> bool { + if grant.is_empty() { + return false; + } + + command.match_indices(grant).any(|(start, _)| { + let before = command[..start].chars().next_back(); + let after = command[start + grant.len()..].chars().next(); + let is_shell_boundary = |ch: char| { + ch.is_whitespace() + || matches!( + ch, + '"' | '\'' | '=' | '>' | '<' | '(' | ')' | '&' | '|' | ';' + ) + }; + let starts_at_boundary = before.is_none_or(is_shell_boundary); + let ends_at_boundary = + grant.ends_with('\\') || after.is_none_or(|ch| ch == '\\' || is_shell_boundary(ch)); + starts_at_boundary && ends_at_boundary + }) +} + /// Per-process mock state: `iso:` sandbox id → granted read-write paths /// (normalized). Populated by the mock provision, consumed by the mock exec to /// decide whether the agent's write target is in-policy. @@ -658,7 +680,9 @@ impl WxcExecInvoker { grants: &[String], ) -> Result { let cmd_norm = mock_normalize(&process.command_line); - let in_policy = grants.iter().any(|g| !g.is_empty() && cmd_norm.contains(g)); + let in_policy = grants + .iter() + .any(|grant| mock_command_references_grant(&cmd_norm, grant)); let command_shell = std::env::var_os("COMSPEC") .unwrap_or_else(|| std::ffi::OsString::from(r"C:\Windows\System32\cmd.exe")); @@ -1009,6 +1033,33 @@ mod tests { ); } + #[test] + fn mock_grant_matching_respects_path_component_boundaries() { + let grant = mock_normalize(r"C:\work\demo"); + + for command in [ + r"echo ok > C:\work\demo", + r"echo ok > C:\work\demo\result.txt", + r#"echo ok > "C:/work/demo/result.txt""#, + ] { + assert!(mock_command_references_grant( + &mock_normalize(command), + &grant + )); + } + + for command in [ + r"echo denied > C:\work\demo-ro-src\result.txt", + r"echo denied > C:\work\demonstration\result.txt", + r"echo denied > XC:\work\demo\result.txt", + ] { + assert!(!mock_command_references_grant( + &mock_normalize(command), + &grant + )); + } + } + #[test] fn oneshot_config_json_emits_typed_ui_policy() { let filesystem = MxcFilesystem::default(); diff --git a/crates/openshell-driver-mxc/tests/demo_examples.rs b/crates/openshell-driver-mxc/tests/demo_examples.rs index 9fe867f4ba..5fb7f33d69 100644 --- a/crates/openshell-driver-mxc/tests/demo_examples.rs +++ b/crates/openshell-driver-mxc/tests/demo_examples.rs @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! Drift guards for the two shipped Windows inference demos. +//! Drift guards for the shipped Windows MXC demos. use std::path::{Path, PathBuf}; @@ -38,6 +38,105 @@ fn read_example(name: &str) -> String { .unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display())) } +#[test] +fn shipped_provider_credential_assets_support_mock_wiring_validation() { + for name in [ + "mxc-provider-credential.toml", + "mxc-provider-credential-policy.yaml", + "mxc-github-provider-profile.yml", + "mxc-provider-credential-probe.ps1", + "run-provider-credential-test.ps1", + ] { + assert!( + examples_root().join(name).is_file(), + "shipped provider-credential asset is missing: {name}" + ); + } + + let runner = read_example("run-provider-credential-test.ps1"); + let probe = read_example("mxc-provider-credential-probe.ps1"); + let config: Value = toml::from_str(&read_example("mxc-provider-credential.toml")) + .expect("provider-credential config must parse"); + assert_eq!( + config + .get("openshell") + .and_then(Value::as_table) + .and_then(|openshell| openshell.get("version")) + .and_then(Value::as_integer), + Some(2) + ); + assert!(runner.contains("[switch] $Mock")); + assert!(runner.contains("OPENSHELL_MXC_MOCK_WXC")); + assert!(runner.contains("--provider")); + assert!(runner.contains("--credential")); + assert!(probe.contains("revision-scoped GITHUB_TOKEN placeholder")); + assert!(probe.contains("no external network request")); +} + +#[test] +fn shipped_ocsf_audit_assets_support_mock_diagnostic_validation() { + for name in [ + "mxc-ocsf-audit.toml", + "ocsf-audit.yaml", + "run-ocsf-audit.ps1", + ] { + assert!( + examples_root().join(name).is_file(), + "shipped OCSF audit asset is missing: {name}" + ); + } + + let config: Value = + toml::from_str(&read_example("mxc-ocsf-audit.toml")).expect("OCSF audit config must parse"); + assert_eq!( + config + .get("openshell") + .and_then(Value::as_table) + .and_then(|openshell| openshell.get("version")) + .and_then(Value::as_integer), + Some(2) + ); + let runner = read_example("run-ocsf-audit.ps1"); + assert!(runner.contains("[switch] $Mock")); + assert!(runner.contains("mxc-etw-zero-events")); + assert!(runner.contains("OPENSHELL_MXC_MOCK_WXC")); +} + +#[test] +fn shipped_aggregate_e2e_assets_support_mock_wiring_validation() { + for name in [ + "mxc-gateway.toml", + "run-mxc-e2e.ps1", + "e2e-policies/fs-rw.yaml", + "e2e-policies/fs-readonly.yaml", + "e2e-policies/fs-empty.yaml", + "e2e-policies/network-reject.yaml", + ] { + assert!( + examples_root().join(name).is_file(), + "shipped aggregate E2E asset is missing: {name}" + ); + } + + let config: Value = + toml::from_str(&read_example("mxc-gateway.toml")).expect("MXC E2E config must parse"); + assert_eq!( + config + .get("openshell") + .and_then(Value::as_table) + .and_then(|openshell| openshell.get("version")) + .and_then(Value::as_integer), + Some(2) + ); + let runner = read_example("run-mxc-e2e.ps1"); + assert!(runner.contains("[switch] $Mock")); + assert!(runner.contains("OPENSHELL_MXC_MOCK_WXC")); + for scenario in ["fs-rw", "fs-readonly", "fs-default-deny", "network-reject"] { + assert!(runner.contains(scenario)); + } + assert!(runner.contains("not evidence of native MXC or OS enforcement")); +} + #[test] fn shipped_audit_and_websocket_configs_use_current_schema() { for name in ["mxc-ocsf-audit.toml", "mxc-ws-gateway.toml"] { @@ -194,7 +293,14 @@ fn shipped_runners_supply_sandbox_scoped_workload_configuration() { #[cfg(target_os = "windows")] #[test] fn shipped_runners_parse_in_windows_powershell() { - for name in ["run-ollama-test.ps1", "run-inference-test.ps1"] { + for name in [ + "run-ollama-test.ps1", + "run-inference-test.ps1", + "run-provider-credential-test.ps1", + "mxc-provider-credential-probe.ps1", + "run-ocsf-audit.ps1", + "run-mxc-e2e.ps1", + ] { let path = examples_root().join(name); let script = r" $errors = $null diff --git a/crates/openshell-driver-mxc/tests/windows_e2e_harness.rs b/crates/openshell-driver-mxc/tests/windows_e2e_harness.rs new file mode 100644 index 0000000000..e9c4852bfe --- /dev/null +++ b/crates/openshell-driver-mxc/tests/windows_e2e_harness.rs @@ -0,0 +1,141 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(target_os = "windows")] + +use std::path::Path; + +#[test] +fn task_harnesses_isolate_and_restore_gateway_configuration() { + let repo_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let scripts_root = repo_root.join("tasks/scripts"); + for name in [ + "windows-mxc-provider-credential-e2e.ps1", + "windows-mxc-ocsf-audit-e2e.ps1", + "windows-mxc-aggregate-e2e.ps1", + ] { + let source = std::fs::read_to_string(scripts_root.join(name)) + .unwrap_or_else(|error| panic!("failed to read {name}: {error}")); + assert!(source.contains("Push-OpenShellMxcE2eEnvironment")); + assert!(source.contains("Pop-OpenShellMxcE2eEnvironment")); + } + + let helper = scripts_root.join("windows-mxc-e2e-environment.ps1"); + let temp = tempfile::tempdir().expect("temporary directory"); + let stage = temp.path().join("artifact root with spaces").join("stage"); + let script = r#" +$ErrorActionPreference = "Stop" +. $env:OPENSHELL_MXC_ENVIRONMENT_HELPER +$names = @( + "APPDATA", + "LOCALAPPDATA", + "XDG_CONFIG_HOME", + "XDG_STATE_HOME", + "XDG_DATA_HOME", + "OPENSHELL_GATEWAY", + "OPENSHELL_GATEWAY_ENDPOINT", + "OPENSHELL_GATEWAY_INSECURE", + "OPENSHELL_GATEWAY_CONFIG", + "OPENSHELL_GATEWAY_NAME" +) +foreach ($name in $names) { + [System.Environment]::SetEnvironmentVariable($name, "caller-$name", "Process") +} + +$snapshot = Push-OpenShellMxcE2eEnvironment -StageDir $env:OPENSHELL_MXC_ENVIRONMENT_STAGE +try { + foreach ($name in @("APPDATA", "LOCALAPPDATA", "XDG_CONFIG_HOME", "XDG_STATE_HOME", "XDG_DATA_HOME")) { + $value = [System.Environment]::GetEnvironmentVariable($name, "Process") + if (-not $value.StartsWith($env:OPENSHELL_MXC_ENVIRONMENT_STAGE, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "$name escaped the staging directory: $value" + } + } + foreach ($name in @("OPENSHELL_GATEWAY", "OPENSHELL_GATEWAY_ENDPOINT", "OPENSHELL_GATEWAY_INSECURE", "OPENSHELL_GATEWAY_CONFIG", "OPENSHELL_GATEWAY_NAME")) { + if ($null -ne [System.Environment]::GetEnvironmentVariable($name, "Process")) { + throw "$name was inherited by the isolated harness" + } + } +} finally { + Pop-OpenShellMxcE2eEnvironment -Snapshot $snapshot +} + +foreach ($name in $names) { + $value = [System.Environment]::GetEnvironmentVariable($name, "Process") + if ($value -ne "caller-$name") { + throw "$name was not restored: $value" + } +} +"#; + + let output = std::process::Command::new("powershell.exe") + .args(["-NoProfile", "-Command", script]) + .env("OPENSHELL_MXC_ENVIRONMENT_HELPER", helper) + .env("OPENSHELL_MXC_ENVIRONMENT_STAGE", stage) + .output() + .expect("failed to launch Windows PowerShell"); + assert!( + output.status.success(), + "environment isolation check failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + +#[test] +fn aggregate_harness_exercises_a_staging_path_with_spaces() { + let harness = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../tasks/scripts/windows-mxc-aggregate-e2e.ps1"); + let source = std::fs::read_to_string(harness).expect("aggregate harness source"); + assert!(source.contains("openshell mxc aggregate e2e-$Target")); + + let runner_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("examples/run-mxc-e2e.ps1"); + let runner = std::fs::read_to_string(&runner_path).expect("aggregate example runner source"); + assert!(runner.contains("function New-CmdWriteCommand")); + + let temp = tempfile::tempdir().expect("temporary directory"); + let stage = temp.path().join("artifact root with spaces"); + std::fs::create_dir_all(&stage).expect("create staging directory"); + let script = r#" +$ErrorActionPreference = "Stop" +$tokens = $null +$errors = $null +$ast = [System.Management.Automation.Language.Parser]::ParseFile( + $env:OPENSHELL_MXC_RUNNER, + [ref]$tokens, + [ref]$errors +) +if ($errors.Count -gt 0) { + throw "runner has PowerShell syntax errors: $($errors.Message -join '; ')" +} +$function = $ast.Find({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -eq "New-CmdWriteCommand" +}, $true) +if ($null -eq $function) { throw "New-CmdWriteCommand was not found" } +Invoke-Expression $function.Extent.Text + +$target = Join-Path $env:OPENSHELL_MXC_SPACE_STAGE "write proof.txt" +$command = New-CmdWriteCommand "ok" $target +& $env:ComSpec /d /s /c $command +if ($LASTEXITCODE -ne 0) { throw "cmd.exe failed with exit $LASTEXITCODE" } +if (-not (Test-Path -LiteralPath $target -PathType Leaf)) { + throw "quoted command did not create the expected artifact: $target" +} +if ((Get-Content -LiteralPath $target -Raw).Trim() -ne "ok") { + throw "quoted command wrote unexpected contents" +} +"#; + let output = std::process::Command::new("powershell.exe") + .args(["-NoProfile", "-Command", script]) + .env("OPENSHELL_MXC_RUNNER", runner_path) + .env("OPENSHELL_MXC_SPACE_STAGE", stage) + .output() + .expect("failed to launch Windows PowerShell"); + assert!( + output.status.success(), + "space-path command check failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} diff --git a/tasks/scripts/windows-mxc-aggregate-e2e.ps1 b/tasks/scripts/windows-mxc-aggregate-e2e.ps1 new file mode 100644 index 0000000000..6ffaa6d37b --- /dev/null +++ b/tasks/scripts/windows-mxc-aggregate-e2e.ps1 @@ -0,0 +1,150 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Runs the shipped aggregate MXC scenario runner with the in-process wxc mock. +# This validates example, CLI, gateway, and driver wiring; it does not claim +# native MXC or Windows isolation enforcement coverage. + +[CmdletBinding()] +param( + [string] $GatewayPath, + [string] $CliPath, + [string] $ArtifactRoot, + [switch] $Mock, + [switch] $KeepArtifacts +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" +$PSNativeCommandUseErrorActionPreference = $false +. (Join-Path $PSScriptRoot "windows-mxc-e2e-environment.ps1") + +if (-not [System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([System.Runtime.InteropServices.OSPlatform]::Windows)) { + throw "windows-mxc-aggregate-e2e.ps1 requires Windows." +} +if (-not $Mock) { + throw "windows-mxc-aggregate-e2e.ps1 only supports mock mode; pass -Mock." +} + +$Target = switch ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString()) { + "X64" { "x86_64-pc-windows-msvc" } + "Arm64" { "aarch64-pc-windows-msvc" } + default { throw "unsupported native Windows architecture: $($_)" } +} + +$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path +$ExamplesRoot = Join-Path $RepoRoot "crates\openshell-driver-mxc\examples" +$TargetDir = if ([string]::IsNullOrWhiteSpace($env:CARGO_TARGET_DIR)) { + Join-Path $RepoRoot "target" +} else { + [System.IO.Path]::GetFullPath($env:CARGO_TARGET_DIR) +} + +if ([string]::IsNullOrWhiteSpace($GatewayPath)) { + $GatewayPath = Join-Path $TargetDir "$Target\release\openshell-gateway.exe" +} +if ([string]::IsNullOrWhiteSpace($CliPath)) { + $CliPath = Join-Path $TargetDir "$Target\release\openshell.exe" +} +$GatewayPath = [System.IO.Path]::GetFullPath($GatewayPath) +$CliPath = [System.IO.Path]::GetFullPath($CliPath) + +foreach ($artifact in @($GatewayPath, $CliPath)) { + if (-not (Test-Path -LiteralPath $artifact -PathType Leaf)) { + throw "required release artifact is missing: $artifact" + } +} + +if ([string]::IsNullOrWhiteSpace($ArtifactRoot)) { + $ArtifactRoot = if ([string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)) { + [System.IO.Path]::GetTempPath() + } else { + $env:RUNNER_TEMP + } +} +$ArtifactRoot = [System.IO.Path]::GetFullPath($ArtifactRoot) +$StageDir = [System.IO.Path]::GetFullPath((Join-Path $ArtifactRoot "openshell mxc aggregate e2e-$Target")) +$expectedPrefix = $ArtifactRoot.TrimEnd('\', '/') + [System.IO.Path]::DirectorySeparatorChar +if (-not $StageDir.StartsWith($expectedPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "refusing to use staging path outside artifact root: $StageDir" +} +if (Test-Path -LiteralPath $StageDir) { + Remove-Item -LiteralPath $StageDir -Recurse -Force +} +New-Item -ItemType Directory -Path $StageDir | Out-Null + +foreach ($fixture in @("run-mxc-e2e.ps1", "mxc-gateway.toml")) { + Copy-Item -LiteralPath (Join-Path $ExamplesRoot $fixture) -Destination $StageDir +} +Copy-Item -LiteralPath (Join-Path $ExamplesRoot "e2e-policies") -Destination $StageDir -Recurse + +function Get-AvailablePort { + $listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0) + try { + $listener.Start() + return ([System.Net.IPEndPoint] $listener.LocalEndpoint).Port + } finally { + $listener.Stop() + } +} + +$passed = $false +$environmentSnapshot = $null + +try { + $environmentSnapshot = Push-OpenShellMxcE2eEnvironment -StageDir $StageDir + + $runner = Join-Path $StageDir "run-mxc-e2e.ps1" + $demoDir = Join-Path $StageDir "demo" + $port = Get-AvailablePort + $output = & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $runner ` + -Mock ` + -GatewayPath $GatewayPath ` + -CliPath $CliPath ` + -DemoDir $demoDir ` + -Port $port ` + -GatewayName "openshell-mxc-aggregate-ci" 2>&1 + $exitCode = $LASTEXITCODE + $output | ForEach-Object { Write-Host $_ } + if ($exitCode -ne 0) { + throw "shipped aggregate MXC example failed in mock mode (exit $exitCode)" + } + + $resultDir = Get-ChildItem -LiteralPath $StageDir -Directory -Filter "results-e2e-*" | + Sort-Object LastWriteTimeUtc -Descending | + Select-Object -First 1 + if ($null -eq $resultDir) { throw "aggregate MXC example did not produce a results directory" } + + $summary = Get-Content -LiteralPath (Join-Path $resultDir.FullName "summary.txt") -Raw + if ($summary -notmatch '(?m)^verdict\s+: PASS\s*$' -or + $summary -notmatch '(?m)^mode\s+: MOCK\s*$' -or + $summary -notmatch '(?m)^totals\s+: PASS=4\s+FAIL=0\s+SKIP=0\s*$' -or + $summary -notmatch '(?m)^Mock mode validates runner/gateway/driver wiring') { + throw "aggregate MXC summary did not report the expected four-scenario mock PASS: $summary" + } + + if (Get-NetTCPConnection -State Listen -LocalPort $port -ErrorAction SilentlyContinue) { + throw "aggregate MXC example left gateway port $port listening" + } + + $passed = $true + Write-Host "MXC aggregate example mock E2E passed for $Target" +} catch { + Write-Host "MXC aggregate example mock E2E failed: $($_.Exception.Message)" -ForegroundColor Red + Get-ChildItem -LiteralPath $StageDir -File -Recurse -ErrorAction SilentlyContinue | + Where-Object { $_.Extension -in @(".log", ".txt") } | + ForEach-Object { + Write-Host "--- $($_.FullName) ---" + Get-Content -LiteralPath $_.FullName -ErrorAction SilentlyContinue | ForEach-Object { Write-Host $_ } + } + throw +} finally { + if ($null -ne $environmentSnapshot) { + Pop-OpenShellMxcE2eEnvironment -Snapshot $environmentSnapshot + } + if ($passed -and -not $KeepArtifacts -and (Test-Path -LiteralPath $StageDir)) { + Remove-Item -LiteralPath $StageDir -Recurse -Force + } else { + Write-Host "MXC aggregate example artifacts: $StageDir" + } +} diff --git a/tasks/scripts/windows-mxc-e2e-environment.ps1 b/tasks/scripts/windows-mxc-e2e-environment.ps1 new file mode 100644 index 0000000000..e4d3b2eb44 --- /dev/null +++ b/tasks/scripts/windows-mxc-e2e-environment.ps1 @@ -0,0 +1,76 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +function Push-OpenShellMxcE2eEnvironment { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string] $StageDir + ) + + $isolatedValues = [ordered]@{ + APPDATA = Join-Path $StageDir "appdata" + LOCALAPPDATA = Join-Path $StageDir "localappdata" + XDG_CONFIG_HOME = Join-Path $StageDir "xdg-config" + XDG_STATE_HOME = Join-Path $StageDir "xdg-state" + XDG_DATA_HOME = Join-Path $StageDir "xdg-data" + OPENSHELL_GATEWAY = $null + OPENSHELL_GATEWAY_ENDPOINT = $null + OPENSHELL_GATEWAY_INSECURE = $null + OPENSHELL_GATEWAY_CONFIG = $null + OPENSHELL_GATEWAY_NAME = $null + } + + New-Item -ItemType Directory -Force -Path @( + $isolatedValues.APPDATA + $isolatedValues.LOCALAPPDATA + $isolatedValues.XDG_CONFIG_HOME + $isolatedValues.XDG_STATE_HOME + $isolatedValues.XDG_DATA_HOME + ) | Out-Null + + $savedValues = @{} + foreach ($name in $isolatedValues.Keys) { + $savedValues[$name] = [System.Environment]::GetEnvironmentVariable( + $name, + [System.EnvironmentVariableTarget]::Process + ) + } + + try { + foreach ($entry in $isolatedValues.GetEnumerator()) { + [System.Environment]::SetEnvironmentVariable( + $entry.Key, + $entry.Value, + [System.EnvironmentVariableTarget]::Process + ) + } + } catch { + foreach ($entry in $savedValues.GetEnumerator()) { + [System.Environment]::SetEnvironmentVariable( + $entry.Key, + $entry.Value, + [System.EnvironmentVariableTarget]::Process + ) + } + throw + } + + return [pscustomobject]@{ Values = $savedValues } +} + +function Pop-OpenShellMxcE2eEnvironment { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [psobject] $Snapshot + ) + + foreach ($entry in $Snapshot.Values.GetEnumerator()) { + [System.Environment]::SetEnvironmentVariable( + $entry.Key, + $entry.Value, + [System.EnvironmentVariableTarget]::Process + ) + } +} diff --git a/tasks/scripts/windows-mxc-ocsf-audit-e2e.ps1 b/tasks/scripts/windows-mxc-ocsf-audit-e2e.ps1 new file mode 100644 index 0000000000..e94681fd1a --- /dev/null +++ b/tasks/scripts/windows-mxc-ocsf-audit-e2e.ps1 @@ -0,0 +1,161 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Runs the shipped MXC OCSF audit example with the in-process wxc mock. The test +# requires the ETW consumer's durable zero-provider-events finding; it does not +# claim real Sandboxing-provider coverage or MXC enforcement. + +[CmdletBinding()] +param( + [string] $GatewayPath, + [string] $CliPath, + [string] $ArtifactRoot, + [switch] $Mock, + [switch] $KeepArtifacts +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" +$PSNativeCommandUseErrorActionPreference = $false +. (Join-Path $PSScriptRoot "windows-mxc-e2e-environment.ps1") + +if (-not [System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([System.Runtime.InteropServices.OSPlatform]::Windows)) { + throw "windows-mxc-ocsf-audit-e2e.ps1 requires Windows." +} +if (-not $Mock) { + throw "windows-mxc-ocsf-audit-e2e.ps1 only supports mock mode; pass -Mock." +} + +$Target = switch ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString()) { + "X64" { "x86_64-pc-windows-msvc" } + "Arm64" { "aarch64-pc-windows-msvc" } + default { throw "unsupported native Windows architecture: $($_)" } +} + +$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path +$ExamplesRoot = Join-Path $RepoRoot "crates\openshell-driver-mxc\examples" +$TargetDir = if ([string]::IsNullOrWhiteSpace($env:CARGO_TARGET_DIR)) { + Join-Path $RepoRoot "target" +} else { + [System.IO.Path]::GetFullPath($env:CARGO_TARGET_DIR) +} + +if ([string]::IsNullOrWhiteSpace($GatewayPath)) { + $GatewayPath = Join-Path $TargetDir "$Target\release\openshell-gateway.exe" +} +if ([string]::IsNullOrWhiteSpace($CliPath)) { + $CliPath = Join-Path $TargetDir "$Target\release\openshell.exe" +} +$GatewayPath = [System.IO.Path]::GetFullPath($GatewayPath) +$CliPath = [System.IO.Path]::GetFullPath($CliPath) + +foreach ($artifact in @($GatewayPath, $CliPath)) { + if (-not (Test-Path -LiteralPath $artifact -PathType Leaf)) { + throw "required release artifact is missing: $artifact" + } +} + +if ([string]::IsNullOrWhiteSpace($ArtifactRoot)) { + $ArtifactRoot = if ([string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)) { + [System.IO.Path]::GetTempPath() + } else { + $env:RUNNER_TEMP + } +} +$ArtifactRoot = [System.IO.Path]::GetFullPath($ArtifactRoot) +$StageDir = [System.IO.Path]::GetFullPath((Join-Path $ArtifactRoot "openshell-mxc-ocsf-audit-e2e-$Target")) +$expectedPrefix = $ArtifactRoot.TrimEnd('\', '/') + [System.IO.Path]::DirectorySeparatorChar +if (-not $StageDir.StartsWith($expectedPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "refusing to use staging path outside artifact root: $StageDir" +} +if (Test-Path -LiteralPath $StageDir) { + Remove-Item -LiteralPath $StageDir -Recurse -Force +} +New-Item -ItemType Directory -Path $StageDir | Out-Null + +foreach ($fixture in @("run-ocsf-audit.ps1", "mxc-ocsf-audit.toml", "ocsf-audit.yaml")) { + Copy-Item -LiteralPath (Join-Path $ExamplesRoot $fixture) -Destination $StageDir +} + +function Get-AvailablePort { + $listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0) + try { + $listener.Start() + return ([System.Net.IPEndPoint] $listener.LocalEndpoint).Port + } finally { + $listener.Stop() + } +} + +$passed = $false +$environmentSnapshot = $null + +try { + $environmentSnapshot = Push-OpenShellMxcE2eEnvironment -StageDir $StageDir + + $runner = Join-Path $StageDir "run-ocsf-audit.ps1" + $share = Join-Path $StageDir "share" + $port = Get-AvailablePort + $output = & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $runner ` + -Mock ` + -GatewayPath $GatewayPath ` + -CliPath $CliPath ` + -ShareDir $share ` + -SandboxCount 1 ` + -Port $port ` + -GatewayName "openshell-mxc-ocsf-ci" 2>&1 + $exitCode = $LASTEXITCODE + $output | ForEach-Object { Write-Host $_ } + if ($exitCode -ne 0) { + throw "shipped OCSF audit example failed in mock mode (exit $exitCode)" + } + + $resultDir = Get-ChildItem -LiteralPath $StageDir -Directory -Filter "results-*" | + Sort-Object LastWriteTimeUtc -Descending | + Select-Object -First 1 + if ($null -eq $resultDir) { throw "OCSF audit example did not produce a results directory" } + + $summary = Get-Content -LiteralPath (Join-Path $resultDir.FullName "summary.txt") -Raw + if ($summary -notmatch '(?m)^verdict\s+: PASS\s*$' -or + $summary -notmatch '(?m)^mode\s+: mock-wiring\s*$' -or + $summary -notmatch '(?m)^zero-event audit\s+: warning \+ durable finding observed\s*$') { + throw "OCSF audit summary did not report the expected mock-wiring PASS: $summary" + } + + $jsonl = Get-ChildItem -LiteralPath $resultDir.FullName -File -Filter "openshell-ocsf*.log" | + Select-Object -First 1 + if ($null -eq $jsonl) { throw "OCSF audit example did not produce a JSONL audit log" } + $findingObserved = $false + foreach ($line in Get-Content -LiteralPath $jsonl.FullName) { + if ([string]::IsNullOrWhiteSpace($line)) { continue } + $event = $line | ConvertFrom-Json + $findingInfo = $event.PSObject.Properties["finding_info"] + if ($findingInfo -and $findingInfo.Value.uid -eq "mxc-etw-zero-events") { + $findingObserved = $true + } + } + if (-not $findingObserved) { + throw "OCSF JSONL did not contain the mxc-etw-zero-events finding" + } + + $passed = $true + Write-Host "MXC OCSF audit example mock E2E passed for $Target" +} catch { + Write-Host "MXC OCSF audit example mock E2E failed: $($_.Exception.Message)" -ForegroundColor Red + Get-ChildItem -LiteralPath $StageDir -File -Recurse -ErrorAction SilentlyContinue | + Where-Object { $_.Extension -in @(".log", ".txt") } | + ForEach-Object { + Write-Host "--- $($_.FullName) ---" + Get-Content -LiteralPath $_.FullName -ErrorAction SilentlyContinue | ForEach-Object { Write-Host $_ } + } + throw +} finally { + if ($null -ne $environmentSnapshot) { + Pop-OpenShellMxcE2eEnvironment -Snapshot $environmentSnapshot + } + if ($passed -and -not $KeepArtifacts -and (Test-Path -LiteralPath $StageDir)) { + Remove-Item -LiteralPath $StageDir -Recurse -Force + } else { + Write-Host "MXC OCSF audit example artifacts: $StageDir" + } +} diff --git a/tasks/scripts/windows-mxc-provider-credential-e2e.ps1 b/tasks/scripts/windows-mxc-provider-credential-e2e.ps1 new file mode 100644 index 0000000000..c76f6d5bf1 --- /dev/null +++ b/tasks/scripts/windows-mxc-provider-credential-e2e.ps1 @@ -0,0 +1,164 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Runs the shipped MXC provider-credential example with a synthetic credential +# and the in-process wxc mock. This proves wiring and placeholder propagation; +# it does not prove host-proxy substitution or MXC enforcement. + +[CmdletBinding()] +param( + [string] $GatewayPath, + [string] $CliPath, + [string] $ArtifactRoot, + [switch] $Mock, + [switch] $KeepArtifacts +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" +$PSNativeCommandUseErrorActionPreference = $false +. (Join-Path $PSScriptRoot "windows-mxc-e2e-environment.ps1") + +if (-not [System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([System.Runtime.InteropServices.OSPlatform]::Windows)) { + throw "windows-mxc-provider-credential-e2e.ps1 requires Windows." +} +if (-not $Mock) { + throw "windows-mxc-provider-credential-e2e.ps1 only supports mock mode; pass -Mock." +} + +$Target = switch ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString()) { + "X64" { "x86_64-pc-windows-msvc" } + "Arm64" { "aarch64-pc-windows-msvc" } + default { throw "unsupported native Windows architecture: $($_)" } +} + +$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path +$ExamplesRoot = Join-Path $RepoRoot "crates\openshell-driver-mxc\examples" +$TargetDir = if ([string]::IsNullOrWhiteSpace($env:CARGO_TARGET_DIR)) { + Join-Path $RepoRoot "target" +} else { + [System.IO.Path]::GetFullPath($env:CARGO_TARGET_DIR) +} + +if ([string]::IsNullOrWhiteSpace($GatewayPath)) { + $GatewayPath = Join-Path $TargetDir "$Target\release\openshell-gateway.exe" +} +if ([string]::IsNullOrWhiteSpace($CliPath)) { + $CliPath = Join-Path $TargetDir "$Target\release\openshell.exe" +} +$GatewayPath = [System.IO.Path]::GetFullPath($GatewayPath) +$CliPath = [System.IO.Path]::GetFullPath($CliPath) + +foreach ($artifact in @($GatewayPath, $CliPath)) { + if (-not (Test-Path -LiteralPath $artifact -PathType Leaf)) { + throw "required release artifact is missing: $artifact" + } +} + +if ([string]::IsNullOrWhiteSpace($ArtifactRoot)) { + $ArtifactRoot = if ([string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)) { + [System.IO.Path]::GetTempPath() + } else { + $env:RUNNER_TEMP + } +} +$ArtifactRoot = [System.IO.Path]::GetFullPath($ArtifactRoot) +$StageDir = [System.IO.Path]::GetFullPath((Join-Path $ArtifactRoot "openshell-mxc-provider-credential-e2e-$Target")) +$expectedPrefix = $ArtifactRoot.TrimEnd('\', '/') + [System.IO.Path]::DirectorySeparatorChar +if (-not $StageDir.StartsWith($expectedPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "refusing to use staging path outside artifact root: $StageDir" +} +if (Test-Path -LiteralPath $StageDir) { + Remove-Item -LiteralPath $StageDir -Recurse -Force +} +New-Item -ItemType Directory -Path $StageDir | Out-Null + +$fixtures = @( + "run-provider-credential-test.ps1", + "mxc-provider-credential-probe.ps1", + "mxc-provider-credential.toml", + "mxc-provider-credential-policy.yaml", + "mxc-github-provider-profile.yml" +) +foreach ($fixture in $fixtures) { + Copy-Item -LiteralPath (Join-Path $ExamplesRoot $fixture) -Destination $StageDir +} + +function Get-AvailablePort { + $listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0) + try { + $listener.Start() + return ([System.Net.IPEndPoint] $listener.LocalEndpoint).Port + } finally { + $listener.Stop() + } +} + +$SyntheticToken = "openshell-ci-synthetic-provider-value" +$passed = $false +$environmentSnapshot = $null +$oldGitHubToken = $env:GITHUB_TOKEN + +try { + $environmentSnapshot = Push-OpenShellMxcE2eEnvironment -StageDir $StageDir + $env:GITHUB_TOKEN = $SyntheticToken + + $runner = Join-Path $StageDir "run-provider-credential-test.ps1" + $share = Join-Path $StageDir "share" + $port = Get-AvailablePort + $output = & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $runner ` + -Mock ` + -GatewayPath $GatewayPath ` + -CliPath $CliPath ` + -ShareDir $share ` + -Port $port ` + -GatewayName "openshell-mxc-provider-ci" 2>&1 + $exitCode = $LASTEXITCODE + $output | ForEach-Object { Write-Host $_ } + if ($exitCode -ne 0) { + throw "shipped provider-credential example failed in mock mode (exit $exitCode)" + } + + $resultDir = Get-ChildItem -LiteralPath $StageDir -Directory -Filter "results-provider-credential-*" | + Sort-Object LastWriteTimeUtc -Descending | + Select-Object -First 1 + if ($null -eq $resultDir) { throw "provider-credential example did not produce a results directory" } + + $summary = Get-Content -LiteralPath (Join-Path $resultDir.FullName "summary.txt") -Raw + if ($summary -notmatch '(?m)^verdict\s+: PASS\s*$' -or $summary -notmatch '(?m)^mode\s+: mock-wiring\s*$') { + throw "provider-credential summary did not report a mock-wiring PASS: $summary" + } + $probe = Get-Content -LiteralPath (Join-Path $resultDir.FullName "mxc-provider-credential-result.txt") -Raw + if ($probe -notmatch 'revision-scoped GITHUB_TOKEN placeholder' -or $probe -notmatch 'no external network request') { + throw "provider-credential probe did not prove the expected mock path: $probe" + } + + foreach ($file in Get-ChildItem -LiteralPath $StageDir -File -Recurse) { + $contents = [System.IO.File]::ReadAllText($file.FullName) + if ($contents.Contains($SyntheticToken)) { + throw "synthetic credential leaked into test artifact: $($file.FullName)" + } + } + + $passed = $true + Write-Host "MXC provider-credential example mock E2E passed for $Target" +} catch { + Write-Host "MXC provider-credential example mock E2E failed: $($_.Exception.Message)" -ForegroundColor Red + Get-ChildItem -LiteralPath $StageDir -File -Recurse -ErrorAction SilentlyContinue | + Where-Object { $_.Extension -in @(".log", ".txt") } | + ForEach-Object { + Write-Host "--- $($_.FullName) ---" + Get-Content -LiteralPath $_.FullName -ErrorAction SilentlyContinue | ForEach-Object { Write-Host $_ } + } + throw +} finally { + $env:GITHUB_TOKEN = $oldGitHubToken + if ($null -ne $environmentSnapshot) { + Pop-OpenShellMxcE2eEnvironment -Snapshot $environmentSnapshot + } + if ($passed -and -not $KeepArtifacts -and (Test-Path -LiteralPath $StageDir)) { + Remove-Item -LiteralPath $StageDir -Recurse -Force + } else { + Write-Host "MXC provider-credential example artifacts: $StageDir" + } +} diff --git a/tasks/windows.toml b/tasks/windows.toml index c533dd53a9..2db94c9c97 100644 --- a/tasks/windows.toml +++ b/tasks/windows.toml @@ -109,6 +109,21 @@ description = "Run MXC Tier-3 e2e scenario runner in mock/wiring-only mode (no r run = "echo 'windows:* tasks require a Windows MSVC host' && exit 1" run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 -Mock" +["windows:e2e:mxc:provider-mock"] +description = "Run the shipped MXC provider-credential example with a synthetic credential and the in-process wxc mock on native Windows" +run = "echo 'windows:* tasks require a Windows MSVC host' && exit 1" +run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-mxc-provider-credential-e2e.ps1 -Mock" + +["windows:e2e:mxc:ocsf-mock"] +description = "Run the shipped MXC OCSF audit example and validate the zero-provider-events finding with the in-process wxc mock on native Windows" +run = "echo 'windows:* tasks require a Windows MSVC host' && exit 1" +run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-mxc-ocsf-audit-e2e.ps1 -Mock" + +["windows:e2e:mxc:aggregate-mock"] +description = "Run all four shipped aggregate MXC scenarios with the in-process wxc mock on native Windows" +run = "echo 'windows:* tasks require a Windows MSVC host' && exit 1" +run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-mxc-aggregate-e2e.ps1 -Mock" + ["windows:e2e:mxc:inference-mock:x64"] description = "Run the shipped MXC inference demos against the in-process wxc mock and a local API stub on Windows x64" run = "echo 'windows:* tasks require a Windows MSVC host' && exit 1" From eac851f1b3fd850ccc6961008528d3d5a127c44d Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Mon, 28 Sep 2026 10:04:36 +0200 Subject: [PATCH 29/42] test(conformance): split smoke capabilities Signed-off-by: Evan Lezar (cherry picked from commit 27d3cb5ee8b0d6d87fe5ce532208bbe1f4eec5e1) --- TESTING.md | 30 ++- architecture/build.md | 7 +- crates/openshell-conformance/src/lib.rs | 2 +- .../src/scenarios/mod.rs | 2 +- .../src/scenarios/smoke.rs | 216 +++++++++++++----- tests/suites/conformance/README.md | 41 ++++ tests/suites/conformance/cli/tests/smoke.rs | 36 +++ 7 files changed, 267 insertions(+), 67 deletions(-) create mode 100644 tests/suites/conformance/README.md create mode 100644 tests/suites/conformance/cli/tests/smoke.rs diff --git a/TESTING.md b/TESTING.md index aaad71aa41..ece1d396c5 100644 --- a/TESTING.md +++ b/TESTING.md @@ -148,8 +148,13 @@ lifecycle management, output parsing, and cleanup. Suites: - Common suite (`--features e2e`) - driver-neutral CLI behavior, sandbox lifecycle, sync, port forwarding, policy, and provider tests. -- CLI conformance (`openshell-conformance`) - the portable deployment smoke - scenario plus focused tests for its reusable command runner. +- CLI conformance (`tests/suites/conformance`) - installed-artifact tests of + portable public CLI behavior. Smoke coverage is split into independent + control-plane and exec tests so each driver runs only the capabilities it + implements. Scenario implementations live in `openshell-conformance`; the + standalone `openshell-conformance` binary remains a compatibility runner for + driver E2E wrappers. See the [suite README](tests/suites/conformance/README.md) + for scope and selection guidance. - Driver suites (`--features e2e-docker`, `e2e-podman`, `e2e-kubernetes`, or `e2e-vm`) - CLI conformance plus the common and driver-specific coverage for the selected deployment. @@ -186,20 +191,21 @@ starts. The task does not provision a gateway or select a compute driver. Set `OPENSHELL_BIN` to test a prebuilt CLI; otherwise, the task builds the CLI from the current checkout. -The phase-1 scenario verifies the complete CLI-to-gateway-to-driver path without -depending on how the gateway was installed or which driver is configured. It -requires machine-readable gRPC status, creates a uniquely named detached -sandbox with `--from base`, verifies the sandbox is `Ready` by finding its -unique name in paginated JSON list output, executes `echo` with a run-specific -marker, deletes the sandbox, and verifies that its name no longer appears. -Driver suites enable the same profile -instead of maintaining a separate smoke implementation. Sandbox lifecycle, +The smoke contract verifies the CLI-to-gateway-to-driver path without depending +on how the gateway was installed. Its control-plane test requires +machine-readable status, creates a uniquely named detached sandbox, verifies it +is `Ready` through get and paginated list output, deletes it, and verifies its +name no longer appears. The exec test creates its own sandbox and checks +`sandbox exec` with a run-specific marker. Drivers without exec support run the +control-plane test alone. Runtimes that require an explicit workload set +`OPENSHELL_CONFORMANCE_SMOKE_COMMAND` to a JSON string array. Sandbox lifecycle, label matrices, VM overlay, and TLS-key permission assertions remain regular E2E coverage. Each invocation prints a ten-character run ID before creating resources. -Conformance sandboxes use names such as `ct--01`. The runner tracks the -exact name and uses it for cleanup; phase 1 does not add ownership labels. +Conformance sandboxes use names such as `ct--cp` and +`ct--ex`. The runner tracks the exact name and uses it for cleanup; +smoke conformance does not add ownership labels. The runner deletes owned resources after both success and failure. If the test process is interrupted before cleanup, locate leftovers without touching diff --git a/architecture/build.md b/architecture/build.md index caea7cd602..dd1f2fb222 100644 --- a/architecture/build.md +++ b/architecture/build.md @@ -295,8 +295,11 @@ and retain that provenance with the local entry; mutable tags are used only for explicit publication. CLI conformance runs after target provisioning and operates only through the -configured OpenShell CLI. The smoke scenario verifies the black-box sandbox -lifecycle by creating, inspecting, executing in, and deleting a sandbox. +configured OpenShell CLI. The archive suite defines the installed-artifact test +boundary, while `openshell-conformance` provides reusable scenarios and a +standalone compatibility runner. Smoke coverage separates control-plane +create/get/list/delete behavior from exec behavior so a runtime can run the +portable subset matching its advertised capabilities. ## Python Wheel Packaging diff --git a/crates/openshell-conformance/src/lib.rs b/crates/openshell-conformance/src/lib.rs index 0691d99ef0..b5374ea4d0 100644 --- a/crates/openshell-conformance/src/lib.rs +++ b/crates/openshell-conformance/src/lib.rs @@ -23,7 +23,7 @@ use tokio::time::sleep; use self::executor::{CliExecutionError, CliExecutor, ProcessCli}; -pub use scenarios::SMOKE_SCENARIO; +pub use scenarios::{SMOKE_CONTROL_PLANE_SCENARIO, SMOKE_EXEC_SCENARIO, SMOKE_SCENARIO}; /// An installed conformance scenario. #[derive(Debug)] diff --git a/crates/openshell-conformance/src/scenarios/mod.rs b/crates/openshell-conformance/src/scenarios/mod.rs index bd6abdc7af..fb90585885 100644 --- a/crates/openshell-conformance/src/scenarios/mod.rs +++ b/crates/openshell-conformance/src/scenarios/mod.rs @@ -5,4 +5,4 @@ mod smoke; -pub use smoke::SMOKE_SCENARIO; +pub use smoke::{SMOKE_CONTROL_PLANE_SCENARIO, SMOKE_EXEC_SCENARIO, SMOKE_SCENARIO}; diff --git a/crates/openshell-conformance/src/scenarios/smoke.rs b/crates/openshell-conformance/src/scenarios/smoke.rs index 7da9ef2210..584cf101ef 100644 --- a/crates/openshell-conformance/src/scenarios/smoke.rs +++ b/crates/openshell-conformance/src/scenarios/smoke.rs @@ -1,8 +1,9 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! Portable phase-1 CLI conformance scenario. +//! Portable smoke conformance scenarios. +use std::ffi::OsString; use std::time::{Duration, Instant}; use crate::{OpenShellRunner, STATUS_TIMEOUT, Scenario, ScenarioFuture}; @@ -31,15 +32,40 @@ struct SandboxListPage { /// Certify status -> create -> list Ready -> exec -> delete -> list empty. pub const SMOKE_SCENARIO: Scenario = Scenario { name: "smoke", - description: "Create, inspect, execute in, and delete a base sandbox.", + description: "Run the control-plane and exec smoke scenarios.", run: run_smoke, }; +/// Certify status -> create -> get/list Ready -> delete -> list empty. +pub const SMOKE_CONTROL_PLANE_SCENARIO: Scenario = Scenario { + name: "smoke-control-plane", + description: "Create, inspect, and delete a sandbox without using sandbox exec.", + run: run_smoke_control_plane, +}; + +/// Certify create -> exec -> delete for drivers that support interactive exec. +pub const SMOKE_EXEC_SCENARIO: Scenario = Scenario { + name: "smoke-exec", + description: "Create a sandbox, execute a command in it, and delete it.", + run: run_smoke_exec, +}; + fn run_smoke(runner: &mut OpenShellRunner) -> ScenarioFuture<'_> { - Box::pin(async move { run_smoke_inner(runner).await }) + Box::pin(async move { + run_smoke_control_plane_inner(runner).await?; + run_smoke_exec_inner(runner).await + }) } -async fn run_smoke_inner(runner: &mut OpenShellRunner) -> Result<(), String> { +fn run_smoke_control_plane(runner: &mut OpenShellRunner) -> ScenarioFuture<'_> { + Box::pin(async move { run_smoke_control_plane_inner(runner).await }) +} + +fn run_smoke_exec(runner: &mut OpenShellRunner) -> ScenarioFuture<'_> { + Box::pin(async move { run_smoke_exec_inner(runner).await }) +} + +async fn run_smoke_control_plane_inner(runner: &mut OpenShellRunner) -> Result<(), String> { let status = runner .step("status") .description("openshell status succeeds") @@ -49,51 +75,18 @@ async fn run_smoke_inner(runner: &mut OpenShellRunner) -> Result<(), String> { .map_err(|error| error.to_string())?; status.require_success()?; - let sandbox_name = format!("ct-{}-01", runner.id()); - runner.track_sandbox(&sandbox_name); - let create = runner - .step("create") - .description("sandbox creation succeeds") - .with_timeout(CREATE_TIMEOUT) - .run(&[ - "sandbox", - "create", - "--name", - &sandbox_name, - "--from", - "base", - "--detach", - ]) - .await - .map_err(|error| error.to_string())?; - create.require_success()?; - - let get = runner - .step("get-ready") - .description(format!("sandbox '{sandbox_name}' can be retrieved")) - .with_timeout(LIST_ATTEMPT_TIMEOUT) - .run(&["sandbox", "get", &sandbox_name, "--output", "json"]) - .await - .map_err(|error| error.to_string())?; - get.require_success()?; + let sandbox_name = format!("ct-{}-cp", runner.id()); + create_sandbox(runner, &sandbox_name, "create").await?; + check_sandbox_ready(runner, &sandbox_name).await?; + check_sandbox_listed(runner, &sandbox_name).await?; - let sandbox = get - .json::() - .map_err(|error| error.to_string())?; - if sandbox.name != sandbox_name { - return Err(format!( - "sandbox get returned {:?}; expected sandbox '{sandbox_name}'", - sandbox.name - )); - } - if sandbox.phase != "Ready" { - return Err(format!( - "sandbox '{sandbox_name}' is in phase {:?}; expected Ready", - sandbox.phase - )); - } + delete_sandbox(runner, &sandbox_name, "delete").await +} - check_sandbox_listed(runner, &sandbox_name).await?; +async fn run_smoke_exec_inner(runner: &mut OpenShellRunner) -> Result<(), String> { + let sandbox_name = format!("ct-{}-ex", runner.id()); + create_sandbox(runner, &sandbox_name, "create").await?; + check_sandbox_ready(runner, &sandbox_name).await?; let marker = format!("openshell-conformance-{}", runner.id()); let exec = runner @@ -118,17 +111,109 @@ async fn run_smoke_inner(runner: &mut OpenShellRunner) -> Result<(), String> { return Err(exec.failure_diagnostic(&format!("stdout is exactly {expected_stdout:?}"))); } + delete_sandbox(runner, &sandbox_name, "delete").await +} + +async fn create_sandbox( + runner: &mut OpenShellRunner, + sandbox_name: &str, + step: &str, +) -> Result<(), String> { + runner.track_sandbox(sandbox_name); + let mut args = vec![ + "sandbox".to_string(), + "create".to_string(), + "--name".to_string(), + sandbox_name.to_string(), + "--detach".to_string(), + ]; + match smoke_command()? { + Some(command) => { + args.push("--".to_string()); + args.extend(command); + } + None => { + args.push("--from".to_string()); + args.push("base".to_string()); + } + } + let args = args.iter().map(String::as_str).collect::>(); + let create = runner + .step(step) + .description("sandbox creation succeeds") + .with_timeout(CREATE_TIMEOUT) + .run(&args) + .await + .map_err(|error| error.to_string())?; + create.require_success() +} + +fn smoke_command() -> Result>, String> { + parse_smoke_command(std::env::var_os("OPENSHELL_CONFORMANCE_SMOKE_COMMAND")) +} + +fn parse_smoke_command(value: Option) -> Result>, String> { + let Some(value) = value else { + return Ok(None); + }; + let value = value.into_string().map_err(|_| { + "OPENSHELL_CONFORMANCE_SMOKE_COMMAND must contain valid Unicode JSON".to_string() + })?; + let command = serde_json::from_str::>(&value).map_err(|error| { + format!("OPENSHELL_CONFORMANCE_SMOKE_COMMAND must be a JSON string array: {error}") + })?; + if command.first().is_none_or(String::is_empty) { + return Err( + "OPENSHELL_CONFORMANCE_SMOKE_COMMAND must contain a non-empty executable".to_string(), + ); + } + Ok(Some(command)) +} + +async fn delete_sandbox( + runner: &mut OpenShellRunner, + sandbox_name: &str, + step: &str, +) -> Result<(), String> { let delete = runner - .step("delete") + .step(step) .description("sandbox deletion succeeds") .with_timeout(DELETE_TIMEOUT) - .run(&["sandbox", "delete", &sandbox_name]) + .run(&["sandbox", "delete", sandbox_name]) .await .map_err(|error| error.to_string())?; delete.require_success()?; - check_empty_list(runner, &sandbox_name).await?; - runner.forget_sandbox(&sandbox_name); + check_empty_list(runner, sandbox_name).await?; + runner.forget_sandbox(sandbox_name); + Ok(()) +} + +async fn check_sandbox_ready(runner: &OpenShellRunner, sandbox_name: &str) -> Result<(), String> { + let get = runner + .step("get-ready") + .description(format!("sandbox '{sandbox_name}' can be retrieved")) + .with_timeout(LIST_ATTEMPT_TIMEOUT) + .run(&["sandbox", "get", sandbox_name, "--output", "json"]) + .await + .map_err(|error| error.to_string())?; + get.require_success()?; + + let sandbox = get + .json::() + .map_err(|error| error.to_string())?; + if sandbox.name != sandbox_name { + return Err(format!( + "sandbox get returned {:?}; expected sandbox '{sandbox_name}'", + sandbox.name + )); + } + if sandbox.phase != "Ready" { + return Err(format!( + "sandbox '{sandbox_name}' is in phase {:?}; expected Ready", + sandbox.phase + )); + } Ok(()) } @@ -212,3 +297,32 @@ async fn find_sandbox( .ok_or_else(|| "sandbox list page counter overflowed".to_string())?; } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn smoke_command_is_optional() { + assert_eq!(parse_smoke_command(None).unwrap(), None); + } + + #[test] + fn smoke_command_parses_a_json_string_array() { + assert_eq!( + parse_smoke_command(Some(OsString::from(r#"["cmd.exe","/c","exit","0"]"#))).unwrap(), + Some(vec![ + "cmd.exe".into(), + "/c".into(), + "exit".into(), + "0".into() + ]) + ); + } + + #[test] + fn smoke_command_rejects_an_empty_executable() { + let error = parse_smoke_command(Some(OsString::from(r#"[""]"#))).unwrap_err(); + assert!(error.contains("non-empty executable")); + } +} diff --git a/tests/suites/conformance/README.md b/tests/suites/conformance/README.md new file mode 100644 index 0000000000..6aaa69b417 --- /dev/null +++ b/tests/suites/conformance/README.md @@ -0,0 +1,41 @@ + + +# CLI conformance suite + +This workspace verifies that an installed `openshell` CLI and a selected gateway +implement portable, public behavior. Tests treat the CLI as a black box. They do +not inspect driver internals or replace driver-specific qualification. + +The suite separates portable contracts by capability. A test environment should +run every test supported by its compute driver instead of treating one broad +smoke test as the portability boundary. In particular: + +- `smoke::control_plane` covers status, create, get, list, and delete. It does + not require `sandbox exec`. +- `smoke::exec` covers `sandbox exec` and runs only where the driver supports + that operation. +- `lifecycle` and the policy advisor tests have additional runtime requirements + documented in their source modules. + +The scenario implementations live in `crates/openshell-conformance` so the +archive tests and the standalone `openshell-conformance` compatibility runner +share assertions and cleanup. New installed-artifact CI should use this test +workspace and select tests with nextest filters. + +Set `OPENSHELL_BIN` to the candidate CLI. The selected gateway must already be +reachable. Runtimes that require an explicit sandbox workload can set +`OPENSHELL_CONFORMANCE_SMOKE_COMMAND` to a JSON string array. For example: + +```shell +export OPENSHELL_CONFORMANCE_SMOKE_COMMAND='["sleep","infinity"]' +cargo nextest run \ + --manifest-path tests/suites/conformance/Cargo.toml \ + -E 'binary(smoke) & test(=control_plane)' +``` + +The runner owns only resources named for its generated run ID and attempts to +delete them after success or failure. Tests must not depend on unrelated gateway +state or delete resources they did not create. diff --git a/tests/suites/conformance/cli/tests/smoke.rs b/tests/suites/conformance/cli/tests/smoke.rs new file mode 100644 index 0000000000..daa26f7eef --- /dev/null +++ b/tests/suites/conformance/cli/tests/smoke.rs @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Driver-agnostic `OpenShell` CLI conformance tests. + +use openshell_conformance::{ + OpenShellRunner, SMOKE_CONTROL_PLANE_SCENARIO, SMOKE_EXEC_SCENARIO, Scenario, +}; + +/// Exercise the public CLI against a provisioned `OpenShell` gateway. +/// +/// The test runner supplies the candidate CLI explicitly so the same archive +/// can validate artifacts installed into any supported test guest. +#[tokio::test] +async fn control_plane() { + run_scenario(&SMOKE_CONTROL_PLANE_SCENARIO).await; +} + +/// Exercise sandbox exec separately so runtimes can select it by capability. +#[tokio::test] +async fn exec() { + run_scenario(&SMOKE_EXEC_SCENARIO).await; +} + +async fn run_scenario(scenario: &'static Scenario) { + let mut runner = + OpenShellRunner::from_env(scenario.name).expect("candidate openshell CLI is available"); + let result = async { + runner.check_gateway_status().await?; + scenario.run(&mut runner).await + } + .await; + if let Err(error) = runner.finish(result).await { + panic!("{} conformance scenario failed:\n{error}", scenario.name); + } +} From 2b31f713399eddd0140ff9a0f08d867cde2bb1e5 Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Mon, 28 Sep 2026 10:22:17 +0200 Subject: [PATCH 30/42] ci(windows): run control-plane conformance Signed-off-by: Evan Lezar --- .github/workflows/windows-msvc.yml | 4 + TESTING.md | 7 +- architecture/windows.md | 4 + crates/openshell-conformance/src/lib.rs | 15 + .../src/scenarios/smoke.rs | 54 ++- tasks/scripts/windows-msvc.ps1 | 126 +++++- tasks/windows.toml | 10 + tests/suites/conformance/Cargo.lock | 392 ++++++++++++++++++ tests/suites/conformance/Cargo.toml | 6 + tests/suites/conformance/README.md | 4 +- tests/suites/conformance/cli/Cargo.toml | 14 + 11 files changed, 613 insertions(+), 23 deletions(-) create mode 100644 tests/suites/conformance/Cargo.lock create mode 100644 tests/suites/conformance/Cargo.toml create mode 100644 tests/suites/conformance/cli/Cargo.toml diff --git a/.github/workflows/windows-msvc.yml b/.github/workflows/windows-msvc.yml index 34ebd9499f..8c847b2355 100644 --- a/.github/workflows/windows-msvc.yml +++ b/.github/workflows/windows-msvc.yml @@ -98,6 +98,8 @@ jobs: run: mise run --skip-tools windows:lint:${{ matrix.arch }} - name: Test run: mise run --skip-tools windows:test:${{ matrix.arch }} + - name: Control-plane conformance + run: mise run --skip-tools windows:test:conformance:${{ matrix.arch }} - name: Build MXC example E2E binaries run: mise run --skip-tools windows:build:${{ matrix.arch }} - name: Run shipped MXC provider-credential example in mock mode @@ -170,6 +172,8 @@ jobs: run: mise run --skip-tools windows:lint:${{ matrix.arch }} - name: Test run: mise run --skip-tools windows:test:${{ matrix.arch }} + - name: Control-plane conformance + run: mise run --skip-tools windows:test:conformance:${{ matrix.arch }} - name: sccache stats if: always() run: sccache --show-stats diff --git a/TESTING.md b/TESTING.md index ece1d396c5..c498d5e18d 100644 --- a/TESTING.md +++ b/TESTING.md @@ -198,9 +198,10 @@ is `Ready` through get and paginated list output, deletes it, and verifies its name no longer appears. The exec test creates its own sandbox and checks `sandbox exec` with a run-specific marker. Drivers without exec support run the control-plane test alone. Runtimes that require an explicit workload set -`OPENSHELL_CONFORMANCE_SMOKE_COMMAND` to a JSON string array. Sandbox lifecycle, -label matrices, VM overlay, and TLS-key permission assertions remain regular -E2E coverage. +`OPENSHELL_CONFORMANCE_SMOKE_COMMAND` to a JSON string array; additional create +options can be supplied through `OPENSHELL_CONFORMANCE_SMOKE_CREATE_ARGS`. +Sandbox lifecycle, label matrices, VM overlay, and TLS-key permission assertions +remain regular E2E coverage. Each invocation prints a ten-character run ID before creating resources. Conformance sandboxes use names such as `ct--cp` and diff --git a/architecture/windows.md b/architecture/windows.md index 8180f25762..da4f19b97d 100644 --- a/architecture/windows.md +++ b/architecture/windows.md @@ -246,6 +246,10 @@ Windows validation separates source correctness from host capability: fail-closed loss handling without requiring MXC. - The architecture-specific `windows:*` tasks check, lint, build, and run workspace and unsupported-driver contract tests for x64 and ARM64. +- Hosted Windows CI runs the archive suite's control-plane smoke test against + the in-process MXC mock. This verifies the public CLI, gateway, driver wiring, + and create/get/list/delete contract without claiming OS enforcement. It does + not run the exec smoke test because MXC does not support `sandbox exec`. - Mock MXC E2E validates gateway, CLI, driver, lifecycle, and policy wiring but is not evidence of OS enforcement. - Hosted Windows CI runs the shipped provider-credential example with a diff --git a/crates/openshell-conformance/src/lib.rs b/crates/openshell-conformance/src/lib.rs index b5374ea4d0..33e9bb5a46 100644 --- a/crates/openshell-conformance/src/lib.rs +++ b/crates/openshell-conformance/src/lib.rs @@ -288,6 +288,21 @@ impl OpenShellRunner { )) } + /// Uses the candidate CLI selected by the archive test runner. + /// + /// Archive-based tests set `OPENSHELL_BIN` to the candidate artifact. + /// Requiring it prevents a test from resolving another CLI from `PATH`. + pub fn from_env(scenario: &str) -> Result { + let binary = std::env::var_os("OPENSHELL_BIN") + .map(PathBuf::from) + .ok_or_else(|| { + RunnerError::BinaryUnavailable( + "OPENSHELL_BIN must name the candidate openshell CLI".to_string(), + ) + })?; + Self::with_binary(binary, scenario) + } + /// Creates a runner with an injected executor. This is useful for harness tests. pub fn with_executor(cli: Arc, scenario: &str) -> Self { Self { diff --git a/crates/openshell-conformance/src/scenarios/smoke.rs b/crates/openshell-conformance/src/scenarios/smoke.rs index 584cf101ef..5f486160d3 100644 --- a/crates/openshell-conformance/src/scenarios/smoke.rs +++ b/crates/openshell-conformance/src/scenarios/smoke.rs @@ -127,15 +127,15 @@ async fn create_sandbox( sandbox_name.to_string(), "--detach".to_string(), ]; - match smoke_command()? { - Some(command) => { - args.push("--".to_string()); - args.extend(command); - } - None => { - args.push("--from".to_string()); - args.push("base".to_string()); - } + if let Some(extra_args) = json_string_array_env("OPENSHELL_CONFORMANCE_SMOKE_CREATE_ARGS")? { + args.extend(extra_args); + } + if let Some(command) = smoke_command()? { + args.push("--".to_string()); + args.extend(command); + } else { + args.push("--from".to_string()); + args.push("base".to_string()); } let args = args.iter().map(String::as_str).collect::>(); let create = runner @@ -153,15 +153,9 @@ fn smoke_command() -> Result>, String> { } fn parse_smoke_command(value: Option) -> Result>, String> { - let Some(value) = value else { + let Some(command) = json_string_array("OPENSHELL_CONFORMANCE_SMOKE_COMMAND", value)? else { return Ok(None); }; - let value = value.into_string().map_err(|_| { - "OPENSHELL_CONFORMANCE_SMOKE_COMMAND must contain valid Unicode JSON".to_string() - })?; - let command = serde_json::from_str::>(&value).map_err(|error| { - format!("OPENSHELL_CONFORMANCE_SMOKE_COMMAND must be a JSON string array: {error}") - })?; if command.first().is_none_or(String::is_empty) { return Err( "OPENSHELL_CONFORMANCE_SMOKE_COMMAND must contain a non-empty executable".to_string(), @@ -170,6 +164,22 @@ fn parse_smoke_command(value: Option) -> Result>, S Ok(Some(command)) } +fn json_string_array_env(name: &str) -> Result>, String> { + json_string_array(name, std::env::var_os(name)) +} + +fn json_string_array(name: &str, value: Option) -> Result>, String> { + let Some(value) = value else { + return Ok(None); + }; + let value = value + .into_string() + .map_err(|_| format!("{name} must contain valid Unicode JSON"))?; + serde_json::from_str::>(&value) + .map(Some) + .map_err(|error| format!("{name} must be a JSON string array: {error}")) +} + async fn delete_sandbox( runner: &mut OpenShellRunner, sandbox_name: &str, @@ -325,4 +335,16 @@ mod tests { let error = parse_smoke_command(Some(OsString::from(r#"[""]"#))).unwrap_err(); assert!(error.contains("non-empty executable")); } + + #[test] + fn smoke_create_args_parse_a_json_string_array() { + assert_eq!( + json_string_array( + "OPENSHELL_CONFORMANCE_SMOKE_CREATE_ARGS", + Some(OsString::from(r#"["--policy","policy.yaml"]"#)), + ) + .unwrap(), + Some(vec!["--policy".into(), "policy.yaml".into()]) + ); + } } diff --git a/tasks/scripts/windows-msvc.ps1 b/tasks/scripts/windows-msvc.ps1 index 54bb721eac..8c4e9fb1b2 100644 --- a/tasks/scripts/windows-msvc.ps1 +++ b/tasks/scripts/windows-msvc.ps1 @@ -6,7 +6,7 @@ [CmdletBinding()] param( [Parameter(Mandatory = $true, Position = 0)] - [ValidateSet("check", "lint", "build", "test", "test-precommit", "test-unsupported", "test-mxc-real", "test-mxc-gb300", "artifacts", "ci")] + [ValidateSet("check", "lint", "build", "test", "test-precommit", "test-conformance", "test-unsupported", "test-mxc-real", "test-mxc-gb300", "artifacts", "ci")] [string] $Action, [Parameter(Position = 1)] @@ -559,6 +559,121 @@ function Invoke-PreCommitTest([string] $RustTarget) { -LogName "test-$RustTarget-precommit.log" } +function Invoke-ConformanceTest([string] $RustTarget) { + Assert-NativeTestTarget $RustTarget + + Invoke-VsCargo ` + -RustTarget $RustTarget ` + -CargoArgs "cargo build --target $RustTarget --bin openshell --bin openshell-gateway $Z3GatewayFeatures" ` + -LogName "build-$RustTarget-conformance.log" + + $binaryDir = Join-Path $TargetDir "$RustTarget\debug" + $gateway = Join-Path $binaryDir "openshell-gateway.exe" + $cli = Join-Path $binaryDir "openshell.exe" + + $buildRoot = Join-Path $binaryDir "build" + $z3Candidates = @( + Get-ChildItem -Path $buildRoot -Filter "libz3.dll" -File -Recurse -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -like "*\z3-$PrebuiltZ3Version\bin\libz3.dll" } | + Sort-Object LastWriteTimeUtc -Descending + ) + if ($z3Candidates.Count -eq 0) { + throw "The prebuilt Z3 runtime was not found under: $buildRoot" + } + Copy-Item -LiteralPath $z3Candidates[0].FullName -Destination (Join-Path $binaryDir "libz3.dll") -Force + + $tempRoot = $env:RUNNER_TEMP + if ([string]::IsNullOrWhiteSpace($tempRoot)) { + $tempRoot = [System.IO.Path]::GetTempPath() + } + $testRoot = Join-Path $tempRoot "openshell-conformance-$RustTarget-$([guid]::NewGuid().ToString('N'))" + $configRoot = Join-Path $testRoot "config" + $stateRoot = Join-Path $testRoot "state" + $workloadRoot = Join-Path $testRoot "workload" + $gatewayConfig = Join-Path $testRoot "gateway.toml" + $sandboxPolicy = Join-Path $testRoot "policy.yaml" + $gatewayLog = Join-Path $LogDir "test-$RustTarget-conformance-gateway.log" + $gatewayErrorLog = Join-Path $LogDir "test-$RustTarget-conformance-gateway.err.log" + New-Item -ItemType Directory -Force -Path $configRoot, $stateRoot, $workloadRoot | Out-Null + @" +[openshell] +version = 2 + +[openshell.drivers.mxc] +wxc_exec_path = "C:\\mxc\\wxc-exec.exe" +backend = "process_container" +"@ | Set-Content -LiteralPath $gatewayConfig -Encoding utf8 + $workloadPolicyPath = $workloadRoot.Replace('\', '/') + @" +version: 1 +filesystem_policy: + include_workdir: false + read_only: [] + read_write: + - "$workloadPolicyPath" +"@ | Set-Content -LiteralPath $sandboxPolicy -Encoding utf8 + + $listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0) + $listener.Start() + $port = ([System.Net.IPEndPoint] $listener.LocalEndpoint).Port + $listener.Stop() + + $env:OPENSHELL_GATEWAY_CONFIG = $gatewayConfig + $env:OPENSHELL_COMPUTE_DRIVER = "mxc" + $env:OPENSHELL_MXC_MOCK_WXC = "1" + $env:XDG_CONFIG_HOME = $configRoot + $env:XDG_STATE_HOME = $stateRoot + $env:OPENSHELL_BIN = $cli + $env:OPENSHELL_CONFORMANCE_SMOKE_CREATE_ARGS = (@( + "--policy", $sandboxPolicy + ) | ConvertTo-Json -Compress) + $env:OPENSHELL_CONFORMANCE_SMOKE_COMMAND = (@( + "C:\Windows\System32\cmd.exe", + "/c", + "echo ready > `"$workloadPolicyPath/ready.txt`" & ping -t 127.0.0.1 > NUL" + ) | ConvertTo-Json -Compress) + + $gatewayProcess = $null + try { + $gatewayProcess = Start-Process -FilePath $gateway ` + -ArgumentList @("--disable-tls", "--db-url", "sqlite::memory:", "--port", $port) ` + -PassThru -WindowStyle Hidden ` + -RedirectStandardOutput $gatewayLog -RedirectStandardError $gatewayErrorLog + + $deadline = (Get-Date).AddSeconds(30) + while ((Get-Date) -lt $deadline) { + if ($gatewayProcess.HasExited) { + throw "Conformance gateway exited early with code $($gatewayProcess.ExitCode)." + } + if (Get-NetTCPConnection -State Listen -LocalPort $port -ErrorAction SilentlyContinue) { + break + } + Start-Sleep -Milliseconds 250 + } + if (-not (Get-NetTCPConnection -State Listen -LocalPort $port -ErrorAction SilentlyContinue)) { + throw "Conformance gateway did not listen on port $port within 30 seconds." + } + + & $cli gateway add "http://127.0.0.1:$port" --local --name windows-conformance + if ($LASTEXITCODE -ne 0) { throw "Failed to register the conformance gateway." } + & $cli gateway select windows-conformance + if ($LASTEXITCODE -ne 0) { throw "Failed to select the conformance gateway." } + + Invoke-VsCargo ` + -RustTarget $RustTarget ` + -CargoArgs "cargo nextest run --manifest-path tests/suites/conformance/Cargo.toml --target $RustTarget --test smoke control_plane --no-capture" ` + -LogName "test-$RustTarget-conformance.log" + } catch { + Get-Content -LiteralPath $gatewayLog, $gatewayErrorLog -ErrorAction SilentlyContinue + throw + } finally { + if ($gatewayProcess -and -not $gatewayProcess.HasExited) { + Stop-Process -Id $gatewayProcess.Id -Force -ErrorAction SilentlyContinue + } + Remove-Item -LiteralPath $testRoot -Recurse -Force -ErrorAction SilentlyContinue + } +} + function Invoke-UnsupportedContractTests([string] $RustTarget) { Assert-NativeTestTarget $RustTarget @@ -678,13 +793,13 @@ if ($Action -eq "ci" -and (Get-HostArch) -ne "amd64") { } $targets = Get-SelectedTargets $Target -if ($Action -in @("test", "test-precommit", "test-unsupported", "test-mxc-real", "test-mxc-gb300")) { +if ($Action -in @("test", "test-precommit", "test-conformance", "test-unsupported", "test-mxc-real", "test-mxc-gb300")) { foreach ($rustTarget in $targets) { Assert-NativeTestTarget $rustTarget } } -if ($Action -in @("check", "lint", "build", "test", "test-precommit", "test-unsupported", "test-mxc-real", "test-mxc-gb300", "ci")) { +if ($Action -in @("check", "lint", "build", "test", "test-precommit", "test-conformance", "test-unsupported", "test-mxc-real", "test-mxc-gb300", "ci")) { $z3Features = Configure-Z3 $Z3WorkspaceFeatures = $z3Features.WorkspaceFeatures $Z3ServerFeatures = $z3Features.ServerFeatures @@ -722,6 +837,11 @@ switch ($Action) { Invoke-PreCommitTest $rustTarget } } + "test-conformance" { + foreach ($rustTarget in $targets) { + Invoke-ConformanceTest $rustTarget + } + } "test-unsupported" { foreach ($rustTarget in $targets) { Invoke-UnsupportedContractTests $rustTarget diff --git a/tasks/windows.toml b/tasks/windows.toml index 2db94c9c97..7d86ad6af6 100644 --- a/tasks/windows.toml +++ b/tasks/windows.toml @@ -55,6 +55,16 @@ description = "Run Windows ARM64 MSVC workspace tests with the CI nextest profil run = "echo 'windows:* tasks require a Windows MSVC host' && exit 1" run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 test-precommit aarch64-pc-windows-msvc" +["windows:test:conformance:x64"] +description = "Run portable control-plane conformance against a mock MXC gateway on native Windows x64" +run = "echo 'windows:* tasks require a Windows MSVC host' && exit 1" +run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 test-conformance x86_64-pc-windows-msvc" + +["windows:test:conformance:arm64"] +description = "Run portable control-plane conformance against a mock MXC gateway on native Windows ARM64" +run = "echo 'windows:* tasks require a Windows MSVC host' && exit 1" +run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 test-conformance aarch64-pc-windows-msvc" + ["windows:test:unsupported:x64"] description = "Run focused Windows unsupported compute-driver contract tests" run = "echo 'windows:* tasks require a Windows MSVC host' && exit 1" diff --git a/tests/suites/conformance/Cargo.lock b/tests/suites/conformance/Cargo.lock new file mode 100644 index 0000000000..4631162435 --- /dev/null +++ b/tests/suites/conformance/Cargo.lock @@ -0,0 +1,392 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "openshell-conformance" +version = "0.0.0" +dependencies = [ + "rand", + "serde", + "serde_json", + "tokio", +] + +[[package]] +name = "openshell-test-conformance-cli" +version = "0.0.0" +dependencies = [ + "openshell-conformance", + "tokio", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "smallvec" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zerocopy" +version = "0.8.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/tests/suites/conformance/Cargo.toml b/tests/suites/conformance/Cargo.toml new file mode 100644 index 0000000000..1c6b001096 --- /dev/null +++ b/tests/suites/conformance/Cargo.toml @@ -0,0 +1,6 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[workspace] +resolver = "2" +members = ["cli"] diff --git a/tests/suites/conformance/README.md b/tests/suites/conformance/README.md index 6aaa69b417..c3b26a8b70 100644 --- a/tests/suites/conformance/README.md +++ b/tests/suites/conformance/README.md @@ -27,7 +27,9 @@ workspace and select tests with nextest filters. Set `OPENSHELL_BIN` to the candidate CLI. The selected gateway must already be reachable. Runtimes that require an explicit sandbox workload can set -`OPENSHELL_CONFORMANCE_SMOKE_COMMAND` to a JSON string array. For example: +`OPENSHELL_CONFORMANCE_SMOKE_COMMAND` to a JSON string array. Set +`OPENSHELL_CONFORMANCE_SMOKE_CREATE_ARGS` to a JSON string array when the +runtime also needs portable create options such as `--policy`. For example: ```shell export OPENSHELL_CONFORMANCE_SMOKE_COMMAND='["sleep","infinity"]' diff --git a/tests/suites/conformance/cli/Cargo.toml b/tests/suites/conformance/cli/Cargo.toml new file mode 100644 index 0000000000..5edcefe687 --- /dev/null +++ b/tests/suites/conformance/cli/Cargo.toml @@ -0,0 +1,14 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "openshell-test-conformance-cli" +version = "0.0.0" +edition = "2024" +rust-version = "1.94" +license = "Apache-2.0" +repository = "https://github.com/NVIDIA/OpenShell" + +[dependencies] +openshell-conformance = { path = "../../../../crates/openshell-conformance" } +tokio = { version = "1.43", features = ["macros", "rt"] } From a52f38689812bedd1c02f6e9a29590b7d629b620 Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Mon, 28 Sep 2026 10:50:16 +0200 Subject: [PATCH 31/42] docs(conformance): explain scenario library (cherry picked from commit c907a4cb8613b686433638a011c3acfe26b98d22) Signed-off-by: Evan Lezar --- crates/openshell-conformance/README.md | 37 ++++++++++++++++++++++++++ tests/suites/conformance/README.md | 9 ++++--- 2 files changed, 42 insertions(+), 4 deletions(-) create mode 100644 crates/openshell-conformance/README.md diff --git a/crates/openshell-conformance/README.md b/crates/openshell-conformance/README.md new file mode 100644 index 0000000000..136ebb7694 --- /dev/null +++ b/crates/openshell-conformance/README.md @@ -0,0 +1,37 @@ + + +# OpenShell conformance scenarios + +This crate defines reusable black-box conformance scenarios for the public +`openshell` CLI. It owns command execution, assertions, diagnostics, generated +resource names, and best-effort cleanup. It does not provision a gateway or +inspect compute-driver internals. + +The crate is a scenario library, not the primary CI entrypoint: + +| Component | Responsibility | +|---|---| +| [`tests/suites/conformance/cli`](../../tests/suites/conformance/README.md) | Wrap exported scenarios as Cargo tests and select them according to runtime capabilities. New installed-artifact CI should use this workspace. | +| `openshell-conformance` | Define portable scenarios and the shared `OpenShellRunner`. | +| [`openshell-conformance-cli`](../openshell-conformance-cli) | Run registered scenarios manually or for compatibility with existing E2E tooling. | + +Cargo tests can invoke any exported `Scenario`. The standalone CLI invokes the +scenarios returned by `scenarios()`. A scenario does not need to be registered +with the CLI when it exists only as an independently selectable test capability. +For example, the Cargo suite invokes `SMOKE_CONTROL_PLANE_SCENARIO` and +`SMOKE_EXEC_SCENARIO` separately, while the compatibility CLI retains the +registered aggregate `SMOKE_SCENARIO` under the stable name `smoke`. + +Scenarios must exercise public CLI behavior, own only resources created for +their run ID, and remain independent of unrelated gateway state. Split coverage +when a behavior requires an optional runtime capability so environments can run +the largest supported subset without weakening assertions. Driver internals, +platform enforcement, and hardware qualification belong in driver-specific +tests rather than this crate. + +When adding a scenario, export it from the library and add the appropriate Cargo +test wrapper under `tests/suites/conformance/cli`. Add it to the standalone CLI +registry only when existing E2E or manual workflows also need that entrypoint. diff --git a/tests/suites/conformance/README.md b/tests/suites/conformance/README.md index c3b26a8b70..f5b7df4eb0 100644 --- a/tests/suites/conformance/README.md +++ b/tests/suites/conformance/README.md @@ -20,10 +20,11 @@ smoke test as the portability boundary. In particular: - `lifecycle` and the policy advisor tests have additional runtime requirements documented in their source modules. -The scenario implementations live in `crates/openshell-conformance` so the -archive tests and the standalone `openshell-conformance` compatibility runner -share assertions and cleanup. New installed-artifact CI should use this test -workspace and select tests with nextest filters. +The scenario implementations live in the +[`openshell-conformance` crate](../../../crates/openshell-conformance/README.md) +so the archive tests and the standalone `openshell-conformance` compatibility +runner share assertions and cleanup. New installed-artifact CI should use this +test workspace and select tests with nextest filters. Set `OPENSHELL_BIN` to the candidate CLI. The selected gateway must already be reachable. Runtimes that require an explicit sandbox workload can set From 90176444756749343663e480a0589c05ab813a2d Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Mon, 28 Sep 2026 15:46:08 +0200 Subject: [PATCH 32/42] test(windows): run full MXC conformance suite Signed-off-by: Evan Lezar --- .github/workflows/windows-msvc.yml | 26 +- TESTING.md | 9 +- architecture/build.md | 6 +- architecture/windows.md | 9 +- crates/openshell-conformance/Cargo.toml | 4 +- crates/openshell-conformance/README.md | 2 +- crates/openshell-conformance/src/executor.rs | 16 +- crates/openshell-conformance/src/lib.rs | 143 +++- .../src/scenarios/file_transfer.rs | 678 ++++++++++++++++++ .../src/scenarios/mod.rs | 11 + .../src/scenarios/policy_behavior.rs | 582 +++++++++++++++ .../src/scenarios/sandbox_lifecycle.rs | 278 +++++++ .../src/scenarios/smoke.rs | 3 - tasks/scripts/windows-msvc.ps1 | 2 +- tasks/windows.toml | 4 +- tests/suites/conformance/Cargo.lock | 73 +- tests/suites/conformance/README.md | 21 +- .../conformance/cli/tests/file_transfer.rs | 40 ++ .../suites/conformance/cli/tests/lifecycle.rs | 22 + .../conformance/cli/tests/policy_advisor.rs | 37 + 20 files changed, 1899 insertions(+), 67 deletions(-) create mode 100644 crates/openshell-conformance/src/scenarios/file_transfer.rs create mode 100644 crates/openshell-conformance/src/scenarios/policy_behavior.rs create mode 100644 crates/openshell-conformance/src/scenarios/sandbox_lifecycle.rs create mode 100644 tests/suites/conformance/cli/tests/file_transfer.rs create mode 100644 tests/suites/conformance/cli/tests/lifecycle.rs create mode 100644 tests/suites/conformance/cli/tests/policy_advisor.rs diff --git a/.github/workflows/windows-msvc.yml b/.github/workflows/windows-msvc.yml index 8c847b2355..a218754203 100644 --- a/.github/workflows/windows-msvc.yml +++ b/.github/workflows/windows-msvc.yml @@ -98,8 +98,19 @@ jobs: run: mise run --skip-tools windows:lint:${{ matrix.arch }} - name: Test run: mise run --skip-tools windows:test:${{ matrix.arch }} - - name: Control-plane conformance + - name: Full MXC conformance run: mise run --skip-tools windows:test:conformance:${{ matrix.arch }} + - name: Upload MXC conformance report + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: windows-mxc-conformance-${{ matrix.arch }} + path: | + build-${{ matrix.rust_target }}-conformance.log + test-${{ matrix.rust_target }}-conformance.log + test-${{ matrix.rust_target }}-conformance-gateway.log + test-${{ matrix.rust_target }}-conformance-gateway.err.log + if-no-files-found: warn - name: Build MXC example E2E binaries run: mise run --skip-tools windows:build:${{ matrix.arch }} - name: Run shipped MXC provider-credential example in mock mode @@ -172,8 +183,19 @@ jobs: run: mise run --skip-tools windows:lint:${{ matrix.arch }} - name: Test run: mise run --skip-tools windows:test:${{ matrix.arch }} - - name: Control-plane conformance + - name: Full MXC conformance run: mise run --skip-tools windows:test:conformance:${{ matrix.arch }} + - name: Upload MXC conformance report + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: windows-mxc-conformance-${{ matrix.arch }} + path: | + build-${{ matrix.rust_target }}-conformance.log + test-${{ matrix.rust_target }}-conformance.log + test-${{ matrix.rust_target }}-conformance-gateway.log + test-${{ matrix.rust_target }}-conformance-gateway.err.log + if-no-files-found: warn - name: sccache stats if: always() run: sccache --show-stats diff --git a/TESTING.md b/TESTING.md index c498d5e18d..d4effe6fce 100644 --- a/TESTING.md +++ b/TESTING.md @@ -150,8 +150,10 @@ Suites: - Common suite (`--features e2e`) - driver-neutral CLI behavior, sandbox lifecycle, sync, port forwarding, policy, and provider tests. - CLI conformance (`tests/suites/conformance`) - installed-artifact tests of portable public CLI behavior. Smoke coverage is split into independent - control-plane and exec tests so each driver runs only the capabilities it - implements. Scenario implementations live in `openshell-conformance`; the + control-plane and exec tests so failures identify the unsupported operation + without hiding later coverage. Conformance CI runs every test; focused + filters are for local diagnosis. Scenario implementations live in + `openshell-conformance`; the standalone `openshell-conformance` binary remains a compatibility runner for driver E2E wrappers. See the [suite README](tests/suites/conformance/README.md) for scope and selection guidance. @@ -196,8 +198,7 @@ on how the gateway was installed. Its control-plane test requires machine-readable status, creates a uniquely named detached sandbox, verifies it is `Ready` through get and paginated list output, deletes it, and verifies its name no longer appears. The exec test creates its own sandbox and checks -`sandbox exec` with a run-specific marker. Drivers without exec support run the -control-plane test alone. Runtimes that require an explicit workload set +`sandbox exec` with a run-specific marker. Runtimes that require an explicit workload set `OPENSHELL_CONFORMANCE_SMOKE_COMMAND` to a JSON string array; additional create options can be supplied through `OPENSHELL_CONFORMANCE_SMOKE_CREATE_ARGS`. Sandbox lifecycle, label matrices, VM overlay, and TLS-key permission assertions diff --git a/architecture/build.md b/architecture/build.md index dd1f2fb222..59a0018495 100644 --- a/architecture/build.md +++ b/architecture/build.md @@ -297,9 +297,9 @@ for explicit publication. CLI conformance runs after target provisioning and operates only through the configured OpenShell CLI. The archive suite defines the installed-artifact test boundary, while `openshell-conformance` provides reusable scenarios and a -standalone compatibility runner. Smoke coverage separates control-plane -create/get/list/delete behavior from exec behavior so a runtime can run the -portable subset matching its advertised capabilities. +standalone compatibility runner. Scenarios are independently selectable for +local diagnosis, while conformance CI runs the complete suite so unsupported +runtime behavior remains visible rather than being silently skipped. ## Python Wheel Packaging diff --git a/architecture/windows.md b/architecture/windows.md index da4f19b97d..23e6ec5cee 100644 --- a/architecture/windows.md +++ b/architecture/windows.md @@ -246,10 +246,11 @@ Windows validation separates source correctness from host capability: fail-closed loss handling without requiring MXC. - The architecture-specific `windows:*` tasks check, lint, build, and run workspace and unsupported-driver contract tests for x64 and ARM64. -- Hosted Windows CI runs the archive suite's control-plane smoke test against - the in-process MXC mock. This verifies the public CLI, gateway, driver wiring, - and create/get/list/delete contract without claiming OS enforcement. It does - not run the exec smoke test because MXC does not support `sandbox exec`. +- Hosted Windows CI runs the complete archive conformance suite against the + in-process MXC mock. Every scenario runs so unsupported operations appear as + explicit failures in the archived nextest report instead of being skipped. + The mock verifies public CLI, gateway, driver, lifecycle, and policy wiring; + it does not claim Windows OS enforcement. - Mock MXC E2E validates gateway, CLI, driver, lifecycle, and policy wiring but is not evidence of OS enforcement. - Hosted Windows CI runs the shipped provider-credential example with a diff --git a/crates/openshell-conformance/Cargo.toml b/crates/openshell-conformance/Cargo.toml index 7b67258045..fbe777a7aa 100644 --- a/crates/openshell-conformance/Cargo.toml +++ b/crates/openshell-conformance/Cargo.toml @@ -14,10 +14,8 @@ repository.workspace = true rand.workspace = true serde.workspace = true serde_json.workspace = true -tokio.workspace = true - -[dev-dependencies] tempfile = "3" +tokio.workspace = true [lints] workspace = true diff --git a/crates/openshell-conformance/README.md b/crates/openshell-conformance/README.md index 136ebb7694..63bf0573bd 100644 --- a/crates/openshell-conformance/README.md +++ b/crates/openshell-conformance/README.md @@ -14,7 +14,7 @@ The crate is a scenario library, not the primary CI entrypoint: | Component | Responsibility | |---|---| -| [`tests/suites/conformance/cli`](../../tests/suites/conformance/README.md) | Wrap exported scenarios as Cargo tests and select them according to runtime capabilities. New installed-artifact CI should use this workspace. | +| [`tests/suites/conformance/cli`](../../tests/suites/conformance/README.md) | Wrap exported scenarios as Cargo tests. Conformance CI should run the complete workspace; focused selection is for local diagnosis. | | `openshell-conformance` | Define portable scenarios and the shared `OpenShellRunner`. | | [`openshell-conformance-cli`](../openshell-conformance-cli) | Run registered scenarios manually or for compatibility with existing E2E tooling. | diff --git a/crates/openshell-conformance/src/executor.rs b/crates/openshell-conformance/src/executor.rs index b45c102ee1..5c5e5844c9 100644 --- a/crates/openshell-conformance/src/executor.rs +++ b/crates/openshell-conformance/src/executor.rs @@ -15,7 +15,12 @@ pub type CliExecution<'a> = Pin> + Send + 'a>>; pub trait CliExecutor: Send + Sync { - fn execute(&self, args: Vec, command_timeout: Duration) -> CliExecution<'_>; + fn execute( + &self, + args: Vec, + environment: Vec<(String, String)>, + command_timeout: Duration, + ) -> CliExecution<'_>; } pub enum CliExecutionError { @@ -34,11 +39,18 @@ impl ProcessCli { } impl CliExecutor for ProcessCli { - fn execute(&self, args: Vec, command_timeout: Duration) -> CliExecution<'_> { + fn execute( + &self, + args: Vec, + environment: Vec<(String, String)>, + command_timeout: Duration, + ) -> CliExecution<'_> { Box::pin(async move { let mut process = tokio::process::Command::new(&self.binary); process .args(&args) + .envs(environment) + .env("NO_COLOR", "1") .stdout(Stdio::piped()) .stderr(Stdio::piped()) .kill_on_drop(true); diff --git a/crates/openshell-conformance/src/lib.rs b/crates/openshell-conformance/src/lib.rs index 33e9bb5a46..739972960d 100644 --- a/crates/openshell-conformance/src/lib.rs +++ b/crates/openshell-conformance/src/lib.rs @@ -23,7 +23,12 @@ use tokio::time::sleep; use self::executor::{CliExecutionError, CliExecutor, ProcessCli}; -pub use scenarios::{SMOKE_CONTROL_PLANE_SCENARIO, SMOKE_EXEC_SCENARIO, SMOKE_SCENARIO}; +pub use scenarios::{ + FILE_TRANSFER_GIT_FILTERING_SCENARIO, FILE_TRANSFER_PATH_SAFETY_SCENARIO, + FILE_TRANSFER_ROUND_TRIP_SCENARIO, FILE_TRANSFER_SCENARIO, MECHANISTIC_PROPOSAL_SCENARIO, + NEW_HOSTNAME_PROPOSAL_SCENARIO, POLICY_LOCAL_SCENARIO, SANDBOX_LIFECYCLE_SCENARIO, + SMOKE_CONTROL_PLANE_SCENARIO, SMOKE_EXEC_SCENARIO, SMOKE_SCENARIO, +}; /// An installed conformance scenario. #[derive(Debug)] @@ -41,7 +46,14 @@ impl Scenario { } } -const SCENARIOS: &[Scenario] = &[SMOKE_SCENARIO]; +const SCENARIOS: &[Scenario] = &[ + SMOKE_SCENARIO, + SANDBOX_LIFECYCLE_SCENARIO, + FILE_TRANSFER_SCENARIO, + MECHANISTIC_PROPOSAL_SCENARIO, + NEW_HOSTNAME_PROPOSAL_SCENARIO, + POLICY_LOCAL_SCENARIO, +]; /// Returns every scenario compiled into this distribution. pub fn scenarios() -> &'static [Scenario] { @@ -248,6 +260,8 @@ pub struct OpenShellRunner { run_id: String, scenario: String, known_sandboxes: BTreeSet, + known_providers: BTreeSet, + known_provider_profiles: BTreeSet, finished: bool, } @@ -290,8 +304,9 @@ impl OpenShellRunner { /// Uses the candidate CLI selected by the archive test runner. /// - /// Archive-based tests set `OPENSHELL_BIN` to the candidate artifact. - /// Requiring it prevents a test from resolving another CLI from `PATH`. + /// Archive-based tests set `OPENSHELL_BIN` to the candidate artifact + /// installed in the guest. Requiring it here prevents a test from silently + /// resolving a different `openshell` binary from `PATH`. pub fn from_env(scenario: &str) -> Result { let binary = std::env::var_os("OPENSHELL_BIN") .map(PathBuf::from) @@ -310,6 +325,8 @@ impl OpenShellRunner { run_id: generate_run_id(), scenario: scenario.to_string(), known_sandboxes: BTreeSet::new(), + known_providers: BTreeSet::new(), + known_provider_profiles: BTreeSet::new(), finished: false, } } @@ -392,6 +409,16 @@ impl OpenShellRunner { self.known_sandboxes.remove(name); } + /// Register a provider name for cleanup. + pub fn track_provider(&mut self, name: &str) { + self.known_providers.insert(name.to_string()); + } + + /// Register a provider profile ID for cleanup. + pub fn track_provider_profile(&mut self, id: &str) { + self.known_provider_profiles.insert(id.to_string()); + } + pub async fn poll_until( &mut self, step: &str, @@ -439,6 +466,7 @@ impl OpenShellRunner { step: &str, expectation: &str, args: Vec, + environment: Vec<(String, String)>, command_timeout: Duration, ) -> Result { let context = self.context(step); @@ -446,22 +474,22 @@ impl OpenShellRunner { eprintln!("{context} running: {command}"); let started = Instant::now(); - let output = - self.cli - .execute(args, command_timeout) - .await - .map_err(|error| match error { - CliExecutionError::Timeout => RunnerError::Timeout { - context: context.clone(), - command: command.clone(), - timeout: command_timeout, - }, - CliExecutionError::Spawn(source) => RunnerError::Spawn { - context: context.clone(), - command: command.clone(), - source, - }, - })?; + let output = self + .cli + .execute(args, environment, command_timeout) + .await + .map_err(|error| match error { + CliExecutionError::Timeout => RunnerError::Timeout { + context: context.clone(), + command: command.clone(), + timeout: command_timeout, + }, + CliExecutionError::Spawn(source) => RunnerError::Spawn { + context: context.clone(), + command: command.clone(), + source, + }, + })?; let elapsed = started.elapsed(); eprintln!( "{context} completed in {:.1?}: exit {}", @@ -483,7 +511,10 @@ impl OpenShellRunner { } async fn cleanup(&self) -> Result<(), String> { - if self.known_sandboxes.is_empty() { + if self.known_sandboxes.is_empty() + && self.known_providers.is_empty() + && self.known_provider_profiles.is_empty() + { return Ok(()); } @@ -515,6 +546,56 @@ impl OpenShellRunner { } } + for name in self.known_providers.clone() { + let remaining = remaining_cleanup_time(cleanup_started); + if remaining.is_zero() { + failures.push(format!( + "{} cleanup budget expired before deleting provider '{name}'", + self.context("cleanup/delete-provider") + )); + break; + } + match self + .step("cleanup/delete-provider") + .description(format!("provider '{name}' is deleted or already absent")) + .with_timeout(remaining) + .run(&["provider", "delete", &name]) + .await + { + Ok(result) if result.success() || output_reports_not_found(&result) => {} + Ok(result) => failures.push(result.failure_diagnostic(&format!( + "provider '{name}' is deleted or already absent" + ))), + Err(error) => failures.push(error.to_string()), + } + } + + for id in self.known_provider_profiles.clone() { + let remaining = remaining_cleanup_time(cleanup_started); + if remaining.is_zero() { + failures.push(format!( + "{} cleanup budget expired before deleting provider profile '{id}'", + self.context("cleanup/delete-provider-profile") + )); + break; + } + match self + .step("cleanup/delete-provider-profile") + .description(format!( + "provider profile '{id}' is deleted or already absent" + )) + .with_timeout(remaining) + .run(&["provider", "profile", "delete", &id]) + .await + { + Ok(result) if result.success() || output_reports_not_found(&result) => {} + Ok(result) => failures.push(result.failure_diagnostic(&format!( + "provider profile '{id}' is deleted or already absent" + ))), + Err(error) => failures.push(error.to_string()), + } + } + if failures.is_empty() { Ok(()) } else { @@ -549,11 +630,24 @@ impl<'a> CommandStep<'a> { impl OpenShellCommand<'_> { pub async fn run(&self, args: &[&str]) -> Result { + self.run_with_env(args, &[]).await + } + + /// Run a command with environment values that are excluded from diagnostics. + pub async fn run_with_env( + &self, + args: &[&str], + environment: &[(&str, &str)], + ) -> Result { self.runner .run_strings( &self.step, &self.description, args.iter().map(|arg| (*arg).to_string()).collect(), + environment + .iter() + .map(|(name, value)| ((*name).to_string(), (*value).to_string())) + .collect(), self.timeout, ) .await @@ -723,7 +817,12 @@ mod tests { } impl CliExecutor for MockCli { - fn execute(&self, args: Vec, _command_timeout: Duration) -> CliExecution<'_> { + fn execute( + &self, + args: Vec, + _environment: Vec<(String, String)>, + _command_timeout: Duration, + ) -> CliExecution<'_> { let response = { let mut state = self.state.lock().expect("lock mock CLI state"); state.invocations.push(args); diff --git a/crates/openshell-conformance/src/scenarios/file_transfer.rs b/crates/openshell-conformance/src/scenarios/file_transfer.rs new file mode 100644 index 0000000000..b1cd38dc6f --- /dev/null +++ b/crates/openshell-conformance/src/scenarios/file_transfer.rs @@ -0,0 +1,678 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Portable CLI file-transfer conformance scenario. + +use std::fs; +use std::path::Path; +use std::process::Stdio; +use std::time::Duration; + +use tokio::process::Command; + +use crate::{CommandResult, OpenShellRunner, Scenario, ScenarioFuture}; + +const CREATE_TIMEOUT: Duration = Duration::from_mins(10); +const COMMAND_TIMEOUT: Duration = Duration::from_mins(2); +const TRANSFER_TIMEOUT: Duration = Duration::from_mins(5); +const LARGE_FILE_SIZE: usize = 512 * 1024; + +/// Certify portable upload and download behavior through the public CLI. +pub const FILE_TRANSFER_SCENARIO: Scenario = Scenario { + name: "file-transfer", + description: "Verify sandbox uploads, downloads, Git filtering, and path safety.", + run: run_file_transfer, +}; + +/// Certify basic file and directory upload and download behavior. +pub const FILE_TRANSFER_ROUND_TRIP_SCENARIO: Scenario = Scenario { + name: "file-transfer/round-trip", + description: "Verify file and directory upload and download round trips.", + run: run_round_trip, +}; + +/// Certify Git-aware upload filtering and fallback behavior. +pub const FILE_TRANSFER_GIT_FILTERING_SCENARIO: Scenario = Scenario { + name: "file-transfer/git-filtering", + description: "Verify Git-aware upload selection and unfiltered fallback behavior.", + run: run_git_filtering, +}; + +/// Certify file-transfer workspace boundary and filename safety behavior. +pub const FILE_TRANSFER_PATH_SAFETY_SCENARIO: Scenario = Scenario { + name: "file-transfer/path-safety", + description: "Verify workspace boundary enforcement and safe filename handling.", + run: run_path_safety, +}; + +fn run_file_transfer(runner: &mut OpenShellRunner) -> ScenarioFuture<'_> { + Box::pin(async move { + FILE_TRANSFER_ROUND_TRIP_SCENARIO.run(runner).await?; + FILE_TRANSFER_GIT_FILTERING_SCENARIO.run(runner).await?; + FILE_TRANSFER_PATH_SAFETY_SCENARIO.run(runner).await + }) +} + +fn run_round_trip(runner: &mut OpenShellRunner) -> ScenarioFuture<'_> { + Box::pin(async move { + let (sandbox_name, remote_root, local) = prepare_sandbox(runner, "round-trip").await?; + round_trip(runner, &sandbox_name, &remote_root, local.path()).await?; + download_file(runner, &sandbox_name, &remote_root, local.path()).await?; + download_directory(runner, &sandbox_name, &remote_root, local.path()).await?; + delete_sandbox(runner, &sandbox_name).await + }) +} + +fn run_git_filtering(runner: &mut OpenShellRunner) -> ScenarioFuture<'_> { + Box::pin(async move { + let (sandbox_name, remote_root, local) = prepare_sandbox(runner, "git-filtering").await?; + gitignore_filtering(runner, &sandbox_name, &remote_root, local.path()).await?; + single_file_from_git_repo(runner, &sandbox_name, &remote_root, local.path()).await?; + gitignored_directory_fallback(runner, &sandbox_name, &remote_root, local.path()).await?; + delete_sandbox(runner, &sandbox_name).await + }) +} + +fn run_path_safety(runner: &mut OpenShellRunner) -> ScenarioFuture<'_> { + Box::pin(async move { + let (sandbox_name, remote_root, local) = prepare_sandbox(runner, "path-safety").await?; + reject_workspace_escape(runner, &sandbox_name, &remote_root, local.path()).await?; + download_dash_leading_name(runner, &sandbox_name, &remote_root, local.path()).await?; + delete_sandbox(runner, &sandbox_name).await + }) +} + +async fn prepare_sandbox( + runner: &mut OpenShellRunner, + group: &str, +) -> Result<(String, String, tempfile::TempDir), String> { + let suffix = match group { + "round-trip" => "fr", + "git-filtering" => "fg", + "path-safety" => "fs", + _ => return Err(format!("unknown file-transfer group {group:?}")), + }; + let sandbox_name = format!("ct-{}-{suffix}", runner.id()); + let remote_root = format!("/sandbox/file-transfer-{}-{suffix}", runner.id()); + let local = + tempfile::tempdir().map_err(|error| format!("create temporary directory: {error}"))?; + + runner.track_sandbox(&sandbox_name); + let create = runner + .step(format!("{group}/create")) + .description(format!("sandbox '{sandbox_name}' is created")) + .with_timeout(CREATE_TIMEOUT) + .run(&["sandbox", "create", "--name", &sandbox_name, "--detach"]) + .await + .map_err(|error| error.to_string())?; + create.require_success()?; + + exec( + runner, + &sandbox_name, + &format!("{group}/prepare"), + &format!("mkdir -p '{remote_root}'"), + ) + .await?; + + Ok((sandbox_name, remote_root, local)) +} + +async fn delete_sandbox(runner: &mut OpenShellRunner, sandbox_name: &str) -> Result<(), String> { + let delete = runner + .step("delete") + .description(format!("sandbox '{sandbox_name}' is deleted")) + .with_timeout(COMMAND_TIMEOUT) + .run(&["sandbox", "delete", sandbox_name]) + .await + .map_err(|error| error.to_string())?; + delete.require_success()?; + runner.forget_sandbox(sandbox_name); + Ok(()) +} + +async fn round_trip( + runner: &OpenShellRunner, + sandbox: &str, + remote_root: &str, + local_root: &Path, +) -> Result<(), String> { + let source = local_root.join("roundtrip-upload"); + fs::create_dir_all(source.join("subdir")).map_err(fs_error("create round-trip source"))?; + fs::write(source.join("greeting.txt"), "hello-from-local") + .map_err(fs_error("write greeting.txt"))?; + fs::write(source.join("subdir/nested.txt"), "nested-content") + .map_err(fs_error("write nested.txt"))?; + + let large = (0u8..=250) + .cycle() + .take(LARGE_FILE_SIZE) + .collect::>(); + fs::write(source.join("large.bin"), &large).map_err(fs_error("write large.bin"))?; + + let remote = format!("{remote_root}/roundtrip"); + upload(runner, sandbox, "roundtrip/upload", &source, &remote, true).await?; + + let destination = local_root.join("roundtrip-download"); + fs::create_dir(&destination).map_err(fs_error("create round-trip destination"))?; + let remote_source = format!("{remote}/roundtrip-upload"); + download( + runner, + sandbox, + "roundtrip/download", + &remote_source, + &destination, + ) + .await?; + + require_text( + &destination.join("greeting.txt"), + "hello-from-local", + "round-trip greeting", + )?; + require_text( + &destination.join("subdir/nested.txt"), + "nested-content", + "round-trip nested file", + )?; + let actual = fs::read(destination.join("large.bin")).map_err(fs_error("read large.bin"))?; + if actual != large { + return Err(format!( + "large file changed during round trip: expected {} bytes, received {} bytes", + large.len(), + actual.len() + )); + } + + let single = local_root.join("single.txt"); + fs::write(&single, "single-file-payload").map_err(fs_error("write single.txt"))?; + let remote_single = format!("{remote_root}/single.txt"); + upload( + runner, + sandbox, + "single/upload", + &single, + &remote_single, + true, + ) + .await?; + let single_destination = local_root.join("single-download"); + fs::create_dir(&single_destination).map_err(fs_error("create single-file destination"))?; + download( + runner, + sandbox, + "single/download", + &remote_single, + &single_destination, + ) + .await?; + require_text( + &single_destination.join("single.txt"), + "single-file-payload", + "single-file round trip", + ) +} + +async fn gitignore_filtering( + runner: &OpenShellRunner, + sandbox: &str, + remote_root: &str, + local_root: &Path, +) -> Result<(), String> { + let repository = local_root.join("filter-repo"); + fs::create_dir(&repository).map_err(fs_error("create filter repository"))?; + git_init(&repository).await?; + fs::write(repository.join(".gitignore"), "*.log\nbuild/\n") + .map_err(fs_error("write filter .gitignore"))?; + fs::write(repository.join("tracked.txt"), "i-am-tracked") + .map_err(fs_error("write tracked.txt"))?; + fs::write(repository.join("ignored.log"), "i-should-be-filtered") + .map_err(fs_error("write ignored.log"))?; + fs::create_dir(repository.join("build")).map_err(fs_error("create ignored build directory"))?; + fs::write(repository.join("build/output.bin"), "build-artifact") + .map_err(fs_error("write ignored build artifact"))?; + git(&repository, &["add", "."]).await?; + + let remote = format!("{remote_root}/filtered"); + upload( + runner, + sandbox, + "gitignore/upload", + &repository, + &remote, + false, + ) + .await?; + + let destination = local_root.join("filter-download"); + fs::create_dir(&destination).map_err(fs_error("create filter destination"))?; + download(runner, sandbox, "gitignore/download", &remote, &destination).await?; + let uploaded = destination.join("filter-repo"); + require_text( + &uploaded.join("tracked.txt"), + "i-am-tracked", + "Git-aware tracked file", + )?; + require_exists(&uploaded.join(".gitignore"), "uploaded .gitignore")?; + require_absent(&uploaded.join("ignored.log"), "Git-ignored file")?; + require_absent(&uploaded.join("build"), "Git-ignored directory") +} + +async fn single_file_from_git_repo( + runner: &OpenShellRunner, + sandbox: &str, + remote_root: &str, + local_root: &Path, +) -> Result<(), String> { + let repository = local_root.join("single-repo"); + fs::create_dir_all(repository.join("nested")) + .map_err(fs_error("create single-file repository"))?; + git_init(&repository).await?; + fs::write(repository.join(".gitignore"), "*.log\n") + .map_err(fs_error("write single-file .gitignore"))?; + fs::write( + repository.join("nested/config.txt"), + "single-file-from-repo", + ) + .map_err(fs_error("write repository config.txt"))?; + fs::write(repository.join("tracked.txt"), "should-not-upload") + .map_err(fs_error("write unrelated tracked.txt"))?; + fs::write(repository.join("ignored.log"), "ignored") + .map_err(fs_error("write repository ignored.log"))?; + + let remote = format!("{remote_root}/single-from-repo"); + upload( + runner, + sandbox, + "single-from-repo/upload", + &repository.join("nested/config.txt"), + &remote, + false, + ) + .await?; + let destination = local_root.join("single-repo-download"); + fs::create_dir(&destination).map_err(fs_error("create single-repo destination"))?; + download( + runner, + sandbox, + "single-from-repo/download", + &remote, + &destination, + ) + .await?; + require_text( + &destination.join("config.txt"), + "single-file-from-repo", + "single file selected from repository", + )?; + require_absent( + &destination.join("tracked.txt"), + "unselected repository file", + )?; + require_absent(&destination.join("ignored.log"), "ignored repository file") +} + +async fn download_file( + runner: &OpenShellRunner, + sandbox: &str, + remote_root: &str, + local_root: &Path, +) -> Result<(), String> { + let remote = format!("{remote_root}/download-file.txt"); + exec( + runner, + sandbox, + "download-file/seed", + &format!("printf greeting-payload > '{remote}'"), + ) + .await?; + let destination = local_root.join("download-file"); + fs::create_dir(&destination).map_err(fs_error("create file-download destination"))?; + download( + runner, + sandbox, + "download-file/download", + &remote, + &destination, + ) + .await?; + require_text( + &destination.join("download-file.txt"), + "greeting-payload", + "downloaded file", + ) +} + +async fn download_directory( + runner: &OpenShellRunner, + sandbox: &str, + remote_root: &str, + local_root: &Path, +) -> Result<(), String> { + let remote = format!("{remote_root}/tree"); + exec( + runner, + sandbox, + "download-directory/seed", + &format!( + "mkdir -p '{remote}/sub' && printf top-level > '{remote}/root.txt' && printf nested > '{remote}/sub/child.txt'" + ), + ) + .await?; + let destination = local_root.join("download-directory"); + fs::create_dir(&destination).map_err(fs_error("create directory-download destination"))?; + download( + runner, + sandbox, + "download-directory/download", + &remote, + &destination, + ) + .await?; + require_text( + &destination.join("root.txt"), + "top-level", + "downloaded directory root file", + )?; + require_text( + &destination.join("sub/child.txt"), + "nested", + "downloaded directory nested file", + ) +} + +async fn reject_workspace_escape( + runner: &OpenShellRunner, + sandbox: &str, + remote_root: &str, + local_root: &Path, +) -> Result<(), String> { + let etc_link = format!("{remote_root}/etc-link"); + let passwd_link = format!("{remote_root}/passwd-link"); + exec( + runner, + sandbox, + "workspace-escape/seed", + &format!("ln -s /etc '{etc_link}' && ln -s /etc/passwd '{passwd_link}'"), + ) + .await?; + let destination = local_root.join("workspace-escape"); + fs::create_dir(&destination).map_err(fs_error("create workspace-escape destination"))?; + + for (step, source) in [ + ("directory-link", etc_link.clone()), + ("file-link", passwd_link), + ("linked-component", format!("{etc_link}/passwd")), + ] { + let result = download_result( + runner, + sandbox, + &format!("workspace-escape/{step}"), + &source, + &destination, + ) + .await?; + if result.success() { + return Err(format!( + "download unexpectedly accepted sandbox path {source:?} that resolves outside the workspace" + )); + } + let diagnostic = format!("{}\n{}", result.stdout(), result.stderr()); + if !diagnostic.contains("resolves to") + || !diagnostic.contains("outside the") + || !diagnostic.contains("sandbox workspace") + { + return Err(result.failure_diagnostic( + "download is rejected because the resolved source is outside the sandbox workspace", + )); + } + } + require_absent(&destination.join("passwd"), "escaped passwd file")?; + require_absent(&destination.join("etc-link"), "escaped /etc directory") +} + +async fn download_dash_leading_name( + runner: &OpenShellRunner, + sandbox: &str, + remote_root: &str, + local_root: &Path, +) -> Result<(), String> { + let remote = format!("{remote_root}/--checkpoint-action=evil"); + exec( + runner, + sandbox, + "dash-leading/seed", + &format!("printf dash-payload > '{remote}'"), + ) + .await?; + let destination = local_root.join("dash-leading"); + fs::create_dir(&destination).map_err(fs_error("create dash-leading destination"))?; + download( + runner, + sandbox, + "dash-leading/download", + &remote, + &destination, + ) + .await?; + require_text( + &destination.join("--checkpoint-action=evil"), + "dash-payload", + "dash-leading file", + ) +} + +async fn gitignored_directory_fallback( + runner: &OpenShellRunner, + sandbox: &str, + remote_root: &str, + local_root: &Path, +) -> Result<(), String> { + let remote_seed = format!("{remote_root}/runs/test.json"); + exec( + runner, + sandbox, + "gitignored-fallback/seed", + &format!("mkdir -p '{remote_root}/runs' && printf downloaded-payload > '{remote_seed}'"), + ) + .await?; + + let repository = local_root.join("fallback-repo"); + fs::create_dir(&repository).map_err(fs_error("create fallback repository"))?; + git_init(&repository).await?; + fs::write(repository.join(".gitignore"), "runs/\n") + .map_err(fs_error("write fallback .gitignore"))?; + let runs = repository.join("runs"); + fs::create_dir(&runs).map_err(fs_error("create ignored runs directory"))?; + download( + runner, + sandbox, + "gitignored-fallback/download-seed", + &remote_seed, + &runs, + ) + .await?; + require_exists(&runs.join("test.json"), "downloaded ignored file")?; + + let remote = format!("{remote_root}/reuploaded"); + let upload_result = upload_result( + runner, + sandbox, + "gitignored-fallback/upload", + &runs, + &remote, + false, + ) + .await?; + upload_result.require_success()?; + let output = format!("{}\n{}", upload_result.stdout(), upload_result.stderr()); + if !output.contains(".gitignore filtering excluded all files") { + return Err(upload_result.failure_diagnostic( + "upload warns that Git filtering excluded every file and falls back to an unfiltered transfer", + )); + } + + let destination = local_root.join("fallback-download"); + fs::create_dir(&destination).map_err(fs_error("create fallback destination"))?; + download( + runner, + sandbox, + "gitignored-fallback/download", + &remote, + &destination, + ) + .await?; + require_text( + &destination.join("runs/test.json"), + "downloaded-payload", + "re-uploaded Git-ignored file", + ) +} + +async fn upload( + runner: &OpenShellRunner, + sandbox: &str, + step: &str, + source: &Path, + destination: &str, + no_git_ignore: bool, +) -> Result<(), String> { + let result = upload_result(runner, sandbox, step, source, destination, no_git_ignore).await?; + result.require_success() +} + +async fn upload_result( + runner: &OpenShellRunner, + sandbox: &str, + step: &str, + source: &Path, + destination: &str, + no_git_ignore: bool, +) -> Result { + let source = source + .to_str() + .ok_or_else(|| format!("local upload path is not UTF-8: {}", source.display()))?; + let mut args = vec!["sandbox", "upload", sandbox, source, destination]; + if no_git_ignore { + args.push("--no-git-ignore"); + } + runner + .step(step) + .description(format!("upload {source:?} to {destination:?} succeeds")) + .with_timeout(TRANSFER_TIMEOUT) + .run(&args) + .await + .map_err(|error| error.to_string()) +} + +async fn download( + runner: &OpenShellRunner, + sandbox: &str, + step: &str, + source: &str, + destination: &Path, +) -> Result<(), String> { + let result = download_result(runner, sandbox, step, source, destination).await?; + result.require_success() +} + +async fn download_result( + runner: &OpenShellRunner, + sandbox: &str, + step: &str, + source: &str, + destination: &Path, +) -> Result { + let destination = destination.to_str().ok_or_else(|| { + format!( + "local download path is not UTF-8: {}", + destination.display() + ) + })?; + runner + .step(step) + .description(format!( + "download {source:?} to {destination:?} has the expected disposition" + )) + .with_timeout(TRANSFER_TIMEOUT) + .run(&["sandbox", "download", sandbox, source, destination]) + .await + .map_err(|error| error.to_string()) +} + +async fn exec( + runner: &OpenShellRunner, + sandbox: &str, + step: &str, + script: &str, +) -> Result<(), String> { + let result = runner + .step(step) + .description(format!("sandbox fixture setup for {step} succeeds")) + .with_timeout(COMMAND_TIMEOUT) + .run(&[ + "sandbox", "exec", "--name", sandbox, "--no-tty", "--", "sh", "-c", script, + ]) + .await + .map_err(|error| error.to_string())?; + result.require_success() +} + +async fn git_init(repository: &Path) -> Result<(), String> { + git(repository, &["init", "--quiet"]).await +} + +async fn git(repository: &Path, args: &[&str]) -> Result<(), String> { + let output = Command::new("git") + .args(args) + .current_dir(repository) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .map_err(|error| format!("failed to run git in {}: {error}", repository.display()))?; + if output.status.success() { + return Ok(()); + } + Err(format!( + "git {} failed in {} with status {}\nstdout:\n{}\nstderr:\n{}", + args.join(" "), + repository.display(), + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + )) +} + +fn require_text(path: &Path, expected: &str, label: &str) -> Result<(), String> { + let actual = fs::read_to_string(path) + .map_err(|error| format!("read {label} at {}: {error}", path.display()))?; + if actual == expected { + Ok(()) + } else { + Err(format!( + "{label} content mismatch at {}: expected {expected:?}, received {actual:?}", + path.display() + )) + } +} + +fn require_exists(path: &Path, label: &str) -> Result<(), String> { + if path.exists() { + Ok(()) + } else { + Err(format!("{label} does not exist at {}", path.display())) + } +} + +fn require_absent(path: &Path, label: &str) -> Result<(), String> { + if path.exists() { + Err(format!("{label} unexpectedly exists at {}", path.display())) + } else { + Ok(()) + } +} + +fn fs_error(context: &'static str) -> impl FnOnce(std::io::Error) -> String { + move |error| format!("{context}: {error}") +} diff --git a/crates/openshell-conformance/src/scenarios/mod.rs b/crates/openshell-conformance/src/scenarios/mod.rs index fb90585885..467d0962a3 100644 --- a/crates/openshell-conformance/src/scenarios/mod.rs +++ b/crates/openshell-conformance/src/scenarios/mod.rs @@ -3,6 +3,17 @@ //! Registered, portable conformance scenarios. +mod file_transfer; +mod policy_behavior; +mod sandbox_lifecycle; mod smoke; +pub use file_transfer::{ + FILE_TRANSFER_GIT_FILTERING_SCENARIO, FILE_TRANSFER_PATH_SAFETY_SCENARIO, + FILE_TRANSFER_ROUND_TRIP_SCENARIO, FILE_TRANSFER_SCENARIO, +}; +pub use policy_behavior::{ + MECHANISTIC_PROPOSAL_SCENARIO, NEW_HOSTNAME_PROPOSAL_SCENARIO, POLICY_LOCAL_SCENARIO, +}; +pub use sandbox_lifecycle::SANDBOX_LIFECYCLE_SCENARIO; pub use smoke::{SMOKE_CONTROL_PLANE_SCENARIO, SMOKE_EXEC_SCENARIO, SMOKE_SCENARIO}; diff --git a/crates/openshell-conformance/src/scenarios/policy_behavior.rs b/crates/openshell-conformance/src/scenarios/policy_behavior.rs new file mode 100644 index 0000000000..e26b89e45c --- /dev/null +++ b/crates/openshell-conformance/src/scenarios/policy_behavior.rs @@ -0,0 +1,582 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Portable policy-local and mechanistic proposal checks. + +use std::io::Write as _; +use std::time::{Duration, Instant}; + +use serde::Deserialize; +use serde_json::Value; +use tempfile::NamedTempFile; +use tokio::time::sleep; + +use crate::{OpenShellRunner, Scenario, ScenarioFuture}; + +const CREATE_TIMEOUT: Duration = Duration::from_mins(10); +const COMMAND_TIMEOUT: Duration = Duration::from_secs(45); +const READY_TIMEOUT: Duration = Duration::from_mins(4); +const POLL_INTERVAL: Duration = Duration::from_secs(2); +const PROPOSAL_TIMEOUT: Duration = Duration::from_secs(90); + +#[derive(Deserialize)] +struct SandboxState { + name: String, + phase: String, +} + +pub const POLICY_LOCAL_SCENARIO: Scenario = Scenario { + name: "policy-local", + description: "Read and request a rule through the sandbox-local policy HTTP API.", + run: run_policy_local, +}; + +pub const MECHANISTIC_PROPOSAL_SCENARIO: Scenario = Scenario { + name: "mechanistic-proposal", + description: "Turn a denied transparent TCP open into a scoped policy draft.", + run: run_mechanistic_proposal, +}; + +pub const NEW_HOSTNAME_PROPOSAL_SCENARIO: Scenario = Scenario { + name: "new-hostname-proposal", + description: "Turn a denied TCP open to a hostname absent from policy into a scoped draft.", + run: run_new_hostname_proposal, +}; + +const EMPTY_NETWORK_POLICY: &[u8] = br"version: 1 +filesystem_policy: + include_workdir: true + read_only: [/usr, /bin, /lib, /lib64, /proc, /dev/urandom, /app, /etc, /var/log] + read_write: [/sandbox, /tmp, /dev/null] +landlock: { compatibility: best_effort } +network_policies: {} +"; + +fn run_policy_local(runner: &mut OpenShellRunner) -> ScenarioFuture<'_> { + Box::pin(async move { + let name = format!("ct-{}-pl", runner.id()); + create_sandbox(runner, &name, None).await?; + enable_proposals(runner, &name).await?; + let binary = sandbox_bash_path(runner, &name).await?; + + let started = Instant::now(); + let readiness_path = format!("/v1/proposals/ct-{}-readiness", runner.id()); + loop { + match request_policy_local(runner, &name, "/v1/policy/current").await { + Ok(response) + if response["format"] == "yaml" + && response["policy_yaml"] + .as_str() + .is_some_and(|yaml| yaml.contains("version: 1")) => + { + // The current-policy route is local; proposal submission also + // needs the supervisor's workspace and gateway lookup session. + match request_policy_local_http(runner, &name, "GET", &readiness_path, "", 404) + .await + { + Ok(lookup) if lookup["error"] == "chunk_not_found" => break, + Ok(lookup) => { + return Err(format!( + "policy.local proposal lookup returned an invalid readiness response: {lookup}" + )); + } + Err(error) if started.elapsed() >= READY_TIMEOUT => return Err(error), + Err(_) => {} + } + } + Ok(response) => { + return Err(format!( + "policy.local returned an invalid current policy: {response}" + )); + } + Err(error) => { + if started.elapsed() >= READY_TIMEOUT { + return Err(error); + } + } + } + sleep(POLL_INTERVAL).await; + } + let denials = request_policy_local(runner, &name, "/v1/denials?last=1").await?; + if !denials["denials"].is_array() || !denials["log_available"].is_boolean() { + return Err(format!( + "policy.local returned an invalid denials response: {denials}" + )); + } + + let rule_name = format!("conformance_local_{}", runner.id()); + let payload = serde_json::json!({ + "intent_summary": "Allow Bash to read the conformance path on example.invalid.", + "operations": [{ + "addRule": { + "ruleName": &rule_name, + "rule": { + "name": &rule_name, + "endpoints": [{ + "host": "example.invalid", + "port": 443, + "protocol": "rest", + "enforcement": "enforce", + "rules": [{"allow": {"method": "GET", "path": "/conformance"}}] + }], + "binaries": [{"path": &binary}] + } + } + }] + }) + .to_string(); + let submitted = + request_policy_local_http(runner, &name, "POST", "/v1/proposals", &payload, 202) + .await?; + let chunk_id = submitted["accepted_chunk_ids"] + .as_array() + .filter(|ids| ids.len() == 1) + .and_then(|ids| ids[0].as_str()) + .filter(|id| !id.is_empty()) + .ok_or_else(|| format!("policy.local did not accept one proposal: {submitted}"))?; + if submitted["status"] != "submitted" + || submitted["accepted_chunks"] != 1 + || submitted["rejected_chunks"] != 0 + { + return Err(format!("policy.local did not submit one rule: {submitted}")); + } + + let state = + request_policy_local(runner, &name, &format!("/v1/proposals/{chunk_id}")).await?; + if state["chunk_id"] != chunk_id + || state["rule_name"] != rule_name + || state["binary"] != binary + || !matches!(state["status"].as_str(), Some("pending" | "approved")) + { + return Err(format!("policy.local returned the wrong proposal: {state}")); + } + + let review = runner + .step("reviewer-inbox") + .description("the requested rule is visible to the reviewer") + .with_timeout(COMMAND_TIMEOUT) + .run(&["rule", "get", &name]) + .await + .map_err(|error| error.to_string())?; + review.require_success()?; + if !review.stdout().contains(&format!("Chunk: {chunk_id}")) + || !review.stdout().contains(&format!("Rule: {rule_name}")) + { + return Err(review.failure_diagnostic("the submitted rule is in the reviewer inbox")); + } + Ok(()) + }) +} + +async fn sandbox_bash_path(runner: &OpenShellRunner, name: &str) -> Result { + let result = runner + .step("bash-binary") + .description("the sandbox's Bash executable has a canonical path") + .with_timeout(COMMAND_TIMEOUT) + .run(&[ + "sandbox", + "exec", + "--name", + name, + "--no-tty", + "--", + "bash", + "-c", + "printf '%s\\n' \"$(readlink -f /proc/$$/exe)\"", + ]) + .await + .map_err(|error| error.to_string())?; + result.require_success()?; + let binary = result.stdout().trim(); + if !binary.starts_with('/') + || binary.contains('\n') + || binary.rsplit('/').next() != Some("bash") + { + return Err(result.failure_diagnostic("one absolute Bash executable path ending in /bash")); + } + Ok(binary.to_string()) +} + +async fn enable_proposals(runner: &OpenShellRunner, name: &str) -> Result<(), String> { + let set = runner + .step("enable-policy-advisor") + .description("sandbox-scoped policy advisor setting is enabled") + .with_timeout(COMMAND_TIMEOUT) + .run(&[ + "settings", + "set", + name, + "--key", + "agent_policy_proposals_enabled", + "--value", + "true", + ]) + .await + .map_err(|error| error.to_string())?; + set.require_success()?; + + let settings = runner + .step("effective-settings") + .description("policy advisor setting is effectively enabled") + .with_timeout(COMMAND_TIMEOUT) + .run(&["settings", "get", name, "--json"]) + .await + .map_err(|error| error.to_string())?; + settings.require_success()?; + let value: Value = settings.json().map_err(|error| error.to_string())?; + if value["settings"]["agent_policy_proposals_enabled"]["value"] != "true" { + return Err(settings.failure_diagnostic( + "effective agent_policy_proposals_enabled is true; check for a global override", + )); + } + Ok(()) +} + +async fn request_policy_local( + runner: &OpenShellRunner, + sandbox: &str, + path: &str, +) -> Result { + request_policy_local_http(runner, sandbox, "GET", path, "", 200).await +} + +async fn request_policy_local_http( + runner: &OpenShellRunner, + sandbox: &str, + method: &str, + path: &str, + body: &str, + expected_status: u16, +) -> Result { + let script = "method=$1; path=$2; body=$3; exec 3<>/dev/tcp/policy.local/80 || exit 1; printf '%s %s HTTP/1.1\\r\\nHost: policy.local\\r\\nContent-Type: application/json\\r\\nContent-Length: %s\\r\\nConnection: close\\r\\n\\r\\n' \"$method\" \"$path\" \"${#body}\" >&3; printf '%s' \"$body\" >&3; cat <&3"; + let result = runner + .step(format!("policy-local-{method}{path}")) + .description(format!( + "{method} http://policy.local{path} succeeds from the sandbox" + )) + .with_timeout(COMMAND_TIMEOUT) + .run(&[ + "sandbox", + "exec", + "--name", + sandbox, + "--no-tty", + "--", + "bash", + "-c", + script, + "policy-local-http", + method, + path, + body, + ]) + .await + .map_err(|error| error.to_string())?; + result.require_success()?; + let (headers, body) = result.stdout().split_once("\r\n\r\n").ok_or_else(|| { + result.failure_diagnostic("a complete HTTP response with headers and JSON body") + })?; + if !headers.starts_with(&format!("HTTP/1.1 {expected_status} ")) + || !headers.lines().any(|line| { + line.to_ascii_lowercase() + .starts_with("content-type: application/json") + }) + { + return Err( + result.failure_diagnostic(&format!("HTTP {expected_status} with JSON Content-Type")) + ); + } + serde_json::from_str(body) + .map_err(|error| result.failure_diagnostic(&format!("valid JSON body: {error}"))) +} + +fn run_mechanistic_proposal(runner: &mut OpenShellRunner) -> ScenarioFuture<'_> { + Box::pin(async move { + let mut policy = NamedTempFile::new().map_err(|error| error.to_string())?; + policy + .write_all(EMPTY_NETWORK_POLICY) + .map_err(|error| error.to_string())?; + let policy_path = policy + .path() + .to_str() + .ok_or("temporary policy path is not UTF-8")?; + let name = format!("ct-{}-mp", runner.id()); + create_sandbox(runner, &name, Some(policy_path)).await?; + enable_proposals(runner, &name).await?; + + let effective = runner + .step("effective-policy") + .description("sandbox has no network allow rules before the probe") + .with_timeout(COMMAND_TIMEOUT) + .run(&["policy", "get", &name, "--full", "--output", "json"]) + .await + .map_err(|error| error.to_string())?; + effective.require_success()?; + let value: Value = effective.json().map_err(|error| error.to_string())?; + let network_rules = &value["policy"]["network_policies"]; + if !network_rules.is_null() + && !network_rules + .as_object() + .is_some_and(serde_json::Map::is_empty) + { + return Err(effective.failure_diagnostic("effective network_policies is empty")); + } + + let probe = runner + .step("denied-tcp-open") + .description("one Bash TCP open to 1.1.1.1:443 is denied by policy") + .with_timeout(COMMAND_TIMEOUT) + .run(&[ + "sandbox", + "exec", + "--name", + &name, + "--no-tty", + "--", + "bash", + "-c", + "printf 'BINARY=%s\\n' \"$(readlink -f /proc/$$/exe)\"; if exec 3<>/dev/tcp/1.1.1.1/443; then echo UNEXPECTED_ALLOWED; exit 1; else echo DENIED; fi", + ]) + .await + .map_err(|error| error.to_string())?; + probe.require_success()?; + let binary = probe + .stdout() + .lines() + .find_map(|line| line.strip_prefix("BINARY=")) + .filter(|binary| binary.starts_with('/') && binary.rsplit('/').next() == Some("bash")) + .ok_or_else(|| probe.failure_diagnostic("canonical Bash executable path is reported"))? + .to_string(); + if !probe.stdout().lines().any(|line| line == "DENIED") { + return Err(probe.failure_diagnostic("TCP open is denied before any upstream dial")); + } + + await_mechanistic_draft( + runner, + &name, + &ExpectedDraft { + rule: "allow_1_1_1_1_443", + endpoint: "1.1.1.1:443", + binary: &binary, + }, + probe.stderr(), + ) + .await + }) +} + +fn run_new_hostname_proposal(runner: &mut OpenShellRunner) -> ScenarioFuture<'_> { + Box::pin(async move { + let mut policy = NamedTempFile::new().map_err(|error| error.to_string())?; + policy + .write_all(EMPTY_NETWORK_POLICY) + .map_err(|error| error.to_string())?; + let policy_path = policy + .path() + .to_str() + .ok_or("temporary policy path is not UTF-8")?; + let name = format!("ct-{}-nh", runner.id()); + create_sandbox(runner, &name, Some(policy_path)).await?; + let binary = sandbox_bash_path(runner, &name).await?; + + let probe = runner + .step("denied-new-hostname") + .description("Bash cannot connect to pypi.org:80 before approval") + .with_timeout(COMMAND_TIMEOUT) + .run(&[ + "sandbox", + "exec", + "--name", + &name, + "--no-tty", + "--", + "bash", + "-c", + "if exec 3<>/dev/tcp/pypi.org/80; then echo UNEXPECTED_ALLOWED; exit 1; else echo DENIED; fi", + ]) + .await + .map_err(|error| error.to_string())?; + probe.require_success()?; + if !probe.stdout().lines().any(|line| line == "DENIED") { + return Err(probe.failure_diagnostic("new hostname stays denied before approval")); + } + + await_mechanistic_draft( + runner, + &name, + &ExpectedDraft { + rule: "allow_pypi_org_80", + endpoint: "pypi.org:80", + binary: &binary, + }, + probe.stderr(), + ) + .await + }) +} + +/// The single L4 mechanistic draft expected for one denied endpoint. +struct ExpectedDraft<'a> { + rule: &'a str, + endpoint: &'a str, + binary: &'a str, +} + +/// Poll the reviewer inbox until a draft appears, tolerating transient read +/// failures, then require that it is the single expected draft. +async fn await_mechanistic_draft( + runner: &OpenShellRunner, + sandbox: &str, + expected: &ExpectedDraft<'_>, + probe_stderr: &str, +) -> Result<(), String> { + let started = Instant::now(); + loop { + let draft = runner + .step("mechanistic-draft") + .description("a single scoped mechanistic draft appears") + .with_timeout(COMMAND_TIMEOUT) + .run(&["rule", "get", sandbox]) + .await + .map_err(|error| error.to_string())?; + if !draft.success() { + if started.elapsed() >= PROPOSAL_TIMEOUT { + return Err(draft.failure_diagnostic("the reviewer inbox is readable")); + } + sleep(POLL_INTERVAL).await; + continue; + } + if !draft.stdout().contains("Chunk:") { + if started.elapsed() >= PROPOSAL_TIMEOUT { + return Err(draft.failure_diagnostic(&format!( + "one mechanistic draft for {} and {}; probe stderr:\n{probe_stderr}", + expected.endpoint, expected.binary + ))); + } + sleep(POLL_INTERVAL).await; + continue; + } + return assert_mechanistic_draft(draft.stdout(), expected) + .map_err(|error| draft.failure_diagnostic(&error)); + } +} + +fn assert_mechanistic_draft(output: &str, expected: &ExpectedDraft<'_>) -> Result<(), String> { + let fields = output.lines().map(str::trim).collect::>(); + let field = |name: &str| { + fields + .iter() + .find_map(|line| line.strip_prefix(name).map(str::trim)) + }; + let endpoints = format!("{} [L4]", expected.endpoint); + if fields + .iter() + .filter(|line| line.starts_with("Chunk:")) + .count() + != 1 + || !matches!(field("Status:"), Some("pending" | "approved")) + || field("Rule:") != Some(expected.rule) + || field("Binary:") != Some(expected.binary) + || field("Binaries:") != Some(expected.binary) + || field("Endpoints:") != Some(endpoints.as_str()) + || !field("Rationale:").is_some_and(|value| value.contains(expected.endpoint)) + { + return Err(format!( + "expected one pending or approved L4 mechanistic draft scoped to {} and {}", + expected.binary, expected.endpoint + )); + } + Ok(()) +} + +async fn create_sandbox( + runner: &mut OpenShellRunner, + name: &str, + policy_path: Option<&str>, +) -> Result<(), String> { + runner.track_sandbox(name); + let mut args = vec![ + "sandbox", + "create", + "--name", + name, + "--detach", + "--no-tty", + "--no-auto-providers", + ]; + if let Some(path) = policy_path { + args.extend(["--policy", path]); + } + args.extend(["--", "sh", "-c", "exec sleep infinity"]); + let create = runner + .step("create") + .description(format!("sandbox '{name}' is created")) + .with_timeout(CREATE_TIMEOUT) + .run(&args) + .await + .map_err(|error| error.to_string())?; + create.require_success()?; + + let started = Instant::now(); + loop { + let get = runner + .step("ready") + .description(format!("sandbox '{name}' reaches Ready")) + .with_timeout(COMMAND_TIMEOUT) + .run(&["sandbox", "get", name, "--output", "json"]) + .await + .map_err(|error| error.to_string())?; + if get.success() { + let state: SandboxState = get.json().map_err(|error| error.to_string())?; + if state.name != name { + return Err(get.failure_diagnostic("sandbox get returns the created name")); + } + if state.phase == "Ready" { + return Ok(()); + } + if state.phase == "Failed" { + return Err(get.failure_diagnostic("sandbox reaches Ready instead of Failed")); + } + } + if started.elapsed() >= READY_TIMEOUT { + return Err(get.failure_diagnostic("sandbox reaches Ready before timeout")); + } + sleep(POLL_INTERVAL).await; + } +} + +#[cfg(test)] +mod tests { + use super::{ExpectedDraft, assert_mechanistic_draft}; + + const EXPECTED: ExpectedDraft<'static> = ExpectedDraft { + rule: "allow_pypi_org_80", + endpoint: "pypi.org:80", + binary: "/usr/bin/bash", + }; + + fn draft(binary: &str) -> String { + format!( + "Chunk: id\nStatus: pending\nRule: allow_pypi_org_80\nBinary: {binary}\nRationale: Allow {binary} to connect to pypi.org:80 (HTTP).\nEndpoints: pypi.org:80 [L4]\nBinaries: {binary}\n" + ) + } + + #[test] + fn draft_assertion_accepts_a_hostname_scoped_draft() { + assert!(assert_mechanistic_draft(&draft("/usr/bin/bash"), &EXPECTED).is_ok()); + } + + #[test] + fn draft_assertion_rejects_unrelated_binary() { + assert!(assert_mechanistic_draft(&draft("/usr/bin/sh"), &EXPECTED).is_err()); + } + + #[test] + fn draft_assertion_rejects_fields_spread_across_drafts() { + let drafts = format!( + "{}Chunk: other\nStatus: pending\nRule: allow_1_1_1_1_443\n", + draft("/usr/bin/bash") + ); + assert!(assert_mechanistic_draft(&drafts, &EXPECTED).is_err()); + } +} diff --git a/crates/openshell-conformance/src/scenarios/sandbox_lifecycle.rs b/crates/openshell-conformance/src/scenarios/sandbox_lifecycle.rs new file mode 100644 index 0000000000..61a26558ff --- /dev/null +++ b/crates/openshell-conformance/src/scenarios/sandbox_lifecycle.rs @@ -0,0 +1,278 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Portable sandbox lifecycle conformance scenarios. + +use std::time::Duration; + +use serde::Deserialize; + +use crate::{OpenShellRunner, Poll, Scenario, ScenarioFuture}; + +const CREATE_TIMEOUT: Duration = Duration::from_mins(10); +const COMMAND_TIMEOUT: Duration = Duration::from_mins(2); +const TRANSITION_TIMEOUT: Duration = Duration::from_mins(4); +const TRANSITION_INTERVAL: Duration = Duration::from_secs(2); + +#[derive(Debug, Deserialize)] +struct SandboxState { + name: String, + phase: String, +} + +/// Certify sandbox stop, start, and deletion lifecycle behavior. +pub const SANDBOX_LIFECYCLE_SCENARIO: Scenario = Scenario { + name: "sandbox-lifecycle", + description: "Verify sandbox stop, start, and deletion lifecycle behavior.", + run: run_sandbox_lifecycle, +}; + +fn run_sandbox_lifecycle(runner: &mut OpenShellRunner) -> ScenarioFuture<'_> { + Box::pin(async move { + stop_start_preserves_workspace(runner).await?; + stopped_can_be_deleted(runner).await + }) +} + +async fn stop_start_preserves_workspace(runner: &mut OpenShellRunner) -> Result<(), String> { + let sandbox_name = format!("ct-{}-ss", runner.id()); + let sentinel = format!("openshell-stop-start-{}", runner.id()); + let sentinel_path = "/sandbox/.openshell-stop-start-sentinel"; + let run_count_path = "/sandbox/.openshell-main-run-count"; + let main = format!( + "count=0; test ! -f '{run_count_path}' || count=$(cat '{run_count_path}'); \ + count=$((count + 1)); printf '%s\\n' \"$count\" > '{run_count_path}'; \ + exec sleep infinity" + ); + + create_running_sandbox(runner, &sandbox_name, &main, "stop-start").await?; + exec_expect_exact( + runner, + &sandbox_name, + "write-sentinel", + &[ + "sh", + "-lc", + &format!("printf '%s\\n' '{sentinel}' > '{sentinel_path}' && sync"), + ], + "", + ) + .await?; + + run_lifecycle_command(runner, "stop", &sandbox_name, "stop-start/stop").await?; + wait_for_phase(runner, &sandbox_name, "Stopped", "stop-start/stopped").await?; + + let stopped_exec = runner + .step("stop-start/exec-while-stopped") + .description(format!( + "sandbox '{sandbox_name}' rejects exec while stopped" + )) + .with_timeout(COMMAND_TIMEOUT) + .run(&[ + "sandbox", + "exec", + "--name", + &sandbox_name, + "--no-tty", + "--", + "cat", + sentinel_path, + ]) + .await + .map_err(|error| error.to_string())?; + if stopped_exec.success() { + return Err( + stopped_exec.failure_diagnostic("sandbox exec fails while the sandbox is stopped") + ); + } + + run_lifecycle_command(runner, "start", &sandbox_name, "stop-start/start").await?; + wait_for_phase(runner, &sandbox_name, "Ready", "stop-start/restarted").await?; + + exec_expect_exact( + runner, + &sandbox_name, + "read-sentinel", + &["cat", sentinel_path], + &format!("{sentinel}\n"), + ) + .await?; + exec_expect_exact( + runner, + &sandbox_name, + "read-main-run-count", + &["cat", run_count_path], + "2\n", + ) + .await +} + +async fn stopped_can_be_deleted(runner: &mut OpenShellRunner) -> Result<(), String> { + let sandbox_name = format!("ct-{}-sd", runner.id()); + create_running_sandbox( + runner, + &sandbox_name, + "exec sleep infinity", + "stopped-delete", + ) + .await?; + + run_lifecycle_command(runner, "stop", &sandbox_name, "stopped-delete/stop").await?; + wait_for_phase(runner, &sandbox_name, "Stopped", "stopped-delete/stopped").await?; + run_lifecycle_command(runner, "delete", &sandbox_name, "stopped-delete/delete").await?; + wait_for_absence(runner, &sandbox_name, "stopped-delete/deleted").await?; + runner.forget_sandbox(&sandbox_name); + Ok(()) +} + +async fn create_running_sandbox( + runner: &mut OpenShellRunner, + sandbox_name: &str, + main: &str, + step: &str, +) -> Result<(), String> { + runner.track_sandbox(sandbox_name); + let create = runner + .step(format!("{step}/create")) + .description(format!("sandbox '{sandbox_name}' is created")) + .with_timeout(CREATE_TIMEOUT) + .run(&[ + "sandbox", + "create", + "--name", + sandbox_name, + "--detach", + "--no-tty", + "--", + "sh", + "-lc", + main, + ]) + .await + .map_err(|error| error.to_string())?; + create.require_success()?; + wait_for_phase(runner, sandbox_name, "Ready", &format!("{step}/ready")).await +} + +async fn run_lifecycle_command( + runner: &OpenShellRunner, + operation: &str, + sandbox_name: &str, + step: &str, +) -> Result<(), String> { + let result = runner + .step(step) + .description(format!("sandbox '{sandbox_name}' {operation} succeeds")) + .with_timeout(COMMAND_TIMEOUT) + .run(&["sandbox", operation, sandbox_name]) + .await + .map_err(|error| error.to_string())?; + result.require_success() +} + +async fn exec_expect_exact( + runner: &OpenShellRunner, + sandbox_name: &str, + step: &str, + command: &[&str], + expected_stdout: &str, +) -> Result<(), String> { + let mut args = vec!["sandbox", "exec", "--name", sandbox_name, "--no-tty", "--"]; + args.extend_from_slice(command); + let result = runner + .step(format!("stop-start/{step}")) + .description(format!("sandbox '{sandbox_name}' exec {step} succeeds")) + .with_timeout(COMMAND_TIMEOUT) + .run(&args) + .await + .map_err(|error| error.to_string())?; + result.require_success()?; + if result.stdout() == expected_stdout { + Ok(()) + } else { + Err(result.failure_diagnostic(&format!("stdout is exactly {expected_stdout:?}"))) + } +} + +async fn wait_for_phase( + runner: &mut OpenShellRunner, + sandbox_name: &str, + expected_phase: &str, + step: &str, +) -> Result<(), String> { + let sandbox_name = sandbox_name.to_string(); + let expected_phase = expected_phase.to_string(); + let step = step.to_string(); + let poll_step = step.clone(); + runner + .poll_until( + &poll_step, + TRANSITION_TIMEOUT, + TRANSITION_INTERVAL, + async move |runner| { + let result = runner + .step(format!("{step}/get")) + .description(format!( + "sandbox '{sandbox_name}' reaches phase {expected_phase}" + )) + .with_timeout(COMMAND_TIMEOUT) + .run(&["sandbox", "get", &sandbox_name, "--output", "json"]) + .await; + match result { + Ok(result) if !result.success() => { + Poll::Pending(result.failure_diagnostic(&format!( + "sandbox '{sandbox_name}' can be retrieved" + ))) + } + Ok(result) => match result.json::() { + Ok(state) if state.name != sandbox_name => Poll::Failed(format!( + "sandbox get returned {:?}; expected '{sandbox_name}'", + state.name + )), + Ok(state) if state.phase == expected_phase => Poll::Ready(()), + Ok(state) => Poll::Pending(format!( + "sandbox '{sandbox_name}' phase is {:?}; expected {expected_phase:?}", + state.phase + )), + Err(error) => Poll::Failed(error.to_string()), + }, + Err(error) => Poll::Pending(error.to_string()), + } + }, + ) + .await + .map_err(|error| error.to_string()) +} + +async fn wait_for_absence( + runner: &mut OpenShellRunner, + sandbox_name: &str, + step: &str, +) -> Result<(), String> { + let sandbox_name = sandbox_name.to_string(); + let step = step.to_string(); + let poll_step = step.clone(); + runner + .poll_until( + &poll_step, + TRANSITION_TIMEOUT, + TRANSITION_INTERVAL, + async move |runner| { + let result = runner + .step(format!("{step}/get")) + .description(format!("sandbox '{sandbox_name}' is no longer retrievable")) + .with_timeout(COMMAND_TIMEOUT) + .run(&["sandbox", "get", &sandbox_name, "--output", "json"]) + .await; + match result { + Ok(result) if !result.success() => Poll::Ready(()), + Ok(_) => { + Poll::Pending(format!("sandbox '{sandbox_name}' is still retrievable")) + } + Err(error) => Poll::Pending(error.to_string()), + } + }, + ) + .await + .map_err(|error| error.to_string()) +} diff --git a/crates/openshell-conformance/src/scenarios/smoke.rs b/crates/openshell-conformance/src/scenarios/smoke.rs index 5f486160d3..de97ed5c64 100644 --- a/crates/openshell-conformance/src/scenarios/smoke.rs +++ b/crates/openshell-conformance/src/scenarios/smoke.rs @@ -133,9 +133,6 @@ async fn create_sandbox( if let Some(command) = smoke_command()? { args.push("--".to_string()); args.extend(command); - } else { - args.push("--from".to_string()); - args.push("base".to_string()); } let args = args.iter().map(String::as_str).collect::>(); let create = runner diff --git a/tasks/scripts/windows-msvc.ps1 b/tasks/scripts/windows-msvc.ps1 index 8c4e9fb1b2..2e3b2c72cc 100644 --- a/tasks/scripts/windows-msvc.ps1 +++ b/tasks/scripts/windows-msvc.ps1 @@ -661,7 +661,7 @@ filesystem_policy: Invoke-VsCargo ` -RustTarget $RustTarget ` - -CargoArgs "cargo nextest run --manifest-path tests/suites/conformance/Cargo.toml --target $RustTarget --test smoke control_plane --no-capture" ` + -CargoArgs "cargo nextest run --profile ci --manifest-path tests/suites/conformance/Cargo.toml --target $RustTarget --no-capture" ` -LogName "test-$RustTarget-conformance.log" } catch { Get-Content -LiteralPath $gatewayLog, $gatewayErrorLog -ErrorAction SilentlyContinue diff --git a/tasks/windows.toml b/tasks/windows.toml index 7d86ad6af6..6979872e98 100644 --- a/tasks/windows.toml +++ b/tasks/windows.toml @@ -56,12 +56,12 @@ run = "echo 'windows:* tasks require a Windows MSVC host' && exit 1" run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 test-precommit aarch64-pc-windows-msvc" ["windows:test:conformance:x64"] -description = "Run portable control-plane conformance against a mock MXC gateway on native Windows x64" +description = "Run the full CLI conformance suite against a mock MXC gateway on native Windows x64" run = "echo 'windows:* tasks require a Windows MSVC host' && exit 1" run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 test-conformance x86_64-pc-windows-msvc" ["windows:test:conformance:arm64"] -description = "Run portable control-plane conformance against a mock MXC gateway on native Windows ARM64" +description = "Run the full CLI conformance suite against a mock MXC gateway on native Windows ARM64" run = "echo 'windows:* tasks require a Windows MSVC host' && exit 1" run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 test-conformance aarch64-pc-windows-msvc" diff --git a/tests/suites/conformance/Cargo.lock b/tests/suites/conformance/Cargo.lock index 4631162435..89d8c0d392 100644 --- a/tests/suites/conformance/Cargo.lock +++ b/tests/suites/conformance/Cargo.lock @@ -16,9 +16,9 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cfg-if" -version = "1.0.5" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "errno" @@ -30,6 +30,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + [[package]] name = "getrandom" version = "0.3.4" @@ -54,6 +60,12 @@ version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "lock_api" version = "0.4.14" @@ -80,6 +92,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + [[package]] name = "openshell-conformance" version = "0.0.0" @@ -87,6 +105,7 @@ dependencies = [ "rand", "serde", "serde_json", + "tempfile", "tokio", ] @@ -198,6 +217,19 @@ dependencies = [ "bitflags", ] +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -231,7 +263,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.5", ] [[package]] @@ -259,9 +291,9 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.16.2" +version = "1.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" [[package]] name = "socket2" @@ -286,15 +318,28 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.6" +version = "3.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" dependencies = [ "proc-macro2", "quote", "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + [[package]] name = "tokio" version = "1.53.1" @@ -320,14 +365,14 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.5", ] [[package]] name = "unicode-ident" -version = "1.0.26" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "wasi" @@ -367,18 +412,18 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] name = "zerocopy" -version = "0.8.59" +version = "0.8.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb" +checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.59" +version = "0.8.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82" +checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" dependencies = [ "proc-macro2", "quote", diff --git a/tests/suites/conformance/README.md b/tests/suites/conformance/README.md index f5b7df4eb0..e05d3083f2 100644 --- a/tests/suites/conformance/README.md +++ b/tests/suites/conformance/README.md @@ -9,22 +9,23 @@ This workspace verifies that an installed `openshell` CLI and a selected gateway implement portable, public behavior. Tests treat the CLI as a black box. They do not inspect driver internals or replace driver-specific qualification. -The suite separates portable contracts by capability. A test environment should -run every test supported by its compute driver instead of treating one broad +The suite separates portable contracts by capability. Conformance CI runs every +test so unsupported behavior remains visible instead of treating one broad smoke test as the portability boundary. In particular: - `smoke::control_plane` covers status, create, get, list, and delete. It does not require `sandbox exec`. -- `smoke::exec` covers `sandbox exec` and runs only where the driver supports - that operation. +- `smoke::exec` covers `sandbox exec`. Drivers without exec support still run + the test and report that capability gap. - `lifecycle` and the policy advisor tests have additional runtime requirements documented in their source modules. The scenario implementations live in the [`openshell-conformance` crate](../../../crates/openshell-conformance/README.md) so the archive tests and the standalone `openshell-conformance` compatibility -runner share assertions and cleanup. New installed-artifact CI should use this -test workspace and select tests with nextest filters. +runner share assertions and cleanup. New installed-artifact CI should run this +complete test workspace. Use nextest filters only to reproduce or diagnose one +scenario locally. Set `OPENSHELL_BIN` to the candidate CLI. The selected gateway must already be reachable. Runtimes that require an explicit sandbox workload can set @@ -39,6 +40,14 @@ cargo nextest run \ -E 'binary(smoke) & test(=control_plane)' ``` +Run the complete suite in CI without stopping after the first capability gap: + +```shell +cargo nextest run \ + --profile ci \ + --manifest-path tests/suites/conformance/Cargo.toml +``` + The runner owns only resources named for its generated run ID and attempts to delete them after success or failure. Tests must not depend on unrelated gateway state or delete resources they did not create. diff --git a/tests/suites/conformance/cli/tests/file_transfer.rs b/tests/suites/conformance/cli/tests/file_transfer.rs new file mode 100644 index 0000000000..167506b87b --- /dev/null +++ b/tests/suites/conformance/cli/tests/file_transfer.rs @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Driver-agnostic sandbox file-transfer conformance tests. + +use openshell_conformance::{ + FILE_TRANSFER_GIT_FILTERING_SCENARIO, FILE_TRANSFER_PATH_SAFETY_SCENARIO, + FILE_TRANSFER_ROUND_TRIP_SCENARIO, OpenShellRunner, Scenario, +}; + +/// Exercise file and directory round trips through the candidate CLI. +#[tokio::test] +async fn round_trip() { + run(FILE_TRANSFER_ROUND_TRIP_SCENARIO).await; +} + +/// Exercise Git-aware upload filtering through the candidate CLI. +#[tokio::test] +async fn git_filtering() { + run(FILE_TRANSFER_GIT_FILTERING_SCENARIO).await; +} + +/// Exercise workspace boundary and filename safety through the candidate CLI. +#[tokio::test] +async fn path_safety() { + run(FILE_TRANSFER_PATH_SAFETY_SCENARIO).await; +} + +async fn run(scenario: Scenario) { + let mut runner = OpenShellRunner::from_env(scenario.name) + .expect("candidate openshell CLI is available"); + let result = async { + runner.check_gateway_status().await?; + scenario.run(&mut runner).await + } + .await; + if let Err(error) = runner.finish(result).await { + panic!("{} conformance scenario failed:\n{error}", scenario.name); + } +} diff --git a/tests/suites/conformance/cli/tests/lifecycle.rs b/tests/suites/conformance/cli/tests/lifecycle.rs new file mode 100644 index 0000000000..1a18adef86 --- /dev/null +++ b/tests/suites/conformance/cli/tests/lifecycle.rs @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Driver-agnostic sandbox lifecycle conformance tests. + +use openshell_conformance::{OpenShellRunner, SANDBOX_LIFECYCLE_SCENARIO}; + +/// Exercise stop, start, and stopped-deletion behavior through the candidate CLI. +#[tokio::test] +async fn sandbox_lifecycle() { + let mut runner = OpenShellRunner::from_env(SANDBOX_LIFECYCLE_SCENARIO.name) + .expect("candidate openshell CLI is available"); + + let result = async { + runner.check_gateway_status().await?; + SANDBOX_LIFECYCLE_SCENARIO.run(&mut runner).await + } + .await; + if let Err(error) = runner.finish(result).await { + panic!("sandbox lifecycle conformance scenario failed:\n{error}"); + } +} diff --git a/tests/suites/conformance/cli/tests/policy_advisor.rs b/tests/suites/conformance/cli/tests/policy_advisor.rs new file mode 100644 index 0000000000..7da2186dd7 --- /dev/null +++ b/tests/suites/conformance/cli/tests/policy_advisor.rs @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Installed-artifact policy advisor conformance. + +use openshell_conformance::{ + MECHANISTIC_PROPOSAL_SCENARIO, NEW_HOSTNAME_PROPOSAL_SCENARIO, OpenShellRunner, + POLICY_LOCAL_SCENARIO, Scenario, +}; + +async fn run(scenario: &'static Scenario) { + let mut runner = + OpenShellRunner::from_env(scenario.name).expect("candidate openshell CLI is available"); + let result = async { + runner.check_gateway_status().await?; + scenario.run(&mut runner).await + } + .await; + if let Err(error) = runner.finish(result).await { + panic!("{} conformance scenario failed:\n{error}", scenario.name); + } +} + +#[tokio::test] +async fn mechanistic_proposal() { + run(&MECHANISTIC_PROPOSAL_SCENARIO).await; +} + +#[tokio::test] +async fn new_hostname_proposal() { + run(&NEW_HOSTNAME_PROPOSAL_SCENARIO).await; +} + +#[tokio::test] +async fn policy_local() { + run(&POLICY_LOCAL_SCENARIO).await; +} From f5c243d85b2bd3b2fa808cbffa9f82022f3fb032 Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Mon, 28 Sep 2026 16:27:28 +0200 Subject: [PATCH 33/42] fix(windows): run conformance without fail-fast Signed-off-by: Evan Lezar --- tasks/scripts/windows-msvc.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tasks/scripts/windows-msvc.ps1 b/tasks/scripts/windows-msvc.ps1 index 2e3b2c72cc..cec1a620b4 100644 --- a/tasks/scripts/windows-msvc.ps1 +++ b/tasks/scripts/windows-msvc.ps1 @@ -661,7 +661,7 @@ filesystem_policy: Invoke-VsCargo ` -RustTarget $RustTarget ` - -CargoArgs "cargo nextest run --profile ci --manifest-path tests/suites/conformance/Cargo.toml --target $RustTarget --no-capture" ` + -CargoArgs "cargo nextest run --no-fail-fast --manifest-path tests/suites/conformance/Cargo.toml --target $RustTarget --no-capture" ` -LogName "test-$RustTarget-conformance.log" } catch { Get-Content -LiteralPath $gatewayLog, $gatewayErrorLog -ErrorAction SilentlyContinue From 16bc1699d2c621c2aca76967e9dc4af50b6ff377 Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Mon, 28 Sep 2026 17:39:59 +0200 Subject: [PATCH 34/42] feat(conformance): run atomic scenario groups Signed-off-by: Evan Lezar --- .github/workflows/windows-msvc.yml | 2 + TESTING.md | 16 ++-- architecture/build.md | 8 +- architecture/windows.md | 5 +- crates/openshell-conformance-cli/src/main.rs | 93 +++++++++++++++---- crates/openshell-conformance/README.md | 27 +++--- crates/openshell-conformance/src/lib.rs | 13 ++- .../src/scenarios/file_transfer.rs | 14 --- .../src/scenarios/mod.rs | 4 +- .../src/scenarios/policy_behavior.rs | 6 +- .../src/scenarios/sandbox_lifecycle.rs | 2 +- .../src/scenarios/smoke.rs | 18 +--- tasks/scripts/windows-msvc.ps1 | 19 +++- tasks/windows.toml | 4 +- tests/suites/conformance/README.md | 34 ++++--- tests/suites/conformance/cli/tests/smoke.rs | 2 +- 16 files changed, 164 insertions(+), 103 deletions(-) diff --git a/.github/workflows/windows-msvc.yml b/.github/workflows/windows-msvc.yml index a218754203..5fa4764132 100644 --- a/.github/workflows/windows-msvc.yml +++ b/.github/workflows/windows-msvc.yml @@ -108,6 +108,7 @@ jobs: path: | build-${{ matrix.rust_target }}-conformance.log test-${{ matrix.rust_target }}-conformance.log + test-${{ matrix.rust_target }}-conformance.err.log test-${{ matrix.rust_target }}-conformance-gateway.log test-${{ matrix.rust_target }}-conformance-gateway.err.log if-no-files-found: warn @@ -193,6 +194,7 @@ jobs: path: | build-${{ matrix.rust_target }}-conformance.log test-${{ matrix.rust_target }}-conformance.log + test-${{ matrix.rust_target }}-conformance.err.log test-${{ matrix.rust_target }}-conformance-gateway.log test-${{ matrix.rust_target }}-conformance-gateway.err.log if-no-files-found: warn diff --git a/TESTING.md b/TESTING.md index d4effe6fce..36cc9271e6 100644 --- a/TESTING.md +++ b/TESTING.md @@ -149,14 +149,14 @@ Suites: - Common suite (`--features e2e`) - driver-neutral CLI behavior, sandbox lifecycle, sync, port forwarding, policy, and provider tests. - CLI conformance (`tests/suites/conformance`) - installed-artifact tests of - portable public CLI behavior. Smoke coverage is split into independent - control-plane and exec tests so failures identify the unsupported operation - without hiding later coverage. Conformance CI runs every test; focused - filters are for local diagnosis. Scenario implementations live in - `openshell-conformance`; the - standalone `openshell-conformance` binary remains a compatibility runner for - driver E2E wrappers. See the [suite README](tests/suites/conformance/README.md) - for scope and selection guidance. + portable public CLI behavior. Logical names use `/`, such as + `smoke/control-plane` and `smoke/exec`, so failures identify the unsupported + operation without hiding later coverage. Scenario implementations live in + `openshell-conformance`; Cargo tests and the standalone + `openshell-conformance` binary execute the same atomic scenarios. Conformance + CI runs every leaf; focused leaf or group selection is for local diagnosis. + See the [suite README](tests/suites/conformance/README.md) for scope and + selection guidance. - Driver suites (`--features e2e-docker`, `e2e-podman`, `e2e-kubernetes`, or `e2e-vm`) - CLI conformance plus the common and driver-specific coverage for the selected deployment. diff --git a/architecture/build.md b/architecture/build.md index 59a0018495..0f75e0152f 100644 --- a/architecture/build.md +++ b/architecture/build.md @@ -297,9 +297,11 @@ for explicit publication. CLI conformance runs after target provisioning and operates only through the configured OpenShell CLI. The archive suite defines the installed-artifact test boundary, while `openshell-conformance` provides reusable scenarios and a -standalone compatibility runner. Scenarios are independently selectable for -local diagnosis, while conformance CI runs the complete suite so unsupported -runtime behavior remains visible rather than being silently skipped. +standalone runner. Its registry contains atomic `/`-separated leaves; group +selectors expand to their leaves, and failures are accumulated until every +selected leaf has run. Cargo tests wrap the same leaves. Conformance CI runs the +complete registry so unsupported runtime behavior remains visible rather than +being silently skipped. ## Python Wheel Packaging diff --git a/architecture/windows.md b/architecture/windows.md index 23e6ec5cee..9a36dacc1e 100644 --- a/architecture/windows.md +++ b/architecture/windows.md @@ -247,8 +247,9 @@ Windows validation separates source correctness from host capability: - The architecture-specific `windows:*` tasks check, lint, build, and run workspace and unsupported-driver contract tests for x64 and ARM64. - Hosted Windows CI runs the complete archive conformance suite against the - in-process MXC mock. Every scenario runs so unsupported operations appear as - explicit failures in the archived nextest report instead of being skipped. + in-process MXC mock through the standalone `openshell-conformance` runner. + Every atomic scenario runs so unsupported operations appear as explicit + failures in the archived report instead of being skipped. The mock verifies public CLI, gateway, driver, lifecycle, and policy wiring; it does not claim Windows OS enforcement. - Mock MXC E2E validates gateway, CLI, driver, lifecycle, and policy wiring but diff --git a/crates/openshell-conformance-cli/src/main.rs b/crates/openshell-conformance-cli/src/main.rs index c9c0beb5e0..fbb0d88a9c 100644 --- a/crates/openshell-conformance-cli/src/main.rs +++ b/crates/openshell-conformance-cli/src/main.rs @@ -3,6 +3,7 @@ //! Standalone runner for `OpenShell` CLI conformance scenarios. +use std::collections::BTreeSet; use std::path::PathBuf; use std::process::ExitCode; @@ -28,9 +29,9 @@ enum Command { #[arg(long, value_enum, default_value_t = OutputFormat::Text)] output: OutputFormat, }, - /// Run all registered scenarios, or named scenarios. + /// Run all registered scenarios, or named scenarios and groups. Run { - /// Scenario names. Omit to run every registered scenario. + /// Scenario or group names. Omit to run every registered scenario. scenarios: Vec, /// Explicit path to the `OpenShell` CLI. Defaults to `openshell` on PATH. #[arg(long)] @@ -119,6 +120,8 @@ async fn run( let selected = select_scenarios(requested)?; let mut results = Vec::with_capacity(selected.len()); for candidate in selected { + // Scenario failures are captured in the report so every selected leaf + // runs before the command returns its aggregate exit status. results.push(run_scenario(candidate, binary.as_ref()).await); } @@ -195,14 +198,35 @@ fn select_scenarios(requested: &[String]) -> Result, Stri if requested.is_empty() { return Ok(scenarios().iter().collect()); } - requested - .iter() - .map(|name| { - scenario(name).ok_or_else(|| { - format!("unknown scenario '{name}'; run `openshell-conformance list`") - }) - }) - .collect() + + let mut selected = Vec::new(); + let mut selected_names = BTreeSet::new(); + for selector in requested { + if let Some(candidate) = scenario(selector) { + if selected_names.insert(candidate.name) { + selected.push(candidate); + } + continue; + } + + let prefix = format!("{selector}/"); + let mut matched = false; + for candidate in scenarios() + .iter() + .filter(|candidate| candidate.name.starts_with(&prefix)) + { + matched = true; + if selected_names.insert(candidate.name) { + selected.push(candidate); + } + } + if !matched { + return Err(format!( + "unknown scenario or group '{selector}'; run `openshell-conformance list`" + )); + } + } + Ok(selected) } #[cfg(test)] @@ -213,21 +237,56 @@ mod tests { #[test] fn selects_all_scenarios_by_default() { - assert_eq!( - select_scenarios(&[]).expect("select all").len(), - scenarios().len() - ); + let selected = select_scenarios(&[]).expect("select all"); + assert_eq!(selected.len(), 9); + assert_eq!(selected.len(), scenarios().len()); } #[test] fn selects_named_scenario() { - let selected = select_scenarios(&["smoke".to_string()]).expect("select smoke"); - assert_eq!(selected[0].name, "smoke"); + let selected = select_scenarios(&["smoke/exec".to_string()]).expect("select smoke exec"); + assert_eq!(selected.len(), 1); + assert_eq!(selected[0].name, "smoke/exec"); + } + + #[test] + fn expands_group_to_all_leaf_scenarios() { + let selected = select_scenarios(&["smoke".to_string()]).expect("select smoke group"); + let names = selected + .iter() + .map(|candidate| candidate.name) + .collect::>(); + assert_eq!(names, ["smoke/control-plane", "smoke/exec"]); + } + + #[test] + fn overlapping_selectors_do_not_run_a_leaf_twice() { + let selected = select_scenarios(&[ + "smoke".to_string(), + "smoke/exec".to_string(), + "policy".to_string(), + ]) + .expect("select overlapping scenarios"); + let names = selected + .iter() + .map(|candidate| candidate.name) + .collect::>(); + assert_eq!( + names, + [ + "smoke/control-plane", + "smoke/exec", + "policy/mechanistic-proposal", + "policy/new-hostname-proposal", + "policy/local", + ] + ); } #[test] fn unknown_scenario_has_actionable_diagnostic() { let error = select_scenarios(&["missing".to_string()]).expect_err("unknown scenario"); + assert!(error.contains("unknown scenario or group 'missing'")); assert!(error.contains("openshell-conformance list")); } @@ -236,7 +295,7 @@ mod tests { let cli = Cli::try_parse_from([ "openshell-conformance", "run", - "smoke", + "smoke/exec", "--openshell-bin", "/opt/openshell", "--output", diff --git a/crates/openshell-conformance/README.md b/crates/openshell-conformance/README.md index 63bf0573bd..0ac33cd7c9 100644 --- a/crates/openshell-conformance/README.md +++ b/crates/openshell-conformance/README.md @@ -10,28 +10,29 @@ This crate defines reusable black-box conformance scenarios for the public resource names, and best-effort cleanup. It does not provision a gateway or inspect compute-driver internals. -The crate is a scenario library, not the primary CI entrypoint: +The crate is a scenario library shared by two execution frontends: | Component | Responsibility | |---|---| -| [`tests/suites/conformance/cli`](../../tests/suites/conformance/README.md) | Wrap exported scenarios as Cargo tests. Conformance CI should run the complete workspace; focused selection is for local diagnosis. | +| [`tests/suites/conformance/cli`](../../tests/suites/conformance/README.md) | Wrap exported leaf scenarios as Cargo tests. | | `openshell-conformance` | Define portable scenarios and the shared `OpenShellRunner`. | -| [`openshell-conformance-cli`](../openshell-conformance-cli) | Run registered scenarios manually or for compatibility with existing E2E tooling. | +| [`openshell-conformance-cli`](../openshell-conformance-cli) | Run every registered leaf or select one leaf or group. | -Cargo tests can invoke any exported `Scenario`. The standalone CLI invokes the -scenarios returned by `scenarios()`. A scenario does not need to be registered -with the CLI when it exists only as an independently selectable test capability. -For example, the Cargo suite invokes `SMOKE_CONTROL_PLANE_SCENARIO` and -`SMOKE_EXEC_SCENARIO` separately, while the compatibility CLI retains the -registered aggregate `SMOKE_SCENARIO` under the stable name `smoke`. +The registry returned by `scenarios()` contains only independently executable +leaf scenarios. Names use `/` to express groups, such as +`smoke/control-plane`, `smoke/exec`, and `file-transfer/path-safety`. The +standalone CLI expands a group such as `smoke` to every leaf below that prefix, +runs every selected leaf even after failures, and returns a failing status only +after reporting all results. Cargo tests invoke the same exported leaves. Scenarios must exercise public CLI behavior, own only resources created for their run ID, and remain independent of unrelated gateway state. Split coverage -when a behavior requires an optional runtime capability so environments can run -the largest supported subset without weakening assertions. Driver internals, +when a behavior requires an optional runtime capability so failures identify the +specific unsupported operation without hiding later coverage. Driver internals, platform enforcement, and hardware qualification belong in driver-specific tests rather than this crate. When adding a scenario, export it from the library and add the appropriate Cargo -test wrapper under `tests/suites/conformance/cli`. Add it to the standalone CLI -registry only when existing E2E or manual workflows also need that entrypoint. +test wrapper under `tests/suites/conformance/cli`, then add the leaf to the +standalone CLI registry. Conformance CI must run all registered leaves; focused +leaf or group selection is for local diagnosis. diff --git a/crates/openshell-conformance/src/lib.rs b/crates/openshell-conformance/src/lib.rs index 739972960d..5ef3c24c94 100644 --- a/crates/openshell-conformance/src/lib.rs +++ b/crates/openshell-conformance/src/lib.rs @@ -25,9 +25,9 @@ use self::executor::{CliExecutionError, CliExecutor, ProcessCli}; pub use scenarios::{ FILE_TRANSFER_GIT_FILTERING_SCENARIO, FILE_TRANSFER_PATH_SAFETY_SCENARIO, - FILE_TRANSFER_ROUND_TRIP_SCENARIO, FILE_TRANSFER_SCENARIO, MECHANISTIC_PROPOSAL_SCENARIO, + FILE_TRANSFER_ROUND_TRIP_SCENARIO, MECHANISTIC_PROPOSAL_SCENARIO, NEW_HOSTNAME_PROPOSAL_SCENARIO, POLICY_LOCAL_SCENARIO, SANDBOX_LIFECYCLE_SCENARIO, - SMOKE_CONTROL_PLANE_SCENARIO, SMOKE_EXEC_SCENARIO, SMOKE_SCENARIO, + SMOKE_CONTROL_PLANE_SCENARIO, SMOKE_EXEC_SCENARIO, }; /// An installed conformance scenario. @@ -47,9 +47,12 @@ impl Scenario { } const SCENARIOS: &[Scenario] = &[ - SMOKE_SCENARIO, + SMOKE_CONTROL_PLANE_SCENARIO, + SMOKE_EXEC_SCENARIO, SANDBOX_LIFECYCLE_SCENARIO, - FILE_TRANSFER_SCENARIO, + FILE_TRANSFER_ROUND_TRIP_SCENARIO, + FILE_TRANSFER_GIT_FILTERING_SCENARIO, + FILE_TRANSFER_PATH_SAFETY_SCENARIO, MECHANISTIC_PROPOSAL_SCENARIO, NEW_HOSTNAME_PROPOSAL_SCENARIO, POLICY_LOCAL_SCENARIO, @@ -60,7 +63,7 @@ pub fn scenarios() -> &'static [Scenario] { SCENARIOS } -/// Finds a scenario by its stable command-line name. +/// Finds a leaf scenario by its registered command-line name. pub fn scenario(name: &str) -> Option<&'static Scenario> { scenarios().iter().find(|candidate| candidate.name == name) } diff --git a/crates/openshell-conformance/src/scenarios/file_transfer.rs b/crates/openshell-conformance/src/scenarios/file_transfer.rs index b1cd38dc6f..b5a3444f38 100644 --- a/crates/openshell-conformance/src/scenarios/file_transfer.rs +++ b/crates/openshell-conformance/src/scenarios/file_transfer.rs @@ -18,12 +18,6 @@ const TRANSFER_TIMEOUT: Duration = Duration::from_mins(5); const LARGE_FILE_SIZE: usize = 512 * 1024; /// Certify portable upload and download behavior through the public CLI. -pub const FILE_TRANSFER_SCENARIO: Scenario = Scenario { - name: "file-transfer", - description: "Verify sandbox uploads, downloads, Git filtering, and path safety.", - run: run_file_transfer, -}; - /// Certify basic file and directory upload and download behavior. pub const FILE_TRANSFER_ROUND_TRIP_SCENARIO: Scenario = Scenario { name: "file-transfer/round-trip", @@ -45,14 +39,6 @@ pub const FILE_TRANSFER_PATH_SAFETY_SCENARIO: Scenario = Scenario { run: run_path_safety, }; -fn run_file_transfer(runner: &mut OpenShellRunner) -> ScenarioFuture<'_> { - Box::pin(async move { - FILE_TRANSFER_ROUND_TRIP_SCENARIO.run(runner).await?; - FILE_TRANSFER_GIT_FILTERING_SCENARIO.run(runner).await?; - FILE_TRANSFER_PATH_SAFETY_SCENARIO.run(runner).await - }) -} - fn run_round_trip(runner: &mut OpenShellRunner) -> ScenarioFuture<'_> { Box::pin(async move { let (sandbox_name, remote_root, local) = prepare_sandbox(runner, "round-trip").await?; diff --git a/crates/openshell-conformance/src/scenarios/mod.rs b/crates/openshell-conformance/src/scenarios/mod.rs index 467d0962a3..c70c6ee5d0 100644 --- a/crates/openshell-conformance/src/scenarios/mod.rs +++ b/crates/openshell-conformance/src/scenarios/mod.rs @@ -10,10 +10,10 @@ mod smoke; pub use file_transfer::{ FILE_TRANSFER_GIT_FILTERING_SCENARIO, FILE_TRANSFER_PATH_SAFETY_SCENARIO, - FILE_TRANSFER_ROUND_TRIP_SCENARIO, FILE_TRANSFER_SCENARIO, + FILE_TRANSFER_ROUND_TRIP_SCENARIO, }; pub use policy_behavior::{ MECHANISTIC_PROPOSAL_SCENARIO, NEW_HOSTNAME_PROPOSAL_SCENARIO, POLICY_LOCAL_SCENARIO, }; pub use sandbox_lifecycle::SANDBOX_LIFECYCLE_SCENARIO; -pub use smoke::{SMOKE_CONTROL_PLANE_SCENARIO, SMOKE_EXEC_SCENARIO, SMOKE_SCENARIO}; +pub use smoke::{SMOKE_CONTROL_PLANE_SCENARIO, SMOKE_EXEC_SCENARIO}; diff --git a/crates/openshell-conformance/src/scenarios/policy_behavior.rs b/crates/openshell-conformance/src/scenarios/policy_behavior.rs index e26b89e45c..772cb44ac0 100644 --- a/crates/openshell-conformance/src/scenarios/policy_behavior.rs +++ b/crates/openshell-conformance/src/scenarios/policy_behavior.rs @@ -26,19 +26,19 @@ struct SandboxState { } pub const POLICY_LOCAL_SCENARIO: Scenario = Scenario { - name: "policy-local", + name: "policy/local", description: "Read and request a rule through the sandbox-local policy HTTP API.", run: run_policy_local, }; pub const MECHANISTIC_PROPOSAL_SCENARIO: Scenario = Scenario { - name: "mechanistic-proposal", + name: "policy/mechanistic-proposal", description: "Turn a denied transparent TCP open into a scoped policy draft.", run: run_mechanistic_proposal, }; pub const NEW_HOSTNAME_PROPOSAL_SCENARIO: Scenario = Scenario { - name: "new-hostname-proposal", + name: "policy/new-hostname-proposal", description: "Turn a denied TCP open to a hostname absent from policy into a scoped draft.", run: run_new_hostname_proposal, }; diff --git a/crates/openshell-conformance/src/scenarios/sandbox_lifecycle.rs b/crates/openshell-conformance/src/scenarios/sandbox_lifecycle.rs index 61a26558ff..e3b9a2fae8 100644 --- a/crates/openshell-conformance/src/scenarios/sandbox_lifecycle.rs +++ b/crates/openshell-conformance/src/scenarios/sandbox_lifecycle.rs @@ -22,7 +22,7 @@ struct SandboxState { /// Certify sandbox stop, start, and deletion lifecycle behavior. pub const SANDBOX_LIFECYCLE_SCENARIO: Scenario = Scenario { - name: "sandbox-lifecycle", + name: "sandbox/lifecycle", description: "Verify sandbox stop, start, and deletion lifecycle behavior.", run: run_sandbox_lifecycle, }; diff --git a/crates/openshell-conformance/src/scenarios/smoke.rs b/crates/openshell-conformance/src/scenarios/smoke.rs index de97ed5c64..4ec7cb3f3d 100644 --- a/crates/openshell-conformance/src/scenarios/smoke.rs +++ b/crates/openshell-conformance/src/scenarios/smoke.rs @@ -29,34 +29,20 @@ struct SandboxListPage { next_page_token: String, } -/// Certify status -> create -> list Ready -> exec -> delete -> list empty. -pub const SMOKE_SCENARIO: Scenario = Scenario { - name: "smoke", - description: "Run the control-plane and exec smoke scenarios.", - run: run_smoke, -}; - /// Certify status -> create -> get/list Ready -> delete -> list empty. pub const SMOKE_CONTROL_PLANE_SCENARIO: Scenario = Scenario { - name: "smoke-control-plane", + name: "smoke/control-plane", description: "Create, inspect, and delete a sandbox without using sandbox exec.", run: run_smoke_control_plane, }; /// Certify create -> exec -> delete for drivers that support interactive exec. pub const SMOKE_EXEC_SCENARIO: Scenario = Scenario { - name: "smoke-exec", + name: "smoke/exec", description: "Create a sandbox, execute a command in it, and delete it.", run: run_smoke_exec, }; -fn run_smoke(runner: &mut OpenShellRunner) -> ScenarioFuture<'_> { - Box::pin(async move { - run_smoke_control_plane_inner(runner).await?; - run_smoke_exec_inner(runner).await - }) -} - fn run_smoke_control_plane(runner: &mut OpenShellRunner) -> ScenarioFuture<'_> { Box::pin(async move { run_smoke_control_plane_inner(runner).await }) } diff --git a/tasks/scripts/windows-msvc.ps1 b/tasks/scripts/windows-msvc.ps1 index cec1a620b4..0ed8452e9f 100644 --- a/tasks/scripts/windows-msvc.ps1 +++ b/tasks/scripts/windows-msvc.ps1 @@ -564,12 +564,13 @@ function Invoke-ConformanceTest([string] $RustTarget) { Invoke-VsCargo ` -RustTarget $RustTarget ` - -CargoArgs "cargo build --target $RustTarget --bin openshell --bin openshell-gateway $Z3GatewayFeatures" ` + -CargoArgs "cargo build --target $RustTarget --bin openshell --bin openshell-gateway --bin openshell-conformance $Z3GatewayFeatures" ` -LogName "build-$RustTarget-conformance.log" $binaryDir = Join-Path $TargetDir "$RustTarget\debug" $gateway = Join-Path $binaryDir "openshell-gateway.exe" $cli = Join-Path $binaryDir "openshell.exe" + $conformance = Join-Path $binaryDir "openshell-conformance.exe" $buildRoot = Join-Path $binaryDir "build" $z3Candidates = @( @@ -594,6 +595,8 @@ function Invoke-ConformanceTest([string] $RustTarget) { $sandboxPolicy = Join-Path $testRoot "policy.yaml" $gatewayLog = Join-Path $LogDir "test-$RustTarget-conformance-gateway.log" $gatewayErrorLog = Join-Path $LogDir "test-$RustTarget-conformance-gateway.err.log" + $conformanceLog = Join-Path $LogDir "test-$RustTarget-conformance.log" + $conformanceErrorLog = Join-Path $LogDir "test-$RustTarget-conformance.err.log" New-Item -ItemType Directory -Force -Path $configRoot, $stateRoot, $workloadRoot | Out-Null @" [openshell] @@ -659,10 +662,16 @@ filesystem_policy: & $cli gateway select windows-conformance if ($LASTEXITCODE -ne 0) { throw "Failed to select the conformance gateway." } - Invoke-VsCargo ` - -RustTarget $RustTarget ` - -CargoArgs "cargo nextest run --no-fail-fast --manifest-path tests/suites/conformance/Cargo.toml --target $RustTarget --no-capture" ` - -LogName "test-$RustTarget-conformance.log" + Write-Host "==> $conformance run --openshell-bin $cli" + Write-Host " log: $conformanceLog" + $conformanceProcess = Start-Process -FilePath $conformance ` + -ArgumentList @("run", "--openshell-bin", "`"$cli`"") ` + -PassThru -Wait -NoNewWindow ` + -RedirectStandardOutput $conformanceLog -RedirectStandardError $conformanceErrorLog + Get-Content -LiteralPath $conformanceLog, $conformanceErrorLog -ErrorAction SilentlyContinue + if ($conformanceProcess.ExitCode -ne 0) { + throw "Conformance command failed with exit code $($conformanceProcess.ExitCode). See $conformanceLog" + } } catch { Get-Content -LiteralPath $gatewayLog, $gatewayErrorLog -ErrorAction SilentlyContinue throw diff --git a/tasks/windows.toml b/tasks/windows.toml index 6979872e98..3cff6ed676 100644 --- a/tasks/windows.toml +++ b/tasks/windows.toml @@ -56,12 +56,12 @@ run = "echo 'windows:* tasks require a Windows MSVC host' && exit 1" run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 test-precommit aarch64-pc-windows-msvc" ["windows:test:conformance:x64"] -description = "Run the full CLI conformance suite against a mock MXC gateway on native Windows x64" +description = "Run all openshell-conformance scenarios against a mock MXC gateway on native Windows x64" run = "echo 'windows:* tasks require a Windows MSVC host' && exit 1" run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 test-conformance x86_64-pc-windows-msvc" ["windows:test:conformance:arm64"] -description = "Run the full CLI conformance suite against a mock MXC gateway on native Windows ARM64" +description = "Run all openshell-conformance scenarios against a mock MXC gateway on native Windows ARM64" run = "echo 'windows:* tasks require a Windows MSVC host' && exit 1" run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 test-conformance aarch64-pc-windows-msvc" diff --git a/tests/suites/conformance/README.md b/tests/suites/conformance/README.md index e05d3083f2..88567c1c30 100644 --- a/tests/suites/conformance/README.md +++ b/tests/suites/conformance/README.md @@ -9,23 +9,24 @@ This workspace verifies that an installed `openshell` CLI and a selected gateway implement portable, public behavior. Tests treat the CLI as a black box. They do not inspect driver internals or replace driver-specific qualification. -The suite separates portable contracts by capability. Conformance CI runs every -test so unsupported behavior remains visible instead of treating one broad -smoke test as the portability boundary. In particular: +The suite separates portable contracts into independently executable leaves. +Logical scenario names use `/` to express groups. Conformance CI runs every leaf +so unsupported behavior remains visible instead of treating one broad scenario +as the portability boundary. In particular: -- `smoke::control_plane` covers status, create, get, list, and delete. It does +- `smoke/control-plane` covers status, create, get, list, and delete. It does not require `sandbox exec`. -- `smoke::exec` covers `sandbox exec`. Drivers without exec support still run +- `smoke/exec` covers `sandbox exec`. Drivers without exec support still run the test and report that capability gap. -- `lifecycle` and the policy advisor tests have additional runtime requirements - documented in their source modules. +- `sandbox/lifecycle`, the `file-transfer/*` leaves, and the `policy/*` leaves + have additional runtime requirements documented in their source modules. The scenario implementations live in the [`openshell-conformance` crate](../../../crates/openshell-conformance/README.md) -so the archive tests and the standalone `openshell-conformance` compatibility -runner share assertions and cleanup. New installed-artifact CI should run this -complete test workspace. Use nextest filters only to reproduce or diagnose one -scenario locally. +so the Cargo tests and the standalone `openshell-conformance` runner share +assertions and cleanup. CI may use either frontend, but must run all leaves and +continue after individual failures. Use focused selection only to reproduce or +diagnose one scenario locally. Set `OPENSHELL_BIN` to the candidate CLI. The selected gateway must already be reachable. Runtimes that require an explicit sandbox workload can set @@ -48,6 +49,17 @@ cargo nextest run \ --manifest-path tests/suites/conformance/Cargo.toml ``` +The standalone runner provides equivalent leaf and group selection: + +```shell +openshell-conformance run +openshell-conformance run smoke +openshell-conformance run smoke/exec +``` + +It executes selected leaves serially, reports every result, and exits non-zero +after the run if any leaf failed. + The runner owns only resources named for its generated run ID and attempts to delete them after success or failure. Tests must not depend on unrelated gateway state or delete resources they did not create. diff --git a/tests/suites/conformance/cli/tests/smoke.rs b/tests/suites/conformance/cli/tests/smoke.rs index daa26f7eef..dd9e0c8da9 100644 --- a/tests/suites/conformance/cli/tests/smoke.rs +++ b/tests/suites/conformance/cli/tests/smoke.rs @@ -16,7 +16,7 @@ async fn control_plane() { run_scenario(&SMOKE_CONTROL_PLANE_SCENARIO).await; } -/// Exercise sandbox exec separately so runtimes can select it by capability. +/// Exercise sandbox exec separately so failures identify this capability gap. #[tokio::test] async fn exec() { run_scenario(&SMOKE_EXEC_SCENARIO).await; From 11568322664d69551bcfc8f4ef8d70f1624316f8 Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Mon, 28 Sep 2026 18:43:03 +0200 Subject: [PATCH 35/42] test(conformance): compile smoke workloads per platform Signed-off-by: Evan Lezar --- TESTING.md | 10 ++- crates/openshell-conformance/README.md | 5 ++ crates/openshell-conformance/src/lib.rs | 1 + .../openshell-conformance/src/platform/mod.rs | 49 ++++++++++++ .../src/platform/unix.rs | 24 ++++++ .../src/platform/windows.rs | 76 +++++++++++++++++++ .../src/scenarios/smoke.rs | 56 +++----------- tasks/scripts/windows-msvc.ps1 | 23 +----- tests/suites/conformance/README.md | 10 +-- 9 files changed, 177 insertions(+), 77 deletions(-) create mode 100644 crates/openshell-conformance/src/platform/mod.rs create mode 100644 crates/openshell-conformance/src/platform/unix.rs create mode 100644 crates/openshell-conformance/src/platform/windows.rs diff --git a/TESTING.md b/TESTING.md index 36cc9271e6..f17dd02e55 100644 --- a/TESTING.md +++ b/TESTING.md @@ -194,13 +194,15 @@ starts. The task does not provision a gateway or select a compute driver. Set the current checkout. The smoke contract verifies the CLI-to-gateway-to-driver path without depending -on how the gateway was installed. Its control-plane test requires +on how the gateway was installed. Its target-specific fixture is selected at +compile time: Unix targets use the runtime's default workload, while Windows +targets create a temporary writable policy and start a native long-running +command. Its control-plane test requires machine-readable status, creates a uniquely named detached sandbox, verifies it is `Ready` through get and paginated list output, deletes it, and verifies its name no longer appears. The exec test creates its own sandbox and checks -`sandbox exec` with a run-specific marker. Runtimes that require an explicit workload set -`OPENSHELL_CONFORMANCE_SMOKE_COMMAND` to a JSON string array; additional create -options can be supplied through `OPENSHELL_CONFORMANCE_SMOKE_CREATE_ARGS`. +`sandbox exec` with a run-specific marker. Additional create options can be +supplied through `OPENSHELL_CONFORMANCE_SMOKE_CREATE_ARGS`. Sandbox lifecycle, label matrices, VM overlay, and TLS-key permission assertions remain regular E2E coverage. diff --git a/crates/openshell-conformance/README.md b/crates/openshell-conformance/README.md index 0ac33cd7c9..b77e6d1e53 100644 --- a/crates/openshell-conformance/README.md +++ b/crates/openshell-conformance/README.md @@ -32,6 +32,11 @@ specific unsupported operation without hiding later coverage. Driver internals, platform enforcement, and hardware qualification belong in driver-specific tests rather than this crate. +Target-specific fixture construction lives under `src/platform`. The smoke +scenarios select the Unix or Windows implementation at compile time while the +scenario assertions remain shared. Platform implementations may construct +temporary policies and commands, but must exercise the same public behavior. + When adding a scenario, export it from the library and add the appropriate Cargo test wrapper under `tests/suites/conformance/cli`, then add the leaf to the standalone CLI registry. Conformance CI must run all registered leaves; focused diff --git a/crates/openshell-conformance/src/lib.rs b/crates/openshell-conformance/src/lib.rs index 5ef3c24c94..81b2706b01 100644 --- a/crates/openshell-conformance/src/lib.rs +++ b/crates/openshell-conformance/src/lib.rs @@ -4,6 +4,7 @@ //! Reusable support for portable `OpenShell` CLI conformance scenarios. pub mod executor; +mod platform; mod scenarios; use std::collections::BTreeSet; diff --git a/crates/openshell-conformance/src/platform/mod.rs b/crates/openshell-conformance/src/platform/mod.rs new file mode 100644 index 0000000000..377aba0bbb --- /dev/null +++ b/crates/openshell-conformance/src/platform/mod.rs @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Target-specific fixtures used by portable conformance scenarios. + +use tempfile::TempDir; + +#[cfg(unix)] +mod unix; +#[cfg(any(test, target_os = "windows"))] +mod windows; + +#[cfg(unix)] +pub use unix::smoke_fixture; +#[cfg(target_os = "windows")] +pub use windows::smoke_fixture; + +#[cfg(not(any(unix, target_os = "windows")))] +compile_error!("OpenShell conformance does not support this target platform"); + +/// Platform-specific inputs needed to keep a smoke-test sandbox alive. +pub struct SmokeFixture { + create_args: Vec, + command: Option>, + // Keep generated policy and workload paths alive until the scenario ends. + _temp_dir: Option, +} + +impl SmokeFixture { + fn new( + create_args: Vec, + command: Option>, + temp_dir: Option, + ) -> Self { + Self { + create_args, + command, + _temp_dir: temp_dir, + } + } + + pub fn create_args(&self) -> &[String] { + &self.create_args + } + + pub fn command(&self) -> Option<&[String]> { + self.command.as_deref() + } +} diff --git a/crates/openshell-conformance/src/platform/unix.rs b/crates/openshell-conformance/src/platform/unix.rs new file mode 100644 index 0000000000..0c31ad9eb6 --- /dev/null +++ b/crates/openshell-conformance/src/platform/unix.rs @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use super::SmokeFixture; + +// Match the fallible Windows fixture constructor behind the platform boundary. +#[allow(clippy::unnecessary_wraps)] +pub fn smoke_fixture() -> Result { + // Unix runtimes resolve their image-appropriate default shell when the + // canonical command is empty, so the smoke scenario needs no override. + Ok(SmokeFixture::new(Vec::new(), None, None)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn smoke_fixture_uses_the_runtime_default_workload() { + let fixture = smoke_fixture().unwrap(); + assert!(fixture.create_args().is_empty()); + assert!(fixture.command().is_none()); + } +} diff --git a/crates/openshell-conformance/src/platform/windows.rs b/crates/openshell-conformance/src/platform/windows.rs new file mode 100644 index 0000000000..4a52cdd81f --- /dev/null +++ b/crates/openshell-conformance/src/platform/windows.rs @@ -0,0 +1,76 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::fs; + +use super::SmokeFixture; + +pub fn smoke_fixture() -> Result { + let temp_dir = tempfile::Builder::new() + .prefix("openshell-conformance-smoke-") + .tempdir() + .map_err(|error| format!("create Windows smoke fixture directory: {error}"))?; + let workload_dir = temp_dir.path().join("workload"); + fs::create_dir(&workload_dir) + .map_err(|error| format!("create Windows smoke workload directory: {error}"))?; + + let workload_policy_path = workload_dir + .to_str() + .ok_or_else(|| "Windows smoke workload path is not valid Unicode".to_string())? + .replace('\\', "/") + .replace('"', "\\\""); + let policy_path = temp_dir.path().join("policy.yaml"); + let policy = format!( + "version: 1\nfilesystem_policy:\n include_workdir: false\n read_only: []\n read_write:\n - \"{workload_policy_path}\"\n" + ); + fs::write(&policy_path, policy) + .map_err(|error| format!("write Windows smoke policy: {error}"))?; + + let ready_path = workload_dir.join("ready.txt"); + let command = vec![ + r"C:\Windows\System32\cmd.exe".to_string(), + "/c".to_string(), + format!( + "echo ready > \"{}\" & ping -t 127.0.0.1 > NUL", + ready_path.display() + ), + ]; + let create_args = vec![ + "--policy".to_string(), + policy_path.to_string_lossy().into_owned(), + ]; + + Ok(SmokeFixture::new( + create_args, + Some(command), + Some(temp_dir), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn smoke_fixture_defines_a_long_running_windows_workload() { + let fixture = smoke_fixture().unwrap(); + let command = fixture.command().unwrap(); + assert_eq!(command[0], r"C:\Windows\System32\cmd.exe"); + assert!(command[2].contains("ready.txt")); + assert!(command[2].contains("ping -t 127.0.0.1")); + + let policy_path = &fixture.create_args()[1]; + let policy = fs::read_to_string(policy_path).unwrap(); + assert!(policy.contains("read_write:")); + assert!( + command[2].contains( + std::path::Path::new(policy_path) + .parent() + .unwrap() + .join("workload") + .to_string_lossy() + .as_ref() + ) + ); + } +} diff --git a/crates/openshell-conformance/src/scenarios/smoke.rs b/crates/openshell-conformance/src/scenarios/smoke.rs index 4ec7cb3f3d..4de0b12553 100644 --- a/crates/openshell-conformance/src/scenarios/smoke.rs +++ b/crates/openshell-conformance/src/scenarios/smoke.rs @@ -6,6 +6,7 @@ use std::ffi::OsString; use std::time::{Duration, Instant}; +use crate::platform::{self, SmokeFixture}; use crate::{OpenShellRunner, STATUS_TIMEOUT, Scenario, ScenarioFuture}; use serde::Deserialize; use tokio::time::sleep; @@ -62,7 +63,7 @@ async fn run_smoke_control_plane_inner(runner: &mut OpenShellRunner) -> Result<( status.require_success()?; let sandbox_name = format!("ct-{}-cp", runner.id()); - create_sandbox(runner, &sandbox_name, "create").await?; + let _fixture = create_sandbox(runner, &sandbox_name, "create").await?; check_sandbox_ready(runner, &sandbox_name).await?; check_sandbox_listed(runner, &sandbox_name).await?; @@ -71,7 +72,7 @@ async fn run_smoke_control_plane_inner(runner: &mut OpenShellRunner) -> Result<( async fn run_smoke_exec_inner(runner: &mut OpenShellRunner) -> Result<(), String> { let sandbox_name = format!("ct-{}-ex", runner.id()); - create_sandbox(runner, &sandbox_name, "create").await?; + let _fixture = create_sandbox(runner, &sandbox_name, "create").await?; check_sandbox_ready(runner, &sandbox_name).await?; let marker = format!("openshell-conformance-{}", runner.id()); @@ -104,7 +105,8 @@ async fn create_sandbox( runner: &mut OpenShellRunner, sandbox_name: &str, step: &str, -) -> Result<(), String> { +) -> Result { + let fixture = platform::smoke_fixture()?; runner.track_sandbox(sandbox_name); let mut args = vec![ "sandbox".to_string(), @@ -116,9 +118,10 @@ async fn create_sandbox( if let Some(extra_args) = json_string_array_env("OPENSHELL_CONFORMANCE_SMOKE_CREATE_ARGS")? { args.extend(extra_args); } - if let Some(command) = smoke_command()? { + args.extend(fixture.create_args().iter().cloned()); + if let Some(command) = fixture.command() { args.push("--".to_string()); - args.extend(command); + args.extend(command.iter().cloned()); } let args = args.iter().map(String::as_str).collect::>(); let create = runner @@ -128,23 +131,8 @@ async fn create_sandbox( .run(&args) .await .map_err(|error| error.to_string())?; - create.require_success() -} - -fn smoke_command() -> Result>, String> { - parse_smoke_command(std::env::var_os("OPENSHELL_CONFORMANCE_SMOKE_COMMAND")) -} - -fn parse_smoke_command(value: Option) -> Result>, String> { - let Some(command) = json_string_array("OPENSHELL_CONFORMANCE_SMOKE_COMMAND", value)? else { - return Ok(None); - }; - if command.first().is_none_or(String::is_empty) { - return Err( - "OPENSHELL_CONFORMANCE_SMOKE_COMMAND must contain a non-empty executable".to_string(), - ); - } - Ok(Some(command)) + create.require_success()?; + Ok(fixture) } fn json_string_array_env(name: &str) -> Result>, String> { @@ -295,30 +283,6 @@ async fn find_sandbox( mod tests { use super::*; - #[test] - fn smoke_command_is_optional() { - assert_eq!(parse_smoke_command(None).unwrap(), None); - } - - #[test] - fn smoke_command_parses_a_json_string_array() { - assert_eq!( - parse_smoke_command(Some(OsString::from(r#"["cmd.exe","/c","exit","0"]"#))).unwrap(), - Some(vec![ - "cmd.exe".into(), - "/c".into(), - "exit".into(), - "0".into() - ]) - ); - } - - #[test] - fn smoke_command_rejects_an_empty_executable() { - let error = parse_smoke_command(Some(OsString::from(r#"[""]"#))).unwrap_err(); - assert!(error.contains("non-empty executable")); - } - #[test] fn smoke_create_args_parse_a_json_string_array() { assert_eq!( diff --git a/tasks/scripts/windows-msvc.ps1 b/tasks/scripts/windows-msvc.ps1 index 0ed8452e9f..f742d4ee65 100644 --- a/tasks/scripts/windows-msvc.ps1 +++ b/tasks/scripts/windows-msvc.ps1 @@ -590,14 +590,12 @@ function Invoke-ConformanceTest([string] $RustTarget) { $testRoot = Join-Path $tempRoot "openshell-conformance-$RustTarget-$([guid]::NewGuid().ToString('N'))" $configRoot = Join-Path $testRoot "config" $stateRoot = Join-Path $testRoot "state" - $workloadRoot = Join-Path $testRoot "workload" $gatewayConfig = Join-Path $testRoot "gateway.toml" - $sandboxPolicy = Join-Path $testRoot "policy.yaml" $gatewayLog = Join-Path $LogDir "test-$RustTarget-conformance-gateway.log" $gatewayErrorLog = Join-Path $LogDir "test-$RustTarget-conformance-gateway.err.log" $conformanceLog = Join-Path $LogDir "test-$RustTarget-conformance.log" $conformanceErrorLog = Join-Path $LogDir "test-$RustTarget-conformance.err.log" - New-Item -ItemType Directory -Force -Path $configRoot, $stateRoot, $workloadRoot | Out-Null + New-Item -ItemType Directory -Force -Path $configRoot, $stateRoot | Out-Null @" [openshell] version = 2 @@ -606,16 +604,6 @@ version = 2 wxc_exec_path = "C:\\mxc\\wxc-exec.exe" backend = "process_container" "@ | Set-Content -LiteralPath $gatewayConfig -Encoding utf8 - $workloadPolicyPath = $workloadRoot.Replace('\', '/') - @" -version: 1 -filesystem_policy: - include_workdir: false - read_only: [] - read_write: - - "$workloadPolicyPath" -"@ | Set-Content -LiteralPath $sandboxPolicy -Encoding utf8 - $listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0) $listener.Start() $port = ([System.Net.IPEndPoint] $listener.LocalEndpoint).Port @@ -627,15 +615,6 @@ filesystem_policy: $env:XDG_CONFIG_HOME = $configRoot $env:XDG_STATE_HOME = $stateRoot $env:OPENSHELL_BIN = $cli - $env:OPENSHELL_CONFORMANCE_SMOKE_CREATE_ARGS = (@( - "--policy", $sandboxPolicy - ) | ConvertTo-Json -Compress) - $env:OPENSHELL_CONFORMANCE_SMOKE_COMMAND = (@( - "C:\Windows\System32\cmd.exe", - "/c", - "echo ready > `"$workloadPolicyPath/ready.txt`" & ping -t 127.0.0.1 > NUL" - ) | ConvertTo-Json -Compress) - $gatewayProcess = $null try { $gatewayProcess = Start-Process -FilePath $gateway ` diff --git a/tests/suites/conformance/README.md b/tests/suites/conformance/README.md index 88567c1c30..c4e0deab09 100644 --- a/tests/suites/conformance/README.md +++ b/tests/suites/conformance/README.md @@ -29,13 +29,13 @@ continue after individual failures. Use focused selection only to reproduce or diagnose one scenario locally. Set `OPENSHELL_BIN` to the candidate CLI. The selected gateway must already be -reachable. Runtimes that require an explicit sandbox workload can set -`OPENSHELL_CONFORMANCE_SMOKE_COMMAND` to a JSON string array. Set -`OPENSHELL_CONFORMANCE_SMOKE_CREATE_ARGS` to a JSON string array when the -runtime also needs portable create options such as `--policy`. For example: +reachable. Smoke workloads are selected from the conformance crate's Unix or +Windows implementation at compile time. Set +`OPENSHELL_CONFORMANCE_SMOKE_CREATE_ARGS` to a JSON string array when a runtime +needs additional portable create options. For example: ```shell -export OPENSHELL_CONFORMANCE_SMOKE_COMMAND='["sleep","infinity"]' +export OPENSHELL_CONFORMANCE_SMOKE_CREATE_ARGS='["--policy","policy.yaml"]' cargo nextest run \ --manifest-path tests/suites/conformance/Cargo.toml \ -E 'binary(smoke) & test(=control_plane)' From 68a5a04c40167ee6d9837712384f35c0758a8b6b Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Mon, 28 Sep 2026 18:45:23 +0200 Subject: [PATCH 36/42] test(conformance): remove smoke environment overrides Signed-off-by: Evan Lezar --- TESTING.md | 3 +- .../src/scenarios/smoke.rs | 37 ------------------- tests/suites/conformance/README.md | 5 +-- 3 files changed, 2 insertions(+), 43 deletions(-) diff --git a/TESTING.md b/TESTING.md index f17dd02e55..aa2a09bda3 100644 --- a/TESTING.md +++ b/TESTING.md @@ -201,8 +201,7 @@ command. Its control-plane test requires machine-readable status, creates a uniquely named detached sandbox, verifies it is `Ready` through get and paginated list output, deletes it, and verifies its name no longer appears. The exec test creates its own sandbox and checks -`sandbox exec` with a run-specific marker. Additional create options can be -supplied through `OPENSHELL_CONFORMANCE_SMOKE_CREATE_ARGS`. +`sandbox exec` with a run-specific marker. Sandbox lifecycle, label matrices, VM overlay, and TLS-key permission assertions remain regular E2E coverage. diff --git a/crates/openshell-conformance/src/scenarios/smoke.rs b/crates/openshell-conformance/src/scenarios/smoke.rs index 4de0b12553..64dd569874 100644 --- a/crates/openshell-conformance/src/scenarios/smoke.rs +++ b/crates/openshell-conformance/src/scenarios/smoke.rs @@ -3,7 +3,6 @@ //! Portable smoke conformance scenarios. -use std::ffi::OsString; use std::time::{Duration, Instant}; use crate::platform::{self, SmokeFixture}; @@ -115,9 +114,6 @@ async fn create_sandbox( sandbox_name.to_string(), "--detach".to_string(), ]; - if let Some(extra_args) = json_string_array_env("OPENSHELL_CONFORMANCE_SMOKE_CREATE_ARGS")? { - args.extend(extra_args); - } args.extend(fixture.create_args().iter().cloned()); if let Some(command) = fixture.command() { args.push("--".to_string()); @@ -135,22 +131,6 @@ async fn create_sandbox( Ok(fixture) } -fn json_string_array_env(name: &str) -> Result>, String> { - json_string_array(name, std::env::var_os(name)) -} - -fn json_string_array(name: &str, value: Option) -> Result>, String> { - let Some(value) = value else { - return Ok(None); - }; - let value = value - .into_string() - .map_err(|_| format!("{name} must contain valid Unicode JSON"))?; - serde_json::from_str::>(&value) - .map(Some) - .map_err(|error| format!("{name} must be a JSON string array: {error}")) -} - async fn delete_sandbox( runner: &mut OpenShellRunner, sandbox_name: &str, @@ -278,20 +258,3 @@ async fn find_sandbox( .ok_or_else(|| "sandbox list page counter overflowed".to_string())?; } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn smoke_create_args_parse_a_json_string_array() { - assert_eq!( - json_string_array( - "OPENSHELL_CONFORMANCE_SMOKE_CREATE_ARGS", - Some(OsString::from(r#"["--policy","policy.yaml"]"#)), - ) - .unwrap(), - Some(vec!["--policy".into(), "policy.yaml".into()]) - ); - } -} diff --git a/tests/suites/conformance/README.md b/tests/suites/conformance/README.md index c4e0deab09..b192e998df 100644 --- a/tests/suites/conformance/README.md +++ b/tests/suites/conformance/README.md @@ -30,12 +30,9 @@ diagnose one scenario locally. Set `OPENSHELL_BIN` to the candidate CLI. The selected gateway must already be reachable. Smoke workloads are selected from the conformance crate's Unix or -Windows implementation at compile time. Set -`OPENSHELL_CONFORMANCE_SMOKE_CREATE_ARGS` to a JSON string array when a runtime -needs additional portable create options. For example: +Windows implementation at compile time. For example: ```shell -export OPENSHELL_CONFORMANCE_SMOKE_CREATE_ARGS='["--policy","policy.yaml"]' cargo nextest run \ --manifest-path tests/suites/conformance/Cargo.toml \ -E 'binary(smoke) & test(=control_plane)' From 3e4134418ee05659d4006fa6df4e21784e11d41a Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Mon, 28 Sep 2026 21:07:47 +0200 Subject: [PATCH 37/42] test(conformance): platformize lifecycle commands Signed-off-by: Evan Lezar --- crates/openshell-conformance/README.md | 10 +- .../openshell-conformance/src/platform/mod.rs | 62 +++++++- .../src/platform/unix.rs | 76 +++++++++- .../src/platform/windows.rs | 135 +++++++++++++++--- .../src/scenarios/sandbox_lifecycle.rs | 97 ++++++------- .../src/scenarios/smoke.rs | 24 ++-- 6 files changed, 310 insertions(+), 94 deletions(-) diff --git a/crates/openshell-conformance/README.md b/crates/openshell-conformance/README.md index b77e6d1e53..83820b8f90 100644 --- a/crates/openshell-conformance/README.md +++ b/crates/openshell-conformance/README.md @@ -32,10 +32,12 @@ specific unsupported operation without hiding later coverage. Driver internals, platform enforcement, and hardware qualification belong in driver-specific tests rather than this crate. -Target-specific fixture construction lives under `src/platform`. The smoke -scenarios select the Unix or Windows implementation at compile time while the -scenario assertions remain shared. Platform implementations may construct -temporary policies and commands, but must exercise the same public behavior. +Target-specific fixture and command construction lives in `src/platform/unix.rs` +and `src/platform/windows.rs`. Scenarios select one implementation at compile +time while their control flow and assertions remain shared. The platform layer +provides a small set of reusable operations instead of mirroring the scenario +tree. Implementations may construct temporary policies and commands, but must +exercise the same public behavior. When adding a scenario, export it from the library and add the appropriate Cargo test wrapper under `tests/suites/conformance/cli`, then add the leaf to the diff --git a/crates/openshell-conformance/src/platform/mod.rs b/crates/openshell-conformance/src/platform/mod.rs index 377aba0bbb..ef5c429129 100644 --- a/crates/openshell-conformance/src/platform/mod.rs +++ b/crates/openshell-conformance/src/platform/mod.rs @@ -11,13 +11,71 @@ mod unix; mod windows; #[cfg(unix)] -pub use unix::smoke_fixture; +pub use unix::{ + increment_then_wait, keep_alive, lifecycle_fixture, marker_command, read_text, smoke_fixture, + write_text, +}; #[cfg(target_os = "windows")] -pub use windows::smoke_fixture; +pub use windows::{ + increment_then_wait, keep_alive, lifecycle_fixture, marker_command, read_text, smoke_fixture, + write_text, +}; #[cfg(not(any(unix, target_os = "windows")))] compile_error!("OpenShell conformance does not support this target platform"); +/// One platform-specific command and its exact successful stdout. +pub struct CommandExpectation { + argv: Vec, + expected_stdout: String, +} + +impl CommandExpectation { + fn new(argv: Vec, expected_stdout: String) -> Self { + Self { + argv, + expected_stdout, + } + } + + pub fn argv(&self) -> &[String] { + &self.argv + } + + pub fn expected_stdout(&self) -> &str { + &self.expected_stdout + } +} + +/// Writable sandbox location and create options for platform-neutral scenarios. +pub struct SandboxFixture { + root: String, + create_args: Vec, + // Keep generated policy and workload paths alive until the scenario ends. + _temp_dir: Option, +} + +impl SandboxFixture { + fn new(root: String, create_args: Vec, temp_dir: Option) -> Self { + Self { + root, + create_args, + _temp_dir: temp_dir, + } + } + + pub fn path(&self, name: &str) -> String { + std::path::Path::new(&self.root) + .join(name) + .to_string_lossy() + .into_owned() + } + + pub fn create_args(&self) -> &[String] { + &self.create_args + } +} + /// Platform-specific inputs needed to keep a smoke-test sandbox alive. pub struct SmokeFixture { create_args: Vec, diff --git a/crates/openshell-conformance/src/platform/unix.rs b/crates/openshell-conformance/src/platform/unix.rs index 0c31ad9eb6..e86bf61f69 100644 --- a/crates/openshell-conformance/src/platform/unix.rs +++ b/crates/openshell-conformance/src/platform/unix.rs @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -use super::SmokeFixture; +use super::{CommandExpectation, SandboxFixture, SmokeFixture}; // Match the fallible Windows fixture constructor behind the platform boundary. #[allow(clippy::unnecessary_wraps)] @@ -11,6 +11,70 @@ pub fn smoke_fixture() -> Result { Ok(SmokeFixture::new(Vec::new(), None, None)) } +pub fn marker_command(marker: &str) -> CommandExpectation { + CommandExpectation::new( + vec!["echo".to_string(), marker.to_string()], + format!("{marker}\n"), + ) +} + +// Match the fallible Windows fixture constructor behind the platform boundary. +#[allow(clippy::unnecessary_wraps)] +pub fn lifecycle_fixture() -> Result { + Ok(SandboxFixture::new( + "/sandbox".to_string(), + Vec::new(), + None, + )) +} + +pub fn keep_alive(_fixture: &SandboxFixture) -> Vec { + vec![ + "sh".to_string(), + "-lc".to_string(), + "exec sleep infinity".to_string(), + ] +} + +pub fn increment_then_wait(path: &str) -> Vec { + let path = shell_quote(path); + vec![ + "sh".to_string(), + "-lc".to_string(), + format!( + "count=0; test ! -f {path} || count=$(cat {path}); \ + count=$((count + 1)); printf '%s\\n' \"$count\" > {path}; \ + exec sleep infinity" + ), + ] +} + +pub fn write_text(path: &str, value: &str) -> CommandExpectation { + CommandExpectation::new( + vec![ + "sh".to_string(), + "-lc".to_string(), + format!( + "printf '%s\\n' {} > {} && sync", + shell_quote(value), + shell_quote(path) + ), + ], + String::new(), + ) +} + +pub fn read_text(path: &str, value: &str) -> CommandExpectation { + CommandExpectation::new( + vec!["cat".to_string(), path.to_string()], + format!("{value}\n"), + ) +} + +fn shell_quote(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\\''")) +} + #[cfg(test)] mod tests { use super::*; @@ -21,4 +85,14 @@ mod tests { assert!(fixture.create_args().is_empty()); assert!(fixture.command().is_none()); } + + #[test] + fn lifecycle_commands_use_unix_tools() { + let fixture = lifecycle_fixture().unwrap(); + let path = fixture.path("sentinel"); + assert_eq!(path, "/sandbox/sentinel"); + assert_eq!(keep_alive(&fixture)[0], "sh"); + assert_eq!(write_text(&path, "value").expected_stdout(), ""); + assert_eq!(read_text(&path, "value").expected_stdout(), "value\n"); + } } diff --git a/crates/openshell-conformance/src/platform/windows.rs b/crates/openshell-conformance/src/platform/windows.rs index 4a52cdd81f..98eb6863a4 100644 --- a/crates/openshell-conformance/src/platform/windows.rs +++ b/crates/openshell-conformance/src/platform/windows.rs @@ -3,20 +3,106 @@ use std::fs; -use super::SmokeFixture; +use super::{CommandExpectation, SandboxFixture, SmokeFixture}; pub fn smoke_fixture() -> Result { + let (temp_dir, workload_dir, create_args) = writable_fixture("smoke")?; + let ready_path = workload_dir.join("ready.txt"); + let command = vec![ + r"C:\Windows\System32\cmd.exe".to_string(), + "/c".to_string(), + format!( + "echo ready > \"{}\" & ping -t 127.0.0.1 > NUL", + ready_path.display() + ), + ]; + Ok(SmokeFixture::new( + create_args, + Some(command), + Some(temp_dir), + )) +} + +pub fn marker_command(marker: &str) -> CommandExpectation { + CommandExpectation::new( + vec![ + r"C:\Windows\System32\cmd.exe".to_string(), + "/c".to_string(), + format!("echo {marker}"), + ], + format!("{marker}\r\n"), + ) +} + +pub fn lifecycle_fixture() -> Result { + let (temp_dir, workload_dir, create_args) = writable_fixture("lifecycle")?; + Ok(SandboxFixture::new( + workload_dir.to_string_lossy().into_owned(), + create_args, + Some(temp_dir), + )) +} + +pub fn keep_alive(fixture: &SandboxFixture) -> Vec { + vec![ + r"C:\Windows\System32\cmd.exe".to_string(), + "/c".to_string(), + format!( + "echo ready > \"{}\" & ping -t 127.0.0.1 > NUL", + fixture.path("ready.txt") + ), + ] +} + +pub fn increment_then_wait(path: &str) -> Vec { + vec![ + r"C:\Windows\System32\cmd.exe".to_string(), + "/v:on".to_string(), + "/c".to_string(), + format!( + "set count=0 & if exist \"{path}\" set /p count=<\"{path}\" & \ + set /a count+=1 > NUL & >\"{path}\" echo !count! & \ + ping -t 127.0.0.1 > NUL" + ), + ] +} + +pub fn write_text(path: &str, value: &str) -> CommandExpectation { + CommandExpectation::new( + vec![ + r"C:\Windows\System32\cmd.exe".to_string(), + "/c".to_string(), + format!(">\"{path}\" echo {value}"), + ], + String::new(), + ) +} + +pub fn read_text(path: &str, value: &str) -> CommandExpectation { + CommandExpectation::new( + vec![ + r"C:\Windows\System32\cmd.exe".to_string(), + "/c".to_string(), + format!("type \"{path}\""), + ], + format!("{value}\r\n"), + ) +} + +fn writable_fixture( + name: &str, +) -> Result<(tempfile::TempDir, std::path::PathBuf, Vec), String> { let temp_dir = tempfile::Builder::new() - .prefix("openshell-conformance-smoke-") + .prefix(&format!("openshell-conformance-{name}-")) .tempdir() - .map_err(|error| format!("create Windows smoke fixture directory: {error}"))?; + .map_err(|error| format!("create Windows {name} fixture directory: {error}"))?; let workload_dir = temp_dir.path().join("workload"); fs::create_dir(&workload_dir) - .map_err(|error| format!("create Windows smoke workload directory: {error}"))?; + .map_err(|error| format!("create Windows {name} workload directory: {error}"))?; let workload_policy_path = workload_dir .to_str() - .ok_or_else(|| "Windows smoke workload path is not valid Unicode".to_string())? + .ok_or_else(|| format!("Windows {name} workload path is not valid Unicode"))? .replace('\\', "/") .replace('"', "\\\""); let policy_path = temp_dir.path().join("policy.yaml"); @@ -24,27 +110,12 @@ pub fn smoke_fixture() -> Result { "version: 1\nfilesystem_policy:\n include_workdir: false\n read_only: []\n read_write:\n - \"{workload_policy_path}\"\n" ); fs::write(&policy_path, policy) - .map_err(|error| format!("write Windows smoke policy: {error}"))?; - - let ready_path = workload_dir.join("ready.txt"); - let command = vec![ - r"C:\Windows\System32\cmd.exe".to_string(), - "/c".to_string(), - format!( - "echo ready > \"{}\" & ping -t 127.0.0.1 > NUL", - ready_path.display() - ), - ]; + .map_err(|error| format!("write Windows {name} policy: {error}"))?; let create_args = vec![ "--policy".to_string(), policy_path.to_string_lossy().into_owned(), ]; - - Ok(SmokeFixture::new( - create_args, - Some(command), - Some(temp_dir), - )) + Ok((temp_dir, workload_dir, create_args)) } #[cfg(test)] @@ -73,4 +144,24 @@ mod tests { ) ); } + + #[test] + fn lifecycle_commands_use_windows_tools_and_policy() { + let fixture = lifecycle_fixture().unwrap(); + let path = fixture.path("sentinel.txt"); + assert!( + std::path::Path::new(&path) + .ends_with(std::path::Path::new("workload").join("sentinel.txt")) + ); + assert_eq!(keep_alive(&fixture)[0], r"C:\Windows\System32\cmd.exe"); + assert_eq!(marker_command("marker").expected_stdout(), "marker\r\n"); + assert_eq!(write_text(&path, "value").expected_stdout(), ""); + assert_eq!(read_text(&path, "value").expected_stdout(), "value\r\n"); + assert!(increment_then_wait(&path)[3].contains("!count!")); + assert!( + fs::read_to_string(&fixture.create_args()[1]) + .unwrap() + .contains("read_write:") + ); + } } diff --git a/crates/openshell-conformance/src/scenarios/sandbox_lifecycle.rs b/crates/openshell-conformance/src/scenarios/sandbox_lifecycle.rs index e3b9a2fae8..224e611d33 100644 --- a/crates/openshell-conformance/src/scenarios/sandbox_lifecycle.rs +++ b/crates/openshell-conformance/src/scenarios/sandbox_lifecycle.rs @@ -7,6 +7,7 @@ use std::time::Duration; use serde::Deserialize; +use crate::platform::{self, CommandExpectation, SandboxFixture}; use crate::{OpenShellRunner, Poll, Scenario, ScenarioFuture}; const CREATE_TIMEOUT: Duration = Duration::from_mins(10); @@ -37,25 +38,17 @@ fn run_sandbox_lifecycle(runner: &mut OpenShellRunner) -> ScenarioFuture<'_> { async fn stop_start_preserves_workspace(runner: &mut OpenShellRunner) -> Result<(), String> { let sandbox_name = format!("ct-{}-ss", runner.id()); let sentinel = format!("openshell-stop-start-{}", runner.id()); - let sentinel_path = "/sandbox/.openshell-stop-start-sentinel"; - let run_count_path = "/sandbox/.openshell-main-run-count"; - let main = format!( - "count=0; test ! -f '{run_count_path}' || count=$(cat '{run_count_path}'); \ - count=$((count + 1)); printf '%s\\n' \"$count\" > '{run_count_path}'; \ - exec sleep infinity" - ); + let fixture = platform::lifecycle_fixture()?; + let sentinel_path = fixture.path(".openshell-stop-start-sentinel"); + let run_count_path = fixture.path(".openshell-main-run-count"); + let main = platform::increment_then_wait(&run_count_path); - create_running_sandbox(runner, &sandbox_name, &main, "stop-start").await?; + create_running_sandbox(runner, &sandbox_name, &fixture, &main, "stop-start").await?; exec_expect_exact( runner, &sandbox_name, "write-sentinel", - &[ - "sh", - "-lc", - &format!("printf '%s\\n' '{sentinel}' > '{sentinel_path}' && sync"), - ], - "", + &platform::write_text(&sentinel_path, &sentinel), ) .await?; @@ -68,16 +61,10 @@ async fn stop_start_preserves_workspace(runner: &mut OpenShellRunner) -> Result< "sandbox '{sandbox_name}' rejects exec while stopped" )) .with_timeout(COMMAND_TIMEOUT) - .run(&[ - "sandbox", - "exec", - "--name", + .run(&exec_args( &sandbox_name, - "--no-tty", - "--", - "cat", - sentinel_path, - ]) + platform::read_text(&sentinel_path, &sentinel).argv(), + )) .await .map_err(|error| error.to_string())?; if stopped_exec.success() { @@ -93,29 +80,23 @@ async fn stop_start_preserves_workspace(runner: &mut OpenShellRunner) -> Result< runner, &sandbox_name, "read-sentinel", - &["cat", sentinel_path], - &format!("{sentinel}\n"), + &platform::read_text(&sentinel_path, &sentinel), ) .await?; exec_expect_exact( runner, &sandbox_name, "read-main-run-count", - &["cat", run_count_path], - "2\n", + &platform::read_text(&run_count_path, "2"), ) .await } async fn stopped_can_be_deleted(runner: &mut OpenShellRunner) -> Result<(), String> { let sandbox_name = format!("ct-{}-sd", runner.id()); - create_running_sandbox( - runner, - &sandbox_name, - "exec sleep infinity", - "stopped-delete", - ) - .await?; + let fixture = platform::lifecycle_fixture()?; + let main = platform::keep_alive(&fixture); + create_running_sandbox(runner, &sandbox_name, &fixture, &main, "stopped-delete").await?; run_lifecycle_command(runner, "stop", &sandbox_name, "stopped-delete/stop").await?; wait_for_phase(runner, &sandbox_name, "Stopped", "stopped-delete/stopped").await?; @@ -128,26 +109,28 @@ async fn stopped_can_be_deleted(runner: &mut OpenShellRunner) -> Result<(), Stri async fn create_running_sandbox( runner: &mut OpenShellRunner, sandbox_name: &str, - main: &str, + fixture: &SandboxFixture, + main: &[String], step: &str, ) -> Result<(), String> { runner.track_sandbox(sandbox_name); + let mut args = vec![ + "sandbox".to_string(), + "create".to_string(), + "--name".to_string(), + sandbox_name.to_string(), + "--detach".to_string(), + "--no-tty".to_string(), + ]; + args.extend(fixture.create_args().iter().cloned()); + args.push("--".to_string()); + args.extend(main.iter().cloned()); + let args = args.iter().map(String::as_str).collect::>(); let create = runner .step(format!("{step}/create")) .description(format!("sandbox '{sandbox_name}' is created")) .with_timeout(CREATE_TIMEOUT) - .run(&[ - "sandbox", - "create", - "--name", - sandbox_name, - "--detach", - "--no-tty", - "--", - "sh", - "-lc", - main, - ]) + .run(&args) .await .map_err(|error| error.to_string())?; create.require_success()?; @@ -174,26 +157,32 @@ async fn exec_expect_exact( runner: &OpenShellRunner, sandbox_name: &str, step: &str, - command: &[&str], - expected_stdout: &str, + command: &CommandExpectation, ) -> Result<(), String> { - let mut args = vec!["sandbox", "exec", "--name", sandbox_name, "--no-tty", "--"]; - args.extend_from_slice(command); let result = runner .step(format!("stop-start/{step}")) .description(format!("sandbox '{sandbox_name}' exec {step} succeeds")) .with_timeout(COMMAND_TIMEOUT) - .run(&args) + .run(&exec_args(sandbox_name, command.argv())) .await .map_err(|error| error.to_string())?; result.require_success()?; - if result.stdout() == expected_stdout { + if result.stdout() == command.expected_stdout() { Ok(()) } else { - Err(result.failure_diagnostic(&format!("stdout is exactly {expected_stdout:?}"))) + Err(result.failure_diagnostic(&format!( + "stdout is exactly {:?}", + command.expected_stdout() + ))) } } +fn exec_args<'a>(sandbox_name: &'a str, command: &'a [String]) -> Vec<&'a str> { + let mut args = vec!["sandbox", "exec", "--name", sandbox_name, "--no-tty", "--"]; + args.extend(command.iter().map(String::as_str)); + args +} + async fn wait_for_phase( runner: &mut OpenShellRunner, sandbox_name: &str, diff --git a/crates/openshell-conformance/src/scenarios/smoke.rs b/crates/openshell-conformance/src/scenarios/smoke.rs index 64dd569874..83a5635bec 100644 --- a/crates/openshell-conformance/src/scenarios/smoke.rs +++ b/crates/openshell-conformance/src/scenarios/smoke.rs @@ -75,24 +75,26 @@ async fn run_smoke_exec_inner(runner: &mut OpenShellRunner) -> Result<(), String check_sandbox_ready(runner, &sandbox_name).await?; let marker = format!("openshell-conformance-{}", runner.id()); + let marker_command = platform::marker_command(&marker); + let mut args = vec![ + "sandbox".to_string(), + "exec".to_string(), + "--name".to_string(), + sandbox_name.clone(), + "--no-tty".to_string(), + "--".to_string(), + ]; + args.extend(marker_command.argv().iter().cloned()); + let args = args.iter().map(String::as_str).collect::>(); let exec = runner .step("exec") .description("sandbox exec exits successfully") .with_timeout(EXEC_TIMEOUT) - .run(&[ - "sandbox", - "exec", - "--name", - &sandbox_name, - "--no-tty", - "--", - "echo", - &marker, - ]) + .run(&args) .await .map_err(|error| error.to_string())?; exec.require_success()?; - let expected_stdout = format!("{marker}\n"); + let expected_stdout = marker_command.expected_stdout(); if exec.stdout() != expected_stdout { return Err(exec.failure_diagnostic(&format!("stdout is exactly {expected_stdout:?}"))); } From 9a36bc3b100436e3299365dadcc1eddb2d4b3589 Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Mon, 28 Sep 2026 21:38:45 +0200 Subject: [PATCH 38/42] fix(conformance): address Windows review feedback Signed-off-by: Evan Lezar --- .../build-openshell-mxc-windows/SKILL.md | 26 +++++-- .../build-openshell-mxc-windows/reference.md | 12 ++++ .github/workflows/windows-msvc.yml | 2 + CONTRIBUTING.md | 1 + architecture/windows.md | 5 +- .../src/scenarios/sandbox_lifecycle.rs | 72 +++++++++++++++---- tasks/windows.toml | 4 +- 7 files changed, 99 insertions(+), 23 deletions(-) diff --git a/.agents/skills/build-openshell-mxc-windows/SKILL.md b/.agents/skills/build-openshell-mxc-windows/SKILL.md index a6d86395bb..ea7b4d6ec4 100644 --- a/.agents/skills/build-openshell-mxc-windows/SKILL.md +++ b/.agents/skills/build-openshell-mxc-windows/SKILL.md @@ -50,6 +50,8 @@ In scope: - Building x64 and ARM64 release binaries for `openshell-gateway` and `openshell`. - Running workspace tests on a native x64 or ARM64 host. +- Running the complete conformance suite against the mock MXC gateway and + reporting unsupported operations without blocking hosted Windows CI. - Running focused unsupported-driver contract tests. - Running the shipped Ollama and cloud-inference MXC examples against the in-process `wxc` mock and a local API stub. @@ -161,6 +163,7 @@ mise run --skip-tools windows:check:arm64 mise run --skip-tools windows:build:x64 mise run --skip-tools windows:build:arm64 mise run --skip-tools windows:test:x64 +mise run --skip-tools windows:test:conformance:x64 mise run --skip-tools windows:test:unsupported:x64 mise run --skip-tools windows:e2e:mxc:inference-mock:x64 mise run --skip-tools windows:e2e:mxc:provider-mock @@ -247,12 +250,13 @@ release binaries, and run the inference, provider-credential, OCSF audit, and aggregate shipped examples through their mock tasks. Pushes to `main` and manual dispatches run the same lint and test commands in a cache-seed job, followed by a dependent release-binary build and mock-example job. The seed and -PR jobs use the same cache namespaces. Merge -queues do not run this workflow. Main/manual seed and build jobs use job-level -`continue-on-error: true`; opt-in PR jobs report failures normally. Applying -the label alone does not start a run: re-run all jobs in the current mirror -push run, or push a new mirrored commit. The binaries are not uploaded or -published. +PR jobs use the same cache namespaces. Merge queues do not run this workflow. +Main/manual seed and build jobs use job-level `continue-on-error: true`; +opt-in PR jobs report failures normally, except that the full mock-MXC +conformance step is advisory on every hosted lane so known capability gaps are +archived without blocking the job. Applying the label alone does not start a +run: re-run all jobs in the current mirror push run, or push a new mirrored +commit. The binaries are not uploaded or published. The ARM64 check/build steps in this x64-host contract are cross-builds. The wrapper discovers and adds host-native LLVM and Ninja to `PATH`, requires the @@ -292,6 +296,8 @@ crypto dependency builds. | `windows:build:arm64` | Release-builds `openshell-gateway.exe`, `openshell.exe`, and `openshell-supervisor-relay.exe` for ARM64. | | `windows:test:x64` | Runs native x64 workspace tests with `--no-fail-fast`, excluding unsupported Windows packages as top-level workspace targets. | | `windows:test:arm64` | Runs native ARM64 workspace tests with `--no-fail-fast` and the same package exclusions. Rejects non-ARM64 hosts. | +| `windows:test:conformance:x64` | Runs every conformance scenario against the mock MXC gateway on native x64, then exits nonzero if any scenario failed. Hosted CI treats that result as advisory and uploads the complete report. | +| `windows:test:conformance:arm64` | Runs the same complete mock-MXC report natively on ARM64. Rejects non-ARM64 hosts. Hosted CI treats scenario failures as advisory. | | `windows:test:unsupported:x64` | Re-runs focused `openshell-gateway` tests for unsupported Windows driver behavior. | | `windows:test:unsupported:arm64` | Re-runs the same focused contracts natively on ARM64. Rejects non-ARM64 hosts. | | `windows:test:mxc-real:x64` | Runs the serial, ignored real-`wxc-exec` integration suite natively on x64 through the MSVC wrapper. Rejects non-x64 hosts. | @@ -356,6 +362,9 @@ When reporting `windows:ci`, distinguish these categories: - The focused unsupported-contract re-run. - The architecture-matched MXC inference, provider-credential, OCSF audit, and aggregate mock tasks and their explicit wiring-only limitation. +- The complete mock-MXC conformance report, separating passed scenarios from + expected capability gaps. Do not report an advisory hosted run as full + conformance. - Explicit Cargo ignored tests, usually ignored doc examples. - Tests hidden by `#[cfg(not(target_os = "windows"))]`; these often appear as `running 0 tests`, not as ignored tests. @@ -373,6 +382,11 @@ Useful log files: | `build-aarch64-pc-windows-msvc-release.log` | ARM64 release build output. | | `test-x86_64-pc-windows-msvc.log` | Full native x64 workspace test output. | | `test-aarch64-pc-windows-msvc.log` | Full native ARM64 workspace test output. | +| `build--conformance.log` | Build output for the CLI, gateway, and standalone conformance runner. | +| `test--conformance.log` | Complete mock-MXC conformance scenario report. | +| `test--conformance.err.log` | Conformance runner standard error. | +| `test--conformance-gateway.log` | Mock-MXC gateway standard output. | +| `test--conformance-gateway.err.log` | Mock-MXC gateway standard error. | | `test-x86_64-pc-windows-msvc-unsupported-*.log` | Focused unsupported-driver contract output. | | `test-aarch64-pc-windows-msvc-unsupported-*.log` | Focused native ARM64 contract output. | | `test-x86_64-pc-windows-msvc-mxc-real.log` | Native x64 real-MXC integration output. | diff --git a/.agents/skills/build-openshell-mxc-windows/reference.md b/.agents/skills/build-openshell-mxc-windows/reference.md index 4ab0358d87..d15fefaebb 100644 --- a/.agents/skills/build-openshell-mxc-windows/reference.md +++ b/.agents/skills/build-openshell-mxc-windows/reference.md @@ -30,6 +30,8 @@ mise run --skip-tools windows:build:x64 mise run --skip-tools windows:build:arm64 mise run --skip-tools windows:test:x64 mise run --skip-tools windows:test:arm64 +mise run --skip-tools windows:test:conformance:x64 +mise run --skip-tools windows:test:conformance:arm64 mise run --skip-tools windows:test:unsupported:x64 mise run --skip-tools windows:test:unsupported:arm64 mise run --skip-tools windows:ci @@ -53,6 +55,12 @@ if ($arch -eq [System.Runtime.InteropServices.Architecture]::Arm64) { The native test tasks reject a target that does not match the host architecture. Do not report x64 compatibility-under-emulation coverage from an ARM64 run. +The conformance tasks start a temporary gateway with the in-process mock MXC +backend and run every atomic scenario through the standalone conformance +runner. The task exits nonzero when any scenario fails so local runs retain an +accurate result. Hosted Windows CI marks only this step as advisory and uploads +the runner and gateway logs even when known MXC capability gaps fail. + The wrapper adds missing rustup targets and preserves an inherited `RUSTC_WRAPPER` when the command is available. Otherwise, it warns and clears the setting. It does not install Visual Studio, Rust, Docker, Kubernetes, @@ -198,6 +206,9 @@ Use the log summaries from: |---|---| | `test-x86_64-pc-windows-msvc.log` | Full x64 workspace test pass. | | `test-aarch64-pc-windows-msvc.log` | Full native ARM64 workspace test pass. | +| `test--conformance.log` | Complete mock-MXC conformance report. | +| `test--conformance.err.log` | Conformance runner standard error. | +| `test--conformance-gateway*.log` | Mock-MXC gateway output and errors. | | `test-x86_64-pc-windows-msvc-unsupported-*.log` | Focused unsupported-contract re-runs and filtered counts. | | `test-aarch64-pc-windows-msvc-unsupported-*.log` | Focused native ARM64 re-runs and filtered counts. | @@ -209,6 +220,7 @@ Separate: - filtered out - cfg-gated zero-test targets - package-level excludes +- conformance scenarios that pass versus capability gaps that fail Package-level excludes are not printed as ignored tests by Cargo. diff --git a/.github/workflows/windows-msvc.yml b/.github/workflows/windows-msvc.yml index 5fa4764132..1ccd9662c8 100644 --- a/.github/workflows/windows-msvc.yml +++ b/.github/workflows/windows-msvc.yml @@ -99,6 +99,7 @@ jobs: - name: Test run: mise run --skip-tools windows:test:${{ matrix.arch }} - name: Full MXC conformance + continue-on-error: true run: mise run --skip-tools windows:test:conformance:${{ matrix.arch }} - name: Upload MXC conformance report if: always() @@ -185,6 +186,7 @@ jobs: - name: Test run: mise run --skip-tools windows:test:${{ matrix.arch }} - name: Full MXC conformance + continue-on-error: true run: mise run --skip-tools windows:test:conformance:${{ matrix.arch }} - name: Upload MXC conformance report if: always() diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 74beb5c6fe..3af91795cd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -385,6 +385,7 @@ install the Windows compiler toolchain. | `windows:lint:` | Run Clippy for the Windows-supported workspace on the selected target architecture. | | `windows:build:` | Build the three Windows release executables. | | `windows:test:` | Run the workspace suite natively; the target must match the host architecture. | +| `windows:test:conformance:` | Run the complete conformance suite against the mock MXC gateway and report every unsupported operation. The task exits nonzero when any scenario fails; hosted CI treats the report as advisory and always uploads its logs. | | `windows:test:unsupported:` | Run the focused unsupported-driver contracts. | | `windows:test:mxc-real:` | Run the probe-gated real-`wxc-exec` integration suite on the matching host. | | `windows:artifacts` | Report sizes and SHA256 hashes for release artifacts. | diff --git a/architecture/windows.md b/architecture/windows.md index 9a36dacc1e..b5938988bd 100644 --- a/architecture/windows.md +++ b/architecture/windows.md @@ -249,7 +249,10 @@ Windows validation separates source correctness from host capability: - Hosted Windows CI runs the complete archive conformance suite against the in-process MXC mock through the standalone `openshell-conformance` runner. Every atomic scenario runs so unsupported operations appear as explicit - failures in the archived report instead of being skipped. + failures in the archived report instead of being skipped. This report is + advisory while those capability gaps remain: its scenario failures do not + fail the surrounding Windows job, and CI always uploads the runner and + gateway logs. The mock verifies public CLI, gateway, driver, lifecycle, and policy wiring; it does not claim Windows OS enforcement. - Mock MXC E2E validates gateway, CLI, driver, lifecycle, and policy wiring but diff --git a/crates/openshell-conformance/src/scenarios/sandbox_lifecycle.rs b/crates/openshell-conformance/src/scenarios/sandbox_lifecycle.rs index 224e611d33..888e09f9b7 100644 --- a/crates/openshell-conformance/src/scenarios/sandbox_lifecycle.rs +++ b/crates/openshell-conformance/src/scenarios/sandbox_lifecycle.rs @@ -21,6 +21,12 @@ struct SandboxState { phase: String, } +#[derive(Debug, Deserialize)] +struct SandboxListPage { + sandboxes: Vec, + next_page_token: String, +} + /// Certify sandbox stop, start, and deletion lifecycle behavior. pub const SANDBOX_LIFECYCLE_SCENARIO: Scenario = Scenario { name: "sandbox/lifecycle", @@ -246,22 +252,60 @@ async fn wait_for_absence( &poll_step, TRANSITION_TIMEOUT, TRANSITION_INTERVAL, - async move |runner| { - let result = runner - .step(format!("{step}/get")) - .description(format!("sandbox '{sandbox_name}' is no longer retrievable")) - .with_timeout(COMMAND_TIMEOUT) - .run(&["sandbox", "get", &sandbox_name, "--output", "json"]) - .await; - match result { - Ok(result) if !result.success() => Poll::Ready(()), - Ok(_) => { - Poll::Pending(format!("sandbox '{sandbox_name}' is still retrievable")) - } - Err(error) => Poll::Pending(error.to_string()), - } + async move |runner| match sandbox_is_listed(runner, &sandbox_name, &step).await { + Ok(false) => Poll::Ready(()), + Ok(true) => Poll::Pending(format!("sandbox '{sandbox_name}' is still retrievable")), + Err(error) => Poll::Pending(error), }, ) .await .map_err(|error| error.to_string()) } + +async fn sandbox_is_listed( + runner: &OpenShellRunner, + sandbox_name: &str, + step: &str, +) -> Result { + let mut page_token = String::new(); + let mut page = 0u32; + loop { + let result = runner + .step(format!("{step}/list/{page}")) + .description(format!( + "sandbox list confirms whether '{sandbox_name}' still exists" + )) + .with_timeout(COMMAND_TIMEOUT) + .run(&[ + "sandbox", + "list", + "--page-size", + "1000", + "--page-token", + &page_token, + "--output", + "json", + ]) + .await + .map_err(|error| error.to_string())?; + result.require_success()?; + + let response = result + .json::() + .map_err(|error| error.to_string())?; + if response + .sandboxes + .iter() + .any(|sandbox| sandbox.name == sandbox_name) + { + return Ok(true); + } + if response.next_page_token.is_empty() { + return Ok(false); + } + page_token = response.next_page_token; + page = page + .checked_add(1) + .ok_or_else(|| "sandbox list page counter overflowed".to_string())?; + } +} diff --git a/tasks/windows.toml b/tasks/windows.toml index 3cff6ed676..b2adca7769 100644 --- a/tasks/windows.toml +++ b/tasks/windows.toml @@ -56,12 +56,12 @@ run = "echo 'windows:* tasks require a Windows MSVC host' && exit 1" run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 test-precommit aarch64-pc-windows-msvc" ["windows:test:conformance:x64"] -description = "Run all openshell-conformance scenarios against a mock MXC gateway on native Windows x64" +description = "Report all openshell-conformance scenarios against a mock MXC gateway on native Windows x64" run = "echo 'windows:* tasks require a Windows MSVC host' && exit 1" run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 test-conformance x86_64-pc-windows-msvc" ["windows:test:conformance:arm64"] -description = "Run all openshell-conformance scenarios against a mock MXC gateway on native Windows ARM64" +description = "Report all openshell-conformance scenarios against a mock MXC gateway on native Windows ARM64" run = "echo 'windows:* tasks require a Windows MSVC host' && exit 1" run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 test-conformance aarch64-pc-windows-msvc" From 2b94a7e92e538cbcf24a8b175b204045130c39f2 Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Mon, 28 Sep 2026 22:10:33 +0200 Subject: [PATCH 39/42] fix(conformance): expose MXC capability gaps Signed-off-by: Evan Lezar --- .../build-openshell-mxc-windows/SKILL.md | 5 + .../build-openshell-mxc-windows/reference.md | 7 + architecture/windows.md | 6 +- crates/openshell-conformance-cli/src/main.rs | 19 +- crates/openshell-conformance/README.md | 5 +- crates/openshell-conformance/src/lib.rs | 6 +- .../openshell-conformance/src/platform/mod.rs | 8 +- .../src/platform/unix.rs | 39 ++++ .../src/platform/windows.rs | 66 +++++- .../src/scenarios/file_transfer.rs | 192 +++++++++++++----- .../src/scenarios/mod.rs | 4 +- .../src/scenarios/policy_behavior.rs | 47 ++--- .../src/scenarios/sandbox_lifecycle.rs | 28 ++- tests/suites/conformance/README.md | 8 +- .../suites/conformance/cli/tests/lifecycle.rs | 27 ++- 15 files changed, 350 insertions(+), 117 deletions(-) diff --git a/.agents/skills/build-openshell-mxc-windows/SKILL.md b/.agents/skills/build-openshell-mxc-windows/SKILL.md index ea7b4d6ec4..e2bd79eb20 100644 --- a/.agents/skills/build-openshell-mxc-windows/SKILL.md +++ b/.agents/skills/build-openshell-mxc-windows/SKILL.md @@ -365,6 +365,11 @@ When reporting `windows:ci`, distinguish these categories: - The complete mock-MXC conformance report, separating passed scenarios from expected capability gaps. Do not report an advisory hosted run as full conformance. +- The first failing public operation in each conformance leaf. The + `sandbox/lifecycle/control-plane` leaf does not require exec; file-transfer + leaves should reach upload or download, and policy leaves should reach the + public exec or policy boundary. Treat a failure during sandbox creation from + a Unix-only fixture as a harness defect, not an MXC capability gap. - Explicit Cargo ignored tests, usually ignored doc examples. - Tests hidden by `#[cfg(not(target_os = "windows"))]`; these often appear as `running 0 tests`, not as ignored tests. diff --git a/.agents/skills/build-openshell-mxc-windows/reference.md b/.agents/skills/build-openshell-mxc-windows/reference.md index d15fefaebb..c93df85c48 100644 --- a/.agents/skills/build-openshell-mxc-windows/reference.md +++ b/.agents/skills/build-openshell-mxc-windows/reference.md @@ -61,6 +61,13 @@ runner. The task exits nonzero when any scenario fails so local runs retain an accurate result. Hosted Windows CI marks only this step as advisory and uploads the runner and gateway logs even when known MXC capability gaps fail. +Interpret each failed leaf at its first public operation. Control-plane +lifecycle coverage is independent of restart/exec persistence. File-transfer +leaves must reach upload or download, and policy leaves must reach the public +exec or policy boundary. A failure during sandbox creation caused by a Unix +command, path, or policy is a conformance fixture defect rather than an MXC +capability gap. + The wrapper adds missing rustup targets and preserves an inherited `RUSTC_WRAPPER` when the command is available. Otherwise, it warns and clears the setting. It does not install Visual Studio, Rust, Docker, Kubernetes, diff --git a/architecture/windows.md b/architecture/windows.md index b5938988bd..35f0ec45a4 100644 --- a/architecture/windows.md +++ b/architecture/windows.md @@ -252,7 +252,11 @@ Windows validation separates source correctness from host capability: failures in the archived report instead of being skipped. This report is advisory while those capability gaps remain: its scenario failures do not fail the surrounding Windows job, and CI always uploads the runner and - gateway logs. + gateway logs. Portable fixtures supply Windows commands, paths, and policies + only to establish each scenario's prerequisites. They keep control-plane + lifecycle coverage independent from restart/exec coverage and ensure that + file-transfer and policy failures identify the public unsupported operation + rather than an invalid Unix workload definition. The mock verifies public CLI, gateway, driver, lifecycle, and policy wiring; it does not claim Windows OS enforcement. - Mock MXC E2E validates gateway, CLI, driver, lifecycle, and policy wiring but diff --git a/crates/openshell-conformance-cli/src/main.rs b/crates/openshell-conformance-cli/src/main.rs index fbb0d88a9c..1963ae1b57 100644 --- a/crates/openshell-conformance-cli/src/main.rs +++ b/crates/openshell-conformance-cli/src/main.rs @@ -238,7 +238,7 @@ mod tests { #[test] fn selects_all_scenarios_by_default() { let selected = select_scenarios(&[]).expect("select all"); - assert_eq!(selected.len(), 9); + assert_eq!(selected.len(), 10); assert_eq!(selected.len(), scenarios().len()); } @@ -259,6 +259,23 @@ mod tests { assert_eq!(names, ["smoke/control-plane", "smoke/exec"]); } + #[test] + fn expands_lifecycle_group_to_independent_capabilities() { + let selected = + select_scenarios(&["sandbox/lifecycle".to_string()]).expect("select lifecycle group"); + let names = selected + .iter() + .map(|candidate| candidate.name) + .collect::>(); + assert_eq!( + names, + [ + "sandbox/lifecycle/control-plane", + "sandbox/lifecycle/restart-persistence", + ] + ); + } + #[test] fn overlapping_selectors_do_not_run_a_leaf_twice() { let selected = select_scenarios(&[ diff --git a/crates/openshell-conformance/README.md b/crates/openshell-conformance/README.md index 83820b8f90..62dc76d9fa 100644 --- a/crates/openshell-conformance/README.md +++ b/crates/openshell-conformance/README.md @@ -37,7 +37,10 @@ and `src/platform/windows.rs`. Scenarios select one implementation at compile time while their control flow and assertions remain shared. The platform layer provides a small set of reusable operations instead of mirroring the scenario tree. Implementations may construct temporary policies and commands, but must -exercise the same public behavior. +exercise the same public behavior. Platform fixtures establish only the +prerequisites for a scenario: an unsupported platform capability must fail at +the public CLI operation under test, not at sandbox creation because the +fixture supplied a command or path for the wrong operating system. When adding a scenario, export it from the library and add the appropriate Cargo test wrapper under `tests/suites/conformance/cli`, then add the leaf to the diff --git a/crates/openshell-conformance/src/lib.rs b/crates/openshell-conformance/src/lib.rs index 81b2706b01..0c2a0f0fea 100644 --- a/crates/openshell-conformance/src/lib.rs +++ b/crates/openshell-conformance/src/lib.rs @@ -27,7 +27,8 @@ use self::executor::{CliExecutionError, CliExecutor, ProcessCli}; pub use scenarios::{ FILE_TRANSFER_GIT_FILTERING_SCENARIO, FILE_TRANSFER_PATH_SAFETY_SCENARIO, FILE_TRANSFER_ROUND_TRIP_SCENARIO, MECHANISTIC_PROPOSAL_SCENARIO, - NEW_HOSTNAME_PROPOSAL_SCENARIO, POLICY_LOCAL_SCENARIO, SANDBOX_LIFECYCLE_SCENARIO, + NEW_HOSTNAME_PROPOSAL_SCENARIO, POLICY_LOCAL_SCENARIO, + SANDBOX_LIFECYCLE_CONTROL_PLANE_SCENARIO, SANDBOX_LIFECYCLE_RESTART_PERSISTENCE_SCENARIO, SMOKE_CONTROL_PLANE_SCENARIO, SMOKE_EXEC_SCENARIO, }; @@ -50,7 +51,8 @@ impl Scenario { const SCENARIOS: &[Scenario] = &[ SMOKE_CONTROL_PLANE_SCENARIO, SMOKE_EXEC_SCENARIO, - SANDBOX_LIFECYCLE_SCENARIO, + SANDBOX_LIFECYCLE_CONTROL_PLANE_SCENARIO, + SANDBOX_LIFECYCLE_RESTART_PERSISTENCE_SCENARIO, FILE_TRANSFER_ROUND_TRIP_SCENARIO, FILE_TRANSFER_GIT_FILTERING_SCENARIO, FILE_TRANSFER_PATH_SAFETY_SCENARIO, diff --git a/crates/openshell-conformance/src/platform/mod.rs b/crates/openshell-conformance/src/platform/mod.rs index ef5c429129..71c01c4e59 100644 --- a/crates/openshell-conformance/src/platform/mod.rs +++ b/crates/openshell-conformance/src/platform/mod.rs @@ -12,13 +12,13 @@ mod windows; #[cfg(unix)] pub use unix::{ - increment_then_wait, keep_alive, lifecycle_fixture, marker_command, read_text, smoke_fixture, - write_text, + file_transfer_fixture, increment_then_wait, keep_alive, lifecycle_fixture, marker_command, + policy_fixture, read_text, restricted_network_policy_fixture, smoke_fixture, write_text, }; #[cfg(target_os = "windows")] pub use windows::{ - increment_then_wait, keep_alive, lifecycle_fixture, marker_command, read_text, smoke_fixture, - write_text, + file_transfer_fixture, increment_then_wait, keep_alive, lifecycle_fixture, marker_command, + policy_fixture, read_text, restricted_network_policy_fixture, smoke_fixture, write_text, }; #[cfg(not(any(unix, target_os = "windows")))] diff --git a/crates/openshell-conformance/src/platform/unix.rs b/crates/openshell-conformance/src/platform/unix.rs index e86bf61f69..0a40258636 100644 --- a/crates/openshell-conformance/src/platform/unix.rs +++ b/crates/openshell-conformance/src/platform/unix.rs @@ -1,6 +1,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +use std::fs; + use super::{CommandExpectation, SandboxFixture, SmokeFixture}; // Match the fallible Windows fixture constructor behind the platform boundary. @@ -28,6 +30,35 @@ pub fn lifecycle_fixture() -> Result { )) } +pub fn file_transfer_fixture() -> Result { + lifecycle_fixture() +} + +pub fn policy_fixture() -> Result { + lifecycle_fixture() +} + +pub fn restricted_network_policy_fixture() -> Result { + let temp_dir = tempfile::Builder::new() + .prefix("openshell-conformance-policy-") + .tempdir() + .map_err(|error| format!("create Unix policy fixture directory: {error}"))?; + let policy_path = temp_dir.path().join("policy.yaml"); + fs::write( + &policy_path, + "version: 1\nfilesystem_policy:\n include_workdir: true\n read_only: [/usr, /bin, /lib, /lib64, /proc, /dev/urandom, /app, /etc, /var/log]\n read_write: [/sandbox, /tmp, /dev/null]\nlandlock: { compatibility: best_effort }\nnetwork_policies: {}\n", + ) + .map_err(|error| format!("write Unix conformance policy: {error}"))?; + Ok(SandboxFixture::new( + "/sandbox".to_string(), + vec![ + "--policy".to_string(), + policy_path.to_string_lossy().into_owned(), + ], + Some(temp_dir), + )) +} + pub fn keep_alive(_fixture: &SandboxFixture) -> Vec { vec![ "sh".to_string(), @@ -95,4 +126,12 @@ mod tests { assert_eq!(write_text(&path, "value").expected_stdout(), ""); assert_eq!(read_text(&path, "value").expected_stdout(), "value\n"); } + + #[test] + fn restricted_network_policy_fixture_adds_an_empty_network_policy() { + let fixture = restricted_network_policy_fixture().unwrap(); + let policy = fs::read_to_string(&fixture.create_args()[1]).unwrap(); + assert!(policy.contains("network_policies: {}")); + assert!(policy.contains("read_write: [/sandbox, /tmp, /dev/null]")); + } } diff --git a/crates/openshell-conformance/src/platform/windows.rs b/crates/openshell-conformance/src/platform/windows.rs index 98eb6863a4..257a7a0b3b 100644 --- a/crates/openshell-conformance/src/platform/windows.rs +++ b/crates/openshell-conformance/src/platform/windows.rs @@ -43,6 +43,33 @@ pub fn lifecycle_fixture() -> Result { )) } +pub fn file_transfer_fixture() -> Result { + let (temp_dir, workload_dir, create_args) = writable_fixture("file-transfer")?; + Ok(SandboxFixture::new( + workload_dir.to_string_lossy().into_owned(), + create_args, + Some(temp_dir), + )) +} + +pub fn policy_fixture() -> Result { + let (temp_dir, workload_dir, create_args) = writable_fixture("policy")?; + Ok(SandboxFixture::new( + workload_dir.to_string_lossy().into_owned(), + create_args, + Some(temp_dir), + )) +} + +pub fn restricted_network_policy_fixture() -> Result { + let (temp_dir, workload_dir, create_args) = writable_fixture_with_network("policy", true)?; + Ok(SandboxFixture::new( + workload_dir.to_string_lossy().into_owned(), + create_args, + Some(temp_dir), + )) +} + pub fn keep_alive(fixture: &SandboxFixture) -> Vec { vec![ r"C:\Windows\System32\cmd.exe".to_string(), @@ -91,6 +118,13 @@ pub fn read_text(path: &str, value: &str) -> CommandExpectation { fn writable_fixture( name: &str, +) -> Result<(tempfile::TempDir, std::path::PathBuf, Vec), String> { + writable_fixture_with_network(name, false) +} + +fn writable_fixture_with_network( + name: &str, + empty_network_policy: bool, ) -> Result<(tempfile::TempDir, std::path::PathBuf, Vec), String> { let temp_dir = tempfile::Builder::new() .prefix(&format!("openshell-conformance-{name}-")) @@ -106,8 +140,13 @@ fn writable_fixture( .replace('\\', "/") .replace('"', "\\\""); let policy_path = temp_dir.path().join("policy.yaml"); + let network_policy = if empty_network_policy { + "network_policies: {}\n" + } else { + "" + }; let policy = format!( - "version: 1\nfilesystem_policy:\n include_workdir: false\n read_only: []\n read_write:\n - \"{workload_policy_path}\"\n" + "version: 1\nfilesystem_policy:\n include_workdir: false\n read_only: []\n read_write:\n - \"{workload_policy_path}\"\n{network_policy}" ); fs::write(&policy_path, policy) .map_err(|error| format!("write Windows {name} policy: {error}"))?; @@ -164,4 +203,29 @@ mod tests { .contains("read_write:") ); } + + #[test] + fn policy_fixtures_use_windows_paths_and_explicit_network_restrictions() { + let fixture = policy_fixture().unwrap(); + let policy = fs::read_to_string(&fixture.create_args()[1]).unwrap(); + assert!(!policy.contains("network_policies:")); + assert!(!policy.contains("/sandbox")); + assert_eq!(keep_alive(&fixture)[0], r"C:\Windows\System32\cmd.exe"); + + let restricted_fixture = restricted_network_policy_fixture().unwrap(); + let restricted_policy = fs::read_to_string(&restricted_fixture.create_args()[1]).unwrap(); + assert!(restricted_policy.contains("network_policies: {}")); + } + + #[test] + fn file_transfer_fixture_provides_a_writable_windows_workspace() { + let fixture = file_transfer_fixture().unwrap(); + let remote = fixture.path("payload.txt"); + assert!(std::path::Path::new(&remote).ends_with("payload.txt")); + assert!( + fs::read_to_string(&fixture.create_args()[1]) + .unwrap() + .contains("read_write:") + ); + } } diff --git a/crates/openshell-conformance/src/scenarios/file_transfer.rs b/crates/openshell-conformance/src/scenarios/file_transfer.rs index b5a3444f38..342e0c62c8 100644 --- a/crates/openshell-conformance/src/scenarios/file_transfer.rs +++ b/crates/openshell-conformance/src/scenarios/file_transfer.rs @@ -10,6 +10,7 @@ use std::time::Duration; use tokio::process::Command; +use crate::platform::{self, SandboxFixture}; use crate::{CommandResult, OpenShellRunner, Scenario, ScenarioFuture}; const CREATE_TIMEOUT: Duration = Duration::from_mins(10); @@ -41,37 +42,92 @@ pub const FILE_TRANSFER_PATH_SAFETY_SCENARIO: Scenario = Scenario { fn run_round_trip(runner: &mut OpenShellRunner) -> ScenarioFuture<'_> { Box::pin(async move { - let (sandbox_name, remote_root, local) = prepare_sandbox(runner, "round-trip").await?; - round_trip(runner, &sandbox_name, &remote_root, local.path()).await?; - download_file(runner, &sandbox_name, &remote_root, local.path()).await?; - download_directory(runner, &sandbox_name, &remote_root, local.path()).await?; - delete_sandbox(runner, &sandbox_name).await + let prepared = prepare_sandbox(runner, "round-trip").await?; + round_trip( + runner, + &prepared.sandbox_name, + &prepared.remote_root, + prepared.local.path(), + ) + .await?; + download_file( + runner, + &prepared.sandbox_name, + &prepared.remote_root, + prepared.local.path(), + ) + .await?; + download_directory( + runner, + &prepared.sandbox_name, + &prepared.remote_root, + prepared.local.path(), + ) + .await?; + delete_sandbox(runner, &prepared.sandbox_name).await }) } fn run_git_filtering(runner: &mut OpenShellRunner) -> ScenarioFuture<'_> { Box::pin(async move { - let (sandbox_name, remote_root, local) = prepare_sandbox(runner, "git-filtering").await?; - gitignore_filtering(runner, &sandbox_name, &remote_root, local.path()).await?; - single_file_from_git_repo(runner, &sandbox_name, &remote_root, local.path()).await?; - gitignored_directory_fallback(runner, &sandbox_name, &remote_root, local.path()).await?; - delete_sandbox(runner, &sandbox_name).await + let prepared = prepare_sandbox(runner, "git-filtering").await?; + gitignore_filtering( + runner, + &prepared.sandbox_name, + &prepared.remote_root, + prepared.local.path(), + ) + .await?; + single_file_from_git_repo( + runner, + &prepared.sandbox_name, + &prepared.remote_root, + prepared.local.path(), + ) + .await?; + gitignored_directory_fallback( + runner, + &prepared.sandbox_name, + &prepared.remote_root, + prepared.local.path(), + ) + .await?; + delete_sandbox(runner, &prepared.sandbox_name).await }) } fn run_path_safety(runner: &mut OpenShellRunner) -> ScenarioFuture<'_> { Box::pin(async move { - let (sandbox_name, remote_root, local) = prepare_sandbox(runner, "path-safety").await?; - reject_workspace_escape(runner, &sandbox_name, &remote_root, local.path()).await?; - download_dash_leading_name(runner, &sandbox_name, &remote_root, local.path()).await?; - delete_sandbox(runner, &sandbox_name).await + let prepared = prepare_sandbox(runner, "path-safety").await?; + download_dash_leading_name( + runner, + &prepared.sandbox_name, + &prepared.remote_root, + prepared.local.path(), + ) + .await?; + reject_workspace_escape( + runner, + &prepared.sandbox_name, + &prepared.remote_root, + prepared.local.path(), + ) + .await?; + delete_sandbox(runner, &prepared.sandbox_name).await }) } +struct PreparedSandbox { + sandbox_name: String, + remote_root: String, + local: tempfile::TempDir, + _fixture: SandboxFixture, +} + async fn prepare_sandbox( runner: &mut OpenShellRunner, group: &str, -) -> Result<(String, String, tempfile::TempDir), String> { +) -> Result { let suffix = match group { "round-trip" => "fr", "git-filtering" => "fg", @@ -79,29 +135,39 @@ async fn prepare_sandbox( _ => return Err(format!("unknown file-transfer group {group:?}")), }; let sandbox_name = format!("ct-{}-{suffix}", runner.id()); - let remote_root = format!("/sandbox/file-transfer-{}-{suffix}", runner.id()); + let fixture = platform::file_transfer_fixture()?; + let remote_root = fixture.path(&format!("file-transfer-{}-{suffix}", runner.id())); let local = tempfile::tempdir().map_err(|error| format!("create temporary directory: {error}"))?; runner.track_sandbox(&sandbox_name); + let mut args = vec![ + "sandbox".to_string(), + "create".to_string(), + "--name".to_string(), + sandbox_name.clone(), + "--detach".to_string(), + "--no-tty".to_string(), + ]; + args.extend(fixture.create_args().iter().cloned()); + args.push("--".to_string()); + args.extend(platform::keep_alive(&fixture)); + let args = args.iter().map(String::as_str).collect::>(); let create = runner .step(format!("{group}/create")) .description(format!("sandbox '{sandbox_name}' is created")) .with_timeout(CREATE_TIMEOUT) - .run(&["sandbox", "create", "--name", &sandbox_name, "--detach"]) + .run(&args) .await .map_err(|error| error.to_string())?; create.require_success()?; - exec( - runner, - &sandbox_name, - &format!("{group}/prepare"), - &format!("mkdir -p '{remote_root}'"), - ) - .await?; - - Ok((sandbox_name, remote_root, local)) + Ok(PreparedSandbox { + sandbox_name, + remote_root, + local, + _fixture: fixture, + }) } async fn delete_sandbox(runner: &mut OpenShellRunner, sandbox_name: &str) -> Result<(), String> { @@ -136,7 +202,7 @@ async fn round_trip( .collect::>(); fs::write(source.join("large.bin"), &large).map_err(fs_error("write large.bin"))?; - let remote = format!("{remote_root}/roundtrip"); + let remote = remote_path(remote_root, "roundtrip"); upload(runner, sandbox, "roundtrip/upload", &source, &remote, true).await?; let destination = local_root.join("roundtrip-download"); @@ -172,7 +238,7 @@ async fn round_trip( let single = local_root.join("single.txt"); fs::write(&single, "single-file-payload").map_err(fs_error("write single.txt"))?; - let remote_single = format!("{remote_root}/single.txt"); + let remote_single = remote_path(remote_root, "single.txt"); upload( runner, sandbox, @@ -219,7 +285,7 @@ async fn gitignore_filtering( .map_err(fs_error("write ignored build artifact"))?; git(&repository, &["add", "."]).await?; - let remote = format!("{remote_root}/filtered"); + let remote = remote_path(remote_root, "filtered"); upload( runner, sandbox, @@ -266,7 +332,7 @@ async fn single_file_from_git_repo( fs::write(repository.join("ignored.log"), "ignored") .map_err(fs_error("write repository ignored.log"))?; - let remote = format!("{remote_root}/single-from-repo"); + let remote = remote_path(remote_root, "single-from-repo"); upload( runner, sandbox, @@ -304,12 +370,16 @@ async fn download_file( remote_root: &str, local_root: &Path, ) -> Result<(), String> { - let remote = format!("{remote_root}/download-file.txt"); - exec( + let remote = remote_path(remote_root, "download-file.txt"); + let seed = local_root.join("download-file.txt"); + fs::write(&seed, "greeting-payload").map_err(fs_error("write download-file seed"))?; + upload( runner, sandbox, - "download-file/seed", - &format!("printf greeting-payload > '{remote}'"), + "download-file/upload-seed", + &seed, + &remote, + true, ) .await?; let destination = local_root.join("download-file"); @@ -335,14 +405,20 @@ async fn download_directory( remote_root: &str, local_root: &Path, ) -> Result<(), String> { - let remote = format!("{remote_root}/tree"); - exec( + let remote = remote_path(remote_root, "tree"); + let seed = local_root.join("tree"); + fs::create_dir_all(seed.join("sub")).map_err(fs_error("create directory-download seed"))?; + fs::write(seed.join("root.txt"), "top-level") + .map_err(fs_error("write directory-download root file"))?; + fs::write(seed.join("sub/child.txt"), "nested") + .map_err(fs_error("write directory-download nested file"))?; + upload( runner, sandbox, - "download-directory/seed", - &format!( - "mkdir -p '{remote}/sub' && printf top-level > '{remote}/root.txt' && printf nested > '{remote}/sub/child.txt'" - ), + "download-directory/upload-seed", + &seed, + remote_root, + true, ) .await?; let destination = local_root.join("download-directory"); @@ -373,8 +449,8 @@ async fn reject_workspace_escape( remote_root: &str, local_root: &Path, ) -> Result<(), String> { - let etc_link = format!("{remote_root}/etc-link"); - let passwd_link = format!("{remote_root}/passwd-link"); + let etc_link = remote_path(remote_root, "etc-link"); + let passwd_link = remote_path(remote_root, "passwd-link"); exec( runner, sandbox, @@ -388,7 +464,7 @@ async fn reject_workspace_escape( for (step, source) in [ ("directory-link", etc_link.clone()), ("file-link", passwd_link), - ("linked-component", format!("{etc_link}/passwd")), + ("linked-component", remote_path(&etc_link, "passwd")), ] { let result = download_result( runner, @@ -423,12 +499,16 @@ async fn download_dash_leading_name( remote_root: &str, local_root: &Path, ) -> Result<(), String> { - let remote = format!("{remote_root}/--checkpoint-action=evil"); - exec( + let remote = remote_path(remote_root, "--checkpoint-action=evil"); + let seed = local_root.join("--checkpoint-action=evil"); + fs::write(&seed, "dash-payload").map_err(fs_error("write dash-leading seed"))?; + upload( runner, sandbox, - "dash-leading/seed", - &format!("printf dash-payload > '{remote}'"), + "dash-leading/upload-seed", + &seed, + remote_root, + true, ) .await?; let destination = local_root.join("dash-leading"); @@ -454,12 +534,16 @@ async fn gitignored_directory_fallback( remote_root: &str, local_root: &Path, ) -> Result<(), String> { - let remote_seed = format!("{remote_root}/runs/test.json"); - exec( + let remote_seed = remote_path(remote_root, "runs/test.json"); + let seed = local_root.join("fallback-seed.json"); + fs::write(&seed, "downloaded-payload").map_err(fs_error("write fallback seed"))?; + upload( runner, sandbox, - "gitignored-fallback/seed", - &format!("mkdir -p '{remote_root}/runs' && printf downloaded-payload > '{remote_seed}'"), + "gitignored-fallback/upload-seed", + &seed, + &remote_seed, + true, ) .await?; @@ -480,7 +564,7 @@ async fn gitignored_directory_fallback( .await?; require_exists(&runs.join("test.json"), "downloaded ignored file")?; - let remote = format!("{remote_root}/reuploaded"); + let remote = remote_path(remote_root, "reuploaded"); let upload_result = upload_result( runner, sandbox, @@ -630,6 +714,10 @@ async fn git(repository: &Path, args: &[&str]) -> Result<(), String> { )) } +fn remote_path(root: &str, path: &str) -> String { + Path::new(root).join(path).to_string_lossy().into_owned() +} + fn require_text(path: &Path, expected: &str, label: &str) -> Result<(), String> { let actual = fs::read_to_string(path) .map_err(|error| format!("read {label} at {}: {error}", path.display()))?; diff --git a/crates/openshell-conformance/src/scenarios/mod.rs b/crates/openshell-conformance/src/scenarios/mod.rs index c70c6ee5d0..6ea166bc08 100644 --- a/crates/openshell-conformance/src/scenarios/mod.rs +++ b/crates/openshell-conformance/src/scenarios/mod.rs @@ -15,5 +15,7 @@ pub use file_transfer::{ pub use policy_behavior::{ MECHANISTIC_PROPOSAL_SCENARIO, NEW_HOSTNAME_PROPOSAL_SCENARIO, POLICY_LOCAL_SCENARIO, }; -pub use sandbox_lifecycle::SANDBOX_LIFECYCLE_SCENARIO; +pub use sandbox_lifecycle::{ + SANDBOX_LIFECYCLE_CONTROL_PLANE_SCENARIO, SANDBOX_LIFECYCLE_RESTART_PERSISTENCE_SCENARIO, +}; pub use smoke::{SMOKE_CONTROL_PLANE_SCENARIO, SMOKE_EXEC_SCENARIO}; diff --git a/crates/openshell-conformance/src/scenarios/policy_behavior.rs b/crates/openshell-conformance/src/scenarios/policy_behavior.rs index 772cb44ac0..44c26b1aec 100644 --- a/crates/openshell-conformance/src/scenarios/policy_behavior.rs +++ b/crates/openshell-conformance/src/scenarios/policy_behavior.rs @@ -3,14 +3,13 @@ //! Portable policy-local and mechanistic proposal checks. -use std::io::Write as _; use std::time::{Duration, Instant}; use serde::Deserialize; use serde_json::Value; -use tempfile::NamedTempFile; use tokio::time::sleep; +use crate::platform::{self, SandboxFixture}; use crate::{OpenShellRunner, Scenario, ScenarioFuture}; const CREATE_TIMEOUT: Duration = Duration::from_mins(10); @@ -43,19 +42,11 @@ pub const NEW_HOSTNAME_PROPOSAL_SCENARIO: Scenario = Scenario { run: run_new_hostname_proposal, }; -const EMPTY_NETWORK_POLICY: &[u8] = br"version: 1 -filesystem_policy: - include_workdir: true - read_only: [/usr, /bin, /lib, /lib64, /proc, /dev/urandom, /app, /etc, /var/log] - read_write: [/sandbox, /tmp, /dev/null] -landlock: { compatibility: best_effort } -network_policies: {} -"; - fn run_policy_local(runner: &mut OpenShellRunner) -> ScenarioFuture<'_> { Box::pin(async move { let name = format!("ct-{}-pl", runner.id()); - create_sandbox(runner, &name, None).await?; + let fixture = platform::policy_fixture()?; + create_sandbox(runner, &name, &fixture).await?; enable_proposals(runner, &name).await?; let binary = sandbox_bash_path(runner, &name).await?; @@ -292,16 +283,9 @@ async fn request_policy_local_http( fn run_mechanistic_proposal(runner: &mut OpenShellRunner) -> ScenarioFuture<'_> { Box::pin(async move { - let mut policy = NamedTempFile::new().map_err(|error| error.to_string())?; - policy - .write_all(EMPTY_NETWORK_POLICY) - .map_err(|error| error.to_string())?; - let policy_path = policy - .path() - .to_str() - .ok_or("temporary policy path is not UTF-8")?; + let fixture = platform::restricted_network_policy_fixture()?; let name = format!("ct-{}-mp", runner.id()); - create_sandbox(runner, &name, Some(policy_path)).await?; + create_sandbox(runner, &name, &fixture).await?; enable_proposals(runner, &name).await?; let effective = runner @@ -367,16 +351,9 @@ fn run_mechanistic_proposal(runner: &mut OpenShellRunner) -> ScenarioFuture<'_> fn run_new_hostname_proposal(runner: &mut OpenShellRunner) -> ScenarioFuture<'_> { Box::pin(async move { - let mut policy = NamedTempFile::new().map_err(|error| error.to_string())?; - policy - .write_all(EMPTY_NETWORK_POLICY) - .map_err(|error| error.to_string())?; - let policy_path = policy - .path() - .to_str() - .ok_or("temporary policy path is not UTF-8")?; + let fixture = platform::restricted_network_policy_fixture()?; let name = format!("ct-{}-nh", runner.id()); - create_sandbox(runner, &name, Some(policy_path)).await?; + create_sandbox(runner, &name, &fixture).await?; let binary = sandbox_bash_path(runner, &name).await?; let probe = runner @@ -492,7 +469,7 @@ fn assert_mechanistic_draft(output: &str, expected: &ExpectedDraft<'_>) -> Resul async fn create_sandbox( runner: &mut OpenShellRunner, name: &str, - policy_path: Option<&str>, + fixture: &SandboxFixture, ) -> Result<(), String> { runner.track_sandbox(name); let mut args = vec![ @@ -504,10 +481,10 @@ async fn create_sandbox( "--no-tty", "--no-auto-providers", ]; - if let Some(path) = policy_path { - args.extend(["--policy", path]); - } - args.extend(["--", "sh", "-c", "exec sleep infinity"]); + args.extend(fixture.create_args().iter().map(String::as_str)); + args.push("--"); + let command = platform::keep_alive(fixture); + args.extend(command.iter().map(String::as_str)); let create = runner .step("create") .description(format!("sandbox '{name}' is created")) diff --git a/crates/openshell-conformance/src/scenarios/sandbox_lifecycle.rs b/crates/openshell-conformance/src/scenarios/sandbox_lifecycle.rs index 888e09f9b7..022c75e337 100644 --- a/crates/openshell-conformance/src/scenarios/sandbox_lifecycle.rs +++ b/crates/openshell-conformance/src/scenarios/sandbox_lifecycle.rs @@ -27,18 +27,26 @@ struct SandboxListPage { next_page_token: String, } -/// Certify sandbox stop, start, and deletion lifecycle behavior. -pub const SANDBOX_LIFECYCLE_SCENARIO: Scenario = Scenario { - name: "sandbox/lifecycle", - description: "Verify sandbox stop, start, and deletion lifecycle behavior.", - run: run_sandbox_lifecycle, +/// Certify lifecycle operations that do not require an interactive exec channel. +pub const SANDBOX_LIFECYCLE_CONTROL_PLANE_SCENARIO: Scenario = Scenario { + name: "sandbox/lifecycle/control-plane", + description: "Verify sandbox stop and deletion through control-plane operations.", + run: run_lifecycle_control_plane, }; -fn run_sandbox_lifecycle(runner: &mut OpenShellRunner) -> ScenarioFuture<'_> { - Box::pin(async move { - stop_start_preserves_workspace(runner).await?; - stopped_can_be_deleted(runner).await - }) +/// Certify that restarting a sandbox preserves its workspace and reruns its command. +pub const SANDBOX_LIFECYCLE_RESTART_PERSISTENCE_SCENARIO: Scenario = Scenario { + name: "sandbox/lifecycle/restart-persistence", + description: "Verify sandbox restart preserves workspace state and reruns the workload.", + run: run_lifecycle_restart_persistence, +}; + +fn run_lifecycle_control_plane(runner: &mut OpenShellRunner) -> ScenarioFuture<'_> { + Box::pin(stopped_can_be_deleted(runner)) +} + +fn run_lifecycle_restart_persistence(runner: &mut OpenShellRunner) -> ScenarioFuture<'_> { + Box::pin(stop_start_preserves_workspace(runner)) } async fn stop_start_preserves_workspace(runner: &mut OpenShellRunner) -> Result<(), String> { diff --git a/tests/suites/conformance/README.md b/tests/suites/conformance/README.md index b192e998df..262c5d2471 100644 --- a/tests/suites/conformance/README.md +++ b/tests/suites/conformance/README.md @@ -18,8 +18,12 @@ as the portability boundary. In particular: not require `sandbox exec`. - `smoke/exec` covers `sandbox exec`. Drivers without exec support still run the test and report that capability gap. -- `sandbox/lifecycle`, the `file-transfer/*` leaves, and the `policy/*` leaves - have additional runtime requirements documented in their source modules. +- `sandbox/lifecycle/control-plane` covers stop and stopped deletion without + requiring exec. +- `sandbox/lifecycle/restart-persistence` covers restart and workspace + persistence, including exec-based observations. +- The `file-transfer/*` and `policy/*` leaves have additional runtime + requirements documented in their source modules. The scenario implementations live in the [`openshell-conformance` crate](../../../crates/openshell-conformance/README.md) diff --git a/tests/suites/conformance/cli/tests/lifecycle.rs b/tests/suites/conformance/cli/tests/lifecycle.rs index 1a18adef86..cf3ff1a6d4 100644 --- a/tests/suites/conformance/cli/tests/lifecycle.rs +++ b/tests/suites/conformance/cli/tests/lifecycle.rs @@ -3,20 +3,33 @@ //! Driver-agnostic sandbox lifecycle conformance tests. -use openshell_conformance::{OpenShellRunner, SANDBOX_LIFECYCLE_SCENARIO}; +use openshell_conformance::{ + OpenShellRunner, SANDBOX_LIFECYCLE_CONTROL_PLANE_SCENARIO, + SANDBOX_LIFECYCLE_RESTART_PERSISTENCE_SCENARIO, Scenario, +}; -/// Exercise stop, start, and stopped-deletion behavior through the candidate CLI. +/// Exercise stop and stopped-deletion behavior through the candidate CLI. #[tokio::test] -async fn sandbox_lifecycle() { - let mut runner = OpenShellRunner::from_env(SANDBOX_LIFECYCLE_SCENARIO.name) - .expect("candidate openshell CLI is available"); +async fn control_plane() { + run(&SANDBOX_LIFECYCLE_CONTROL_PLANE_SCENARIO).await; +} + +/// Exercise restart and workspace-persistence behavior through the candidate CLI. +#[tokio::test] +async fn restart_persistence() { + run(&SANDBOX_LIFECYCLE_RESTART_PERSISTENCE_SCENARIO).await; +} + +async fn run(scenario: &'static Scenario) { + let mut runner = + OpenShellRunner::from_env(scenario.name).expect("candidate openshell CLI is available"); let result = async { runner.check_gateway_status().await?; - SANDBOX_LIFECYCLE_SCENARIO.run(&mut runner).await + scenario.run(&mut runner).await } .await; if let Err(error) = runner.finish(result).await { - panic!("sandbox lifecycle conformance scenario failed:\n{error}"); + panic!("{} conformance scenario failed:\n{error}", scenario.name); } } From 352bd1299a432966bbd289160c9f3f5fa8c06714 Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Tue, 29 Sep 2026 10:10:18 +0200 Subject: [PATCH 40/42] fix(cli): support Windows SSH transfers Signed-off-by: Evan Lezar --- architecture/windows.md | 3 + crates/openshell-cli/src/ssh.rs | 32 +++++--- crates/openshell-core/src/forward.rs | 107 ++++++++++++++++++++++++--- 3 files changed, 124 insertions(+), 18 deletions(-) diff --git a/architecture/windows.md b/architecture/windows.md index 35f0ec45a4..97772d5423 100644 --- a/architecture/windows.md +++ b/architecture/windows.md @@ -257,6 +257,9 @@ Windows validation separates source correctness from host capability: lifecycle coverage independent from restart/exec coverage and ensure that file-transfer and policy failures identify the public unsupported operation rather than an invalid Unix workload definition. + The Windows CLI quotes OpenSSH `ProxyCommand` arguments for `CreateProcessW` + and recognizes both Windows and Unix separators in sandbox transfer paths, + allowing file-transfer scenarios to reach the MXC supervisor boundary. The mock verifies public CLI, gateway, driver, lifecycle, and policy wiring; it does not claim Windows OS enforcement. - Mock MXC E2E validates gateway, CLI, driver, lifecycle, and policy wiring but diff --git a/crates/openshell-cli/src/ssh.rs b/crates/openshell-cli/src/ssh.rs index c0937db484..cf70d273b7 100644 --- a/crates/openshell-cli/src/ssh.rs +++ b/crates/openshell-cli/src/ssh.rs @@ -9,8 +9,8 @@ use miette::{IntoDiagnostic, Result, WrapErr}; #[cfg(unix)] use nix::sys::signal::{SaFlags, SigAction, SigHandler, SigSet, Signal, sigaction}; use openshell_core::forward::{ - ForwardSpec, build_proxy_command, format_gateway_url, resolve_ssh_gateway, shell_escape, - validate_ssh_session_response, write_forward_pid, + ForwardSpec, build_proxy_command, format_gateway_url, proxy_command_escape, + resolve_ssh_gateway, shell_escape, validate_ssh_session_response, write_forward_pid, }; use openshell_core::proto::{ CreateSshSessionRequest, GetSandboxRequest, SshRelayTarget, TcpForwardFrame, TcpForwardInit, @@ -854,15 +854,19 @@ async fn ssh_tar_upload( Ok(()) } -/// Split a sandbox path into (`parent_directory`, basename). +/// Split a Unix or Windows sandbox path into (`parent_directory`, basename). /// /// Examples: /// `"/sandbox/.bashrc"` -> `("/sandbox", ".bashrc")` /// `"/sandbox/sub/file"` -> `("/sandbox/sub", "file")` +/// `r"C:\sandbox\file"` -> `(r"C:\sandbox", "file")` /// `"file.txt"` -> `(".", "file.txt")` fn split_sandbox_path(path: &str) -> (&str, &str) { - match path.rfind('/') { - Some(0) => ("/", &path[1..]), + match path.rfind(['/', '\\']) { + Some(0) => (&path[..1], &path[1..]), + Some(pos) if pos == 2 && path.as_bytes().get(1) == Some(&b':') => { + (&path[..=pos], &path[pos + 1..]) + } Some(pos) => (&path[..pos], &path[pos + 1..]), None => (".", path), } @@ -1543,13 +1547,13 @@ fn host_alias(name: &str, workspace: &str) -> String { fn render_ssh_config(gateway: &str, name: &str, workspace: &str) -> String { let exe = std::env::current_exe().expect("failed to resolve OpenShell executable"); - let exe = shell_escape(&exe.to_string_lossy()); + let exe = proxy_command_escape(&exe.to_string_lossy()); let proxy_cmd = format!( "{exe} ssh-proxy --gateway-name {} --name {} --workspace {}", - shell_escape(gateway), - shell_escape(name), - shell_escape(workspace), + proxy_command_escape(gateway), + proxy_command_escape(name), + proxy_command_escape(workspace), ); let host_alias = host_alias(name, workspace); format!( @@ -2008,6 +2012,14 @@ mod tests { ("/sandbox/sub", "file") ); assert_eq!(split_sandbox_path("/a/b/c/d.txt"), ("/a/b/c", "d.txt")); + assert_eq!( + split_sandbox_path(r"C:\sandbox\sub\file.txt"), + (r"C:\sandbox\sub", "file.txt") + ); + assert_eq!( + split_sandbox_path(r"\\server\share\file.txt"), + (r"\\server\share", "file.txt") + ); } #[test] @@ -2524,6 +2536,8 @@ mod tests { fn split_sandbox_path_handles_root_and_bare_names() { // File directly under root assert_eq!(split_sandbox_path("/.bashrc"), ("/", ".bashrc")); + assert_eq!(split_sandbox_path(r"\file.txt"), (r"\", "file.txt")); + assert_eq!(split_sandbox_path(r"C:\file.txt"), (r"C:\", "file.txt")); // No directory component at all assert_eq!(split_sandbox_path("file.txt"), (".", "file.txt")); } diff --git a/crates/openshell-core/src/forward.rs b/crates/openshell-core/src/forward.rs index a17edbdee2..31dfd5ad64 100644 --- a/crates/openshell-core/src/forward.rs +++ b/crates/openshell-core/src/forward.rs @@ -778,7 +778,7 @@ pub fn format_gateway_url(scheme: &str, host: &str, port: u16) -> String { format!("{scheme}://{}:{port}", bracket_ipv6_host(host)) } -/// Shell-escape a value for use inside a `ProxyCommand` string. +/// POSIX shell-escape a value for use in command strings. pub fn shell_escape(value: &str) -> String { if value.is_empty() { return "''".to_string(); @@ -795,11 +795,63 @@ pub fn shell_escape(value: &str) -> String { format!("'{escaped}'") } +/// Quote one argument for the Windows command-line parser used by +/// `CreateProcessW`-launched programs. +/// +/// OpenSSH executes `ProxyCommand` directly on Windows instead of passing it +/// through `/bin/sh`. Always quoting each argument keeps executable paths with +/// spaces intact. Backslashes immediately before a quote or the closing quote +/// are doubled according to the Windows C runtime argument parsing rules. +#[cfg(any(windows, test))] +fn windows_command_line_escape(value: &str) -> String { + let mut escaped = String::with_capacity(value.len() + 2); + escaped.push('"'); + + let mut backslashes = 0; + for character in value.chars() { + if character == '\\' { + backslashes += 1; + continue; + } + + if character == '"' { + for _ in 0..=(backslashes * 2) { + escaped.push('\\'); + } + } else { + for _ in 0..backslashes { + escaped.push('\\'); + } + } + backslashes = 0; + escaped.push(character); + } + + for _ in 0..(backslashes * 2) { + escaped.push('\\'); + } + escaped.push('"'); + escaped +} + +/// Escape one argument for the command-launch behavior OpenSSH uses for +/// `ProxyCommand` on the current host platform. +pub fn proxy_command_escape(value: &str) -> String { + #[cfg(not(windows))] + { + shell_escape(value) + } + #[cfg(windows)] + { + windows_command_line_escape(value) + } +} + /// Build the SSH `ProxyCommand` string used to tunnel to a sandbox. /// -/// Every interpolated argument is shell-escaped so that server-supplied values -/// (gateway URL, sandbox id, token, gateway name) cannot inject shell -/// metacharacters into the command that OpenSSH executes via `/bin/sh -c`. +/// On Unix, every interpolated argument is shell-escaped for the `/bin/sh -c` +/// invocation used by OpenSSH. On Windows, every argument is quoted for the +/// command line that OpenSSH launches through `CreateProcessW`. pub fn build_proxy_command( exe: &str, gateway_url: &str, @@ -809,11 +861,11 @@ pub fn build_proxy_command( ) -> String { format!( "{} ssh-proxy --gateway {} --sandbox-id {} --token {} --gateway-name {}", - shell_escape(exe), - shell_escape(gateway_url), - shell_escape(sandbox_id), - shell_escape(token), - shell_escape(gateway_name), + proxy_command_escape(exe), + proxy_command_escape(gateway_url), + proxy_command_escape(sandbox_id), + proxy_command_escape(token), + proxy_command_escape(gateway_name), ) } @@ -1085,6 +1137,23 @@ mod tests { assert_eq!(shell_escape("it's"), "'it'\"'\"'s'"); } + #[test] + fn windows_command_line_escape_quotes_paths_and_backslashes() { + assert_eq!( + windows_command_line_escape(r"C:\Program Files\OpenShell\openshell.exe"), + r#""C:\Program Files\OpenShell\openshell.exe""# + ); + assert_eq!(windows_command_line_escape(""), r#""""#); + assert_eq!( + windows_command_line_escape(r"C:\directory\"), + r#""C:\directory\\""# + ); + assert_eq!( + windows_command_line_escape(r#"say "hello""#), + r#""say \"hello\"""# + ); + } + fn valid_session_response() -> crate::proto::CreateSshSessionResponse { crate::proto::CreateSshSessionResponse { sandbox_id: "sb-1234".to_string(), @@ -1192,6 +1261,7 @@ mod tests { } #[test] + #[cfg(not(windows))] fn build_proxy_command_escapes_shell_metacharacters() { // Attacker-controlled values in every escapable position. let cmd = build_proxy_command( @@ -1214,6 +1284,7 @@ mod tests { } #[test] + #[cfg(not(windows))] fn build_proxy_command_empty_values_quote_rather_than_vanish() { // An empty value must become `''` rather than disappearing — otherwise // downstream argv splitting would misalign. @@ -1222,6 +1293,7 @@ mod tests { } #[test] + #[cfg(not(windows))] fn build_proxy_command_safe_values_pass_through_unquoted() { let cmd = build_proxy_command( "/usr/local/bin/openshell", @@ -1236,8 +1308,25 @@ mod tests { ); } + #[test] + #[cfg(windows)] + fn build_proxy_command_uses_windows_argument_quoting() { + let cmd = build_proxy_command( + r"C:\Program Files\OpenShell\openshell.exe", + "https://gateway.example:443", + "sb-123", + "tok.456", + "name_1", + ); + assert_eq!( + cmd, + r#""C:\Program Files\OpenShell\openshell.exe" ssh-proxy --gateway "https://gateway.example:443" --sandbox-id "sb-123" --token "tok.456" --gateway-name "name_1""# + ); + } + /// Helper: return the concatenation of characters that appear outside /// POSIX single-quoted runs. Used by the metacharacter assertions above. + #[cfg(not(windows))] fn outside_single_quotes(s: &str) -> String { let mut out = String::new(); let mut inside = false; From 390ea18e81a68b71b68764c63e63e7727ff4d799 Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Tue, 29 Sep 2026 10:48:37 +0200 Subject: [PATCH 41/42] test(cli): honor platform SSH quoting Signed-off-by: Evan Lezar --- crates/openshell-cli/src/ssh.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/openshell-cli/src/ssh.rs b/crates/openshell-cli/src/ssh.rs index cf70d273b7..42a622ea1b 100644 --- a/crates/openshell-cli/src/ssh.rs +++ b/crates/openshell-cli/src/ssh.rs @@ -1829,12 +1829,13 @@ mod tests { #[test] fn render_ssh_config_includes_workspace_in_proxy_command() { let config = render_ssh_config("my-gw", "demo", "beta"); + let escaped_workspace = proxy_command_escape("beta"); assert!( config.contains("Host openshell-demo.beta"), "host alias should be workspace-qualified: {config}" ); assert!( - config.contains("--workspace beta"), + config.contains(&format!("--workspace {escaped_workspace}")), "ProxyCommand should include --workspace: {config}" ); } From 1e3e57f246f15d3b80ac1307950c9b86496ed5cf Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Wed, 30 Sep 2026 16:17:33 +0200 Subject: [PATCH 42/42] ci(windows): run conformance as cargo tests Signed-off-by: Evan Lezar --- .../build-openshell-mxc-windows/SKILL.md | 27 ++++++++-------- .github/workflows/windows-msvc.yml | 6 ++-- architecture/windows.md | 16 +++++----- tasks/scripts/windows-msvc.ps1 | 32 ++++++++++--------- tasks/windows.toml | 4 +-- 5 files changed, 43 insertions(+), 42 deletions(-) diff --git a/.agents/skills/build-openshell-mxc-windows/SKILL.md b/.agents/skills/build-openshell-mxc-windows/SKILL.md index e2bd79eb20..37a1876fef 100644 --- a/.agents/skills/build-openshell-mxc-windows/SKILL.md +++ b/.agents/skills/build-openshell-mxc-windows/SKILL.md @@ -251,12 +251,13 @@ aggregate shipped examples through their mock tasks. Pushes to `main` and manual dispatches run the same lint and test commands in a cache-seed job, followed by a dependent release-binary build and mock-example job. The seed and PR jobs use the same cache namespaces. Merge queues do not run this workflow. -Main/manual seed and build jobs use job-level `continue-on-error: true`; -opt-in PR jobs report failures normally, except that the full mock-MXC -conformance step is advisory on every hosted lane so known capability gaps are -archived without blocking the job. Applying the label alone does not start a -run: re-run all jobs in the current mirror push run, or push a new mirrored -commit. The binaries are not uploaded or published. +Main/manual seed and build jobs use job-level `continue-on-error: true`; opt-in +PR jobs report failures normally. The mock-MXC conformance task keeps completed +Cargo test failures advisory so known capability gaps are archived without +blocking the job, while compilation and gateway setup failures remain fatal. +Applying the label alone does not start a run: re-run all jobs in the current +mirror push run, or push a new mirrored commit. The binaries are not uploaded +or published. The ARM64 check/build steps in this x64-host contract are cross-builds. The wrapper discovers and adds host-native LLVM and Ninja to `PATH`, requires the @@ -296,8 +297,8 @@ crypto dependency builds. | `windows:build:arm64` | Release-builds `openshell-gateway.exe`, `openshell.exe`, and `openshell-supervisor-relay.exe` for ARM64. | | `windows:test:x64` | Runs native x64 workspace tests with `--no-fail-fast`, excluding unsupported Windows packages as top-level workspace targets. | | `windows:test:arm64` | Runs native ARM64 workspace tests with `--no-fail-fast` and the same package exclusions. Rejects non-ARM64 hosts. | -| `windows:test:conformance:x64` | Runs every conformance scenario against the mock MXC gateway on native x64, then exits nonzero if any scenario failed. Hosted CI treats that result as advisory and uploads the complete report. | -| `windows:test:conformance:arm64` | Runs the same complete mock-MXC report natively on ARM64. Rejects non-ARM64 hosts. Hosted CI treats scenario failures as advisory. | +| `windows:test:conformance:x64` | Compiles and runs every CLI conformance Cargo test serially against the mock MXC gateway on native x64. Completed scenario failures are advisory; compilation and gateway setup failures remain fatal. | +| `windows:test:conformance:arm64` | Runs the same complete mock-MXC Cargo test report natively on ARM64. Rejects non-ARM64 hosts and preserves the same failure boundary. | | `windows:test:unsupported:x64` | Re-runs focused `openshell-gateway` tests for unsupported Windows driver behavior. | | `windows:test:unsupported:arm64` | Re-runs the same focused contracts natively on ARM64. Rejects non-ARM64 hosts. | | `windows:test:mxc-real:x64` | Runs the serial, ignored real-`wxc-exec` integration suite natively on x64 through the MSVC wrapper. Rejects non-x64 hosts. | @@ -360,8 +361,6 @@ When reporting `windows:ci`, distinguish these categories: - Passed tests from the full ARM64 workspace test log when run on a native ARM64 host. - The focused unsupported-contract re-run. -- The architecture-matched MXC inference, provider-credential, OCSF audit, and - aggregate mock tasks and their explicit wiring-only limitation. - The complete mock-MXC conformance report, separating passed scenarios from expected capability gaps. Do not report an advisory hosted run as full conformance. @@ -370,6 +369,8 @@ When reporting `windows:ci`, distinguish these categories: leaves should reach upload or download, and policy leaves should reach the public exec or policy boundary. Treat a failure during sandbox creation from a Unix-only fixture as a harness defect, not an MXC capability gap. +- The architecture-matched MXC inference, provider-credential, OCSF audit, and + aggregate mock tasks and their explicit wiring-only limitation. - Explicit Cargo ignored tests, usually ignored doc examples. - Tests hidden by `#[cfg(not(target_os = "windows"))]`; these often appear as `running 0 tests`, not as ignored tests. @@ -387,9 +388,9 @@ Useful log files: | `build-aarch64-pc-windows-msvc-release.log` | ARM64 release build output. | | `test-x86_64-pc-windows-msvc.log` | Full native x64 workspace test output. | | `test-aarch64-pc-windows-msvc.log` | Full native ARM64 workspace test output. | -| `build--conformance.log` | Build output for the CLI, gateway, and standalone conformance runner. | -| `test--conformance.log` | Complete mock-MXC conformance scenario report. | -| `test--conformance.err.log` | Conformance runner standard error. | +| `build--conformance.log` | Build output for the CLI and gateway. | +| `build--conformance-tests.log` | Compile-only output for the CLI conformance Cargo test package. | +| `test--conformance.log` | Complete mock-MXC Cargo test report, including standard output and error. | | `test--conformance-gateway.log` | Mock-MXC gateway standard output. | | `test--conformance-gateway.err.log` | Mock-MXC gateway standard error. | | `test-x86_64-pc-windows-msvc-unsupported-*.log` | Focused unsupported-driver contract output. | diff --git a/.github/workflows/windows-msvc.yml b/.github/workflows/windows-msvc.yml index 1ccd9662c8..ae94ad7282 100644 --- a/.github/workflows/windows-msvc.yml +++ b/.github/workflows/windows-msvc.yml @@ -99,7 +99,6 @@ jobs: - name: Test run: mise run --skip-tools windows:test:${{ matrix.arch }} - name: Full MXC conformance - continue-on-error: true run: mise run --skip-tools windows:test:conformance:${{ matrix.arch }} - name: Upload MXC conformance report if: always() @@ -108,8 +107,8 @@ jobs: name: windows-mxc-conformance-${{ matrix.arch }} path: | build-${{ matrix.rust_target }}-conformance.log + build-${{ matrix.rust_target }}-conformance-tests.log test-${{ matrix.rust_target }}-conformance.log - test-${{ matrix.rust_target }}-conformance.err.log test-${{ matrix.rust_target }}-conformance-gateway.log test-${{ matrix.rust_target }}-conformance-gateway.err.log if-no-files-found: warn @@ -186,7 +185,6 @@ jobs: - name: Test run: mise run --skip-tools windows:test:${{ matrix.arch }} - name: Full MXC conformance - continue-on-error: true run: mise run --skip-tools windows:test:conformance:${{ matrix.arch }} - name: Upload MXC conformance report if: always() @@ -195,8 +193,8 @@ jobs: name: windows-mxc-conformance-${{ matrix.arch }} path: | build-${{ matrix.rust_target }}-conformance.log + build-${{ matrix.rust_target }}-conformance-tests.log test-${{ matrix.rust_target }}-conformance.log - test-${{ matrix.rust_target }}-conformance.err.log test-${{ matrix.rust_target }}-conformance-gateway.log test-${{ matrix.rust_target }}-conformance-gateway.err.log if-no-files-found: warn diff --git a/architecture/windows.md b/architecture/windows.md index 97772d5423..1ecf27c4b3 100644 --- a/architecture/windows.md +++ b/architecture/windows.md @@ -246,14 +246,14 @@ Windows validation separates source correctness from host capability: fail-closed loss handling without requiring MXC. - The architecture-specific `windows:*` tasks check, lint, build, and run workspace and unsupported-driver contract tests for x64 and ARM64. -- Hosted Windows CI runs the complete archive conformance suite against the - in-process MXC mock through the standalone `openshell-conformance` runner. - Every atomic scenario runs so unsupported operations appear as explicit - failures in the archived report instead of being skipped. This report is - advisory while those capability gaps remain: its scenario failures do not - fail the surrounding Windows job, and CI always uploads the runner and - gateway logs. Portable fixtures supply Windows commands, paths, and policies - only to establish each scenario's prerequisites. They keep control-plane +- Hosted Windows CI runs the complete CLI conformance Cargo test package + against the in-process MXC mock. Every atomic scenario runs serially so + unsupported operations appear as explicit failures in the archived report + instead of being skipped. Scenario failures remain advisory while those + capability gaps exist, but compilation, gateway startup, and gateway + selection failures still fail the surrounding Windows job. CI always uploads + the Cargo and gateway logs. Portable fixtures supply Windows commands, paths, + and policies only to establish each scenario's prerequisites. They keep control-plane lifecycle coverage independent from restart/exec coverage and ensure that file-transfer and policy failures identify the public unsupported operation rather than an invalid Unix workload definition. diff --git a/tasks/scripts/windows-msvc.ps1 b/tasks/scripts/windows-msvc.ps1 index f742d4ee65..ef6ea293c5 100644 --- a/tasks/scripts/windows-msvc.ps1 +++ b/tasks/scripts/windows-msvc.ps1 @@ -397,7 +397,8 @@ function Invoke-VsCargo { param( [Parameter(Mandatory = $true)] [string] $RustTarget, [Parameter(Mandatory = $true)] [string] $CargoArgs, - [Parameter(Mandatory = $true)] [string] $LogName + [Parameter(Mandatory = $true)] [string] $LogName, + [switch] $AllowNonZeroExit ) & rustup target add $RustTarget @@ -464,6 +465,10 @@ function Invoke-VsCargo { Get-Content $logPath } if ($exitCode -ne 0) { + if ($AllowNonZeroExit) { + Write-Warning "Command completed with exit code $exitCode. See $logPath" + return + } throw "Command failed with exit code $exitCode. See $logPath" } } finally { @@ -564,13 +569,17 @@ function Invoke-ConformanceTest([string] $RustTarget) { Invoke-VsCargo ` -RustTarget $RustTarget ` - -CargoArgs "cargo build --target $RustTarget --bin openshell --bin openshell-gateway --bin openshell-conformance $Z3GatewayFeatures" ` + -CargoArgs "cargo build --target $RustTarget --bin openshell --bin openshell-gateway $Z3GatewayFeatures" ` -LogName "build-$RustTarget-conformance.log" + Invoke-VsCargo ` + -RustTarget $RustTarget ` + -CargoArgs "cargo test --locked --manifest-path tests/suites/conformance/Cargo.toml --package openshell-test-conformance-cli --target $RustTarget --no-run" ` + -LogName "build-$RustTarget-conformance-tests.log" + $binaryDir = Join-Path $TargetDir "$RustTarget\debug" $gateway = Join-Path $binaryDir "openshell-gateway.exe" $cli = Join-Path $binaryDir "openshell.exe" - $conformance = Join-Path $binaryDir "openshell-conformance.exe" $buildRoot = Join-Path $binaryDir "build" $z3Candidates = @( @@ -593,8 +602,6 @@ function Invoke-ConformanceTest([string] $RustTarget) { $gatewayConfig = Join-Path $testRoot "gateway.toml" $gatewayLog = Join-Path $LogDir "test-$RustTarget-conformance-gateway.log" $gatewayErrorLog = Join-Path $LogDir "test-$RustTarget-conformance-gateway.err.log" - $conformanceLog = Join-Path $LogDir "test-$RustTarget-conformance.log" - $conformanceErrorLog = Join-Path $LogDir "test-$RustTarget-conformance.err.log" New-Item -ItemType Directory -Force -Path $configRoot, $stateRoot | Out-Null @" [openshell] @@ -641,16 +648,11 @@ backend = "process_container" & $cli gateway select windows-conformance if ($LASTEXITCODE -ne 0) { throw "Failed to select the conformance gateway." } - Write-Host "==> $conformance run --openshell-bin $cli" - Write-Host " log: $conformanceLog" - $conformanceProcess = Start-Process -FilePath $conformance ` - -ArgumentList @("run", "--openshell-bin", "`"$cli`"") ` - -PassThru -Wait -NoNewWindow ` - -RedirectStandardOutput $conformanceLog -RedirectStandardError $conformanceErrorLog - Get-Content -LiteralPath $conformanceLog, $conformanceErrorLog -ErrorAction SilentlyContinue - if ($conformanceProcess.ExitCode -ne 0) { - throw "Conformance command failed with exit code $($conformanceProcess.ExitCode). See $conformanceLog" - } + Invoke-VsCargo ` + -RustTarget $RustTarget ` + -CargoArgs "cargo test --locked --manifest-path tests/suites/conformance/Cargo.toml --package openshell-test-conformance-cli --target $RustTarget --no-fail-fast -- --test-threads=1 --nocapture" ` + -LogName "test-$RustTarget-conformance.log" ` + -AllowNonZeroExit } catch { Get-Content -LiteralPath $gatewayLog, $gatewayErrorLog -ErrorAction SilentlyContinue throw diff --git a/tasks/windows.toml b/tasks/windows.toml index b2adca7769..3296b6ee92 100644 --- a/tasks/windows.toml +++ b/tasks/windows.toml @@ -56,12 +56,12 @@ run = "echo 'windows:* tasks require a Windows MSVC host' && exit 1" run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 test-precommit aarch64-pc-windows-msvc" ["windows:test:conformance:x64"] -description = "Report all openshell-conformance scenarios against a mock MXC gateway on native Windows x64" +description = "Run all CLI conformance Cargo tests against a mock MXC gateway on native Windows x64" run = "echo 'windows:* tasks require a Windows MSVC host' && exit 1" run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 test-conformance x86_64-pc-windows-msvc" ["windows:test:conformance:arm64"] -description = "Report all openshell-conformance scenarios against a mock MXC gateway on native Windows ARM64" +description = "Run all CLI conformance Cargo tests against a mock MXC gateway on native Windows ARM64" run = "echo 'windows:* tasks require a Windows MSVC host' && exit 1" run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 test-conformance aarch64-pc-windows-msvc"