mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-10-02 07:34:45 +08:00
refactor(mxc): adopt RFC 0012 sandbox runtime
Signed-off-by: Drew Newberry <anewberry@nvidia.com>
This commit is contained in:
@@ -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 native Windows MSVC and MXC runtime lane for x64 and ARM64. Use when working on Windows compilation, `windows:*` mise tasks, the MXC supervisor/sandbox pairing, unsupported Windows compute-driver contracts, or Windows build reports. This skill does not implement Docker, Kubernetes, Podman, VM, MSI, or service support on Windows.
|
||||
metadata:
|
||||
internal: true
|
||||
---
|
||||
@@ -12,12 +12,13 @@ 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`
|
||||
- `openshell-supervisor-relay.exe` (Windows-only MXC workload relay)
|
||||
- `openshell-supervisor.exe` (host RFC 0012 isolation backend)
|
||||
- `openshell-sandbox.exe` (MXC ProcessContainer boundary)
|
||||
|
||||
It intentionally does not make Windows a Docker, Kubernetes, Podman, or VM
|
||||
runtime host.
|
||||
@@ -46,8 +47,8 @@ In scope:
|
||||
- Refreshing a local checkout to the latest upstream GitHub `main`.
|
||||
- Maintaining `tasks/windows.toml` and `tasks/scripts/windows-msvc.ps1`.
|
||||
- Running x64 and ARM64 MSVC checks.
|
||||
- Building x64 and ARM64 release binaries for `openshell-gateway` and
|
||||
`openshell`.
|
||||
- Building x64 and ARM64 release binaries for `openshell-gateway`, `openshell`,
|
||||
`openshell-supervisor`, and `openshell-sandbox`.
|
||||
- Running workspace tests on a native x64 or ARM64 host.
|
||||
- Running focused unsupported-driver contract tests.
|
||||
- Reporting test counts, skipped/gated areas, warnings, artifacts, and logs.
|
||||
@@ -60,12 +61,9 @@ Out of scope:
|
||||
- Kubernetes support on Windows.
|
||||
- Podman, Podman machine, or Podman Desktop support on Windows.
|
||||
- VM, Hyper-V, WSL, libkrun, or VM-backed sandbox execution on Windows.
|
||||
- New MXC compute driver crate.
|
||||
- OpenShell to MXC policy translation.
|
||||
- Windows named-pipe driver IPC.
|
||||
- Windows Credential Manager or DPAPI integration.
|
||||
- MSI, WinGet, Windows service registration, or installer work.
|
||||
- Windows supervisor runtime port.
|
||||
|
||||
## Hard Rules
|
||||
|
||||
@@ -253,8 +251,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`, `openshell-supervisor.exe`, and `openshell-sandbox.exe` for x64. |
|
||||
| `windows:build:arm64` | Release-builds the same four binaries 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. |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Reference: Windows MSVC maintenance lane
|
||||
|
||||
Companion to [SKILL.md](SKILL.md). Use this file for quick lookup while
|
||||
maintaining the existing build-only Windows MSVC lane.
|
||||
maintaining the native Windows MSVC and MXC runtime lane.
|
||||
|
||||
## Lane Files
|
||||
|
||||
@@ -76,7 +76,7 @@ Ninja to `PATH`, while the crypto crates select `clang-cl`. Use a short
|
||||
|
||||
## Unsupported Driver Rules
|
||||
|
||||
Windows is a build target only. These runtimes remain unsupported:
|
||||
These Windows runtimes remain unsupported:
|
||||
|
||||
- Docker
|
||||
- Kubernetes
|
||||
@@ -111,18 +111,14 @@ top-level workspace targets for check/test:
|
||||
--exclude openshell-driver-podman
|
||||
--exclude openshell-driver-vault
|
||||
--exclude openshell-driver-vm
|
||||
--exclude openshell-sandbox
|
||||
--exclude openshell-supervisor
|
||||
--exclude openshell-supervisor-process
|
||||
--exclude openshell-vfio
|
||||
```
|
||||
|
||||
The gateway keeps platform configuration and unsupported-operation contracts
|
||||
without depending on the Docker, Kubernetes, Podman, sandbox runtime,
|
||||
standalone supervisor, supervisor process runtime, VM, or VFIO crates. The MXC
|
||||
driver does depend on the cross-platform supervisor network library for its host
|
||||
egress proxy. The Kubernetes Secrets and Vault libraries still compile as
|
||||
gateway dependencies; only their standalone Unix-socket binaries and
|
||||
without depending on the Docker, Kubernetes, Podman, VM, or VFIO runtime crates.
|
||||
The MXC runtime compiles the supervisor, supervisor-process library, and sandbox
|
||||
boundary on Windows. The Kubernetes Secrets and Vault libraries still compile
|
||||
as gateway dependencies; only their standalone Unix-socket binaries and
|
||||
package-level tests are excluded as top-level targets.
|
||||
|
||||
## Common Errors
|
||||
|
||||
@@ -63,8 +63,7 @@ These pipelines connect skills into end-to-end workflows. Individual skill files
|
||||
| `crates/openshell-driver-docker/` | Docker compute driver | In-process `ComputeDriver` backend for local Docker sandbox containers |
|
||||
| `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-driver-mxc/` | Microsoft MXC compute driver | In-process Windows ProcessContainer backend that pairs a host isolation-backend supervisor with `openshell-sandbox` inside MXC |
|
||||
| `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 |
|
||||
|
||||
+1
-1
@@ -105,7 +105,7 @@ Contributor and maintainer skills live in `.agents/skills/`. They are marked int
|
||||
| Triage | `triage-issue` | Assess, classify, and route community-filed issues |
|
||||
| Platform | `helm-dev-environment` | Start and manage the local Kubernetes development environment |
|
||||
| Platform | `tui-development` | Development guide for the ratatui-based terminal UI |
|
||||
| Platform | `build-openshell-mxc-windows` | Maintain and validate the build-only x64 and ARM64 Windows MSVC lane |
|
||||
| Platform | `build-openshell-mxc-windows` | Maintain and validate the x64 and ARM64 Windows MSVC and MXC runtime lane |
|
||||
| Documentation | `update-docs-from-commits` | Scan recent commits and draft doc updates for user-facing changes |
|
||||
| Maintenance | `sync-agent-infra` | Detect and fix drift across agent-first infrastructure files |
|
||||
| Reference | `sbom` | Generate SBOMs and resolve dependency licenses |
|
||||
|
||||
Generated
+4
-14
@@ -4198,9 +4198,10 @@ dependencies = [
|
||||
"futures",
|
||||
"noyalib",
|
||||
"openshell-core",
|
||||
"openshell-isolation-interface",
|
||||
"openshell-ocsf",
|
||||
"openshell-policy",
|
||||
"openshell-supervisor-network",
|
||||
"openshell-sandbox-backend",
|
||||
"rand 0.9.4",
|
||||
"rustls",
|
||||
"serde",
|
||||
@@ -4209,7 +4210,6 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tokio-tungstenite 0.26.2",
|
||||
"tonic",
|
||||
"tracing",
|
||||
"uuid",
|
||||
@@ -4537,6 +4537,8 @@ dependencies = [
|
||||
"tonic",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"url",
|
||||
"windows",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4848,18 +4850,6 @@ 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"
|
||||
|
||||
+1
-1
@@ -58,7 +58,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_Security", "Win32_System_Diagnostics_Etw", "Win32_System_Threading", "Win32_System_Time"] }
|
||||
anyhow = "1"
|
||||
|
||||
# Logging/Tracing
|
||||
|
||||
@@ -167,19 +167,22 @@ 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.
|
||||
On Windows, the MXC driver creates the same RFC 0012 runtime pairing as the VM
|
||||
backend: `openshell-supervisor --role=isolation-backend` runs on the trusted
|
||||
host and `openshell-sandbox` runs inside the ProcessContainer. Their
|
||||
generation-scoped TLS transport and sandbox JWT carry lifecycle, exec,
|
||||
forwarding, provider refresh, and retained process I/O. The driver only
|
||||
provisions and monitors the pair; it does not define a second control or relay
|
||||
protocol.
|
||||
|
||||
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.
|
||||
MXC denies direct Internet access and allows only the loopback route required
|
||||
for the authenticated Sandbox Protocol and explicit proxy. Proxy-aware
|
||||
workloads receive a per-generation authenticated proxy URL and public CA trust
|
||||
material. The host supervisor applies OpenShell network policy and provider
|
||||
injection. The loopback exception does not isolate unrelated host services, and
|
||||
the current Windows explicit-proxy path attributes descendant traffic to the
|
||||
admitted main workload binary. See the MXC driver README for these enforcement
|
||||
limits.
|
||||
|
||||
The gateway persists lifecycle intent before mutating compute:
|
||||
|
||||
|
||||
@@ -11,8 +11,9 @@ driver. It does not make Windows a Docker, Kubernetes, Podman, or VM runtime hos
|
||||
- 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.
|
||||
canonical sandbox policy through `DriverSandboxSpec`, then starts the standard
|
||||
host supervisor and in-ProcessContainer sandbox boundary. Supervisor session
|
||||
readiness remains authoritative.
|
||||
- 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
|
||||
@@ -49,9 +50,9 @@ 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.
|
||||
|
||||
The standalone sandbox and supervisor runtimes are Unix-only and are excluded
|
||||
as top-level Windows workspace targets. The MXC driver links only the
|
||||
cross-platform supervisor network library needed by its host egress proxy.
|
||||
The sandbox, supervisor, and supervisor-process crates compile on Windows and
|
||||
form the RFC 0012 MXC runtime pair. The release lane builds both runtime
|
||||
binaries with the gateway and CLI.
|
||||
|
||||
| Driver | Windows build behavior | Runtime behavior |
|
||||
|---|---|---|
|
||||
@@ -59,7 +60,7 @@ cross-platform supervisor network library needed by its host egress proxy.
|
||||
| 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. |
|
||||
| MXC | Driver, supervisor, and sandbox compile in the native Windows lane. | `process_container` supplies the default-deny outer fence and authenticated RFC 0012 runtime pair; `isolation_session` is rejected. |
|
||||
|
||||
This keeps Windows behavior explicit without carrying runtime dependencies or
|
||||
creating misleading Windows driver artifacts.
|
||||
@@ -88,8 +89,8 @@ Windows validation is exposed through `tasks/windows.toml`:
|
||||
| `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:build:x64` | Build release x64 `openshell-gateway.exe`, `openshell.exe`, `openshell-supervisor.exe`, and `openshell-sandbox.exe`. |
|
||||
| `windows:build:arm64` | Build the same four release binaries for ARM64. |
|
||||
| `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. |
|
||||
@@ -199,7 +200,7 @@ native rather than emulated coverage.
|
||||
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 and ARM64 release build status for `openshell-gateway.exe`, `openshell.exe`, `openshell-supervisor.exe`, and `openshell-sandbox.exe`.
|
||||
- x64 test summary.
|
||||
- Native ARM64 test summary when validation runs on an ARM64 host.
|
||||
- Focused unsupported-driver contract test status.
|
||||
|
||||
@@ -107,6 +107,7 @@ impl DockerBoundarySpec {
|
||||
resource_claim_files: BTreeMap::new(),
|
||||
workload_identity: self.workload_identity.clone(),
|
||||
outer_fence: outer_fence.clone(),
|
||||
direct_proxy_url: None,
|
||||
child_env: self.child_env,
|
||||
},
|
||||
runtime_descriptor: SandboxRuntimeDescriptor {
|
||||
@@ -119,6 +120,7 @@ impl DockerBoundarySpec {
|
||||
},
|
||||
tls: self.supervisor_tls,
|
||||
host_gateway_ip: self.host_gateway_ip,
|
||||
direct_proxy: None,
|
||||
resource_claims,
|
||||
outer_fence,
|
||||
},
|
||||
|
||||
@@ -265,6 +265,7 @@ impl KubernetesSandboxRuntimeBoundarySpec {
|
||||
)]),
|
||||
workload_identity: self.workload_identity.clone(),
|
||||
outer_fence: outer_fence.clone(),
|
||||
direct_proxy_url: None,
|
||||
child_env: self.child_env,
|
||||
},
|
||||
runtime_descriptor: SandboxRuntimeDescriptor {
|
||||
@@ -278,6 +279,7 @@ impl KubernetesSandboxRuntimeBoundarySpec {
|
||||
},
|
||||
tls: self.supervisor_tls,
|
||||
host_gateway_ip: self.host_gateway_ip,
|
||||
direct_proxy: None,
|
||||
resource_claims,
|
||||
outer_fence,
|
||||
},
|
||||
|
||||
@@ -15,6 +15,8 @@ name = "openshell_driver_mxc"
|
||||
|
||||
[dependencies]
|
||||
openshell-core = { path = "../openshell-core", default-features = false }
|
||||
openshell-isolation-interface = { path = "../openshell-isolation-interface" }
|
||||
openshell-sandbox-backend = { path = "../openshell-sandbox-backend" }
|
||||
# OCSF builders + emit target used by the Windows ETW audit consumer. OS-agnostic
|
||||
# crate (no windows deps), so safe to depend on from all targets; only the
|
||||
# windows-gated `etw_consumer` module actually uses it.
|
||||
@@ -26,26 +28,18 @@ tokio-stream = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
|
||||
# ETW/TDH real-time consumer and host CONNECT proxy integration.
|
||||
# ETW/TDH real-time consumer.
|
||||
[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
|
||||
|
||||
@@ -1,222 +1,114 @@
|
||||
# openshell-driver-mxc
|
||||
|
||||
OpenShell compute driver backed by **Microsoft MXC** (`wxc-exec`) on Windows.
|
||||
The Windows-only MXC compute driver runs each workload in a Microsoft MXC
|
||||
ProcessContainer while preserving OpenShell's standard RFC 0012 runtime split:
|
||||
|
||||
## Design
|
||||
```text
|
||||
gateway / MXC driver
|
||||
|
|
||||
| gateway authentication and policy
|
||||
v
|
||||
openshell-supervisor --role=isolation-backend (host)
|
||||
|
|
||||
| generation-scoped TLS + sandbox JWT
|
||||
v
|
||||
openshell-sandbox (ProcessContainer)
|
||||
|
|
||||
v
|
||||
workload
|
||||
```
|
||||
|
||||
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 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 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.
|
||||
The driver provisions and monitors the two runtime processes. It does not own a
|
||||
second forwarding protocol. Process lifecycle, exec, provider refresh, dynamic
|
||||
forwarding, retained output, and network policy flow through the ordinary
|
||||
supervisor session and authenticated Sandbox Protocol.
|
||||
|
||||
## Capability Matrix
|
||||
## Enforcement boundaries
|
||||
|
||||
| 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. |
|
||||
- MXC supplies the default-deny filesystem fence, AppContainer token, UI
|
||||
policy, and loopback-only network fence.
|
||||
- `openshell-sandbox` consumes its bootstrap files before launching untrusted
|
||||
code, authenticates the paired supervisor, and terminates workloads when an
|
||||
authenticated supervisor cannot recover within the reconnect deadline.
|
||||
- The host supervisor owns an authenticated per-generation explicit proxy.
|
||||
Missing or cross-sandbox credentials receive HTTP 407 before policy
|
||||
evaluation. MXC denies direct Internet egress.
|
||||
- The current explicit-proxy path attributes traffic to the admitted main
|
||||
workload binary. It does not distinguish descendant processes. MXC process
|
||||
policy must therefore prevent an untrusted allowed child binary from
|
||||
inheriting broader per-binary network rights.
|
||||
- The loopback fence permits `127.0.0.1/32`; it does not isolate unrelated host
|
||||
services bound to that address. Treat the gateway host as trusted.
|
||||
- Host supervisor tokens and descriptors live beneath an owner-only Windows
|
||||
DACL. Boundary bootstrap secrets live in the ProcessContainer staging path
|
||||
and are deleted before workload launch.
|
||||
|
||||
The filesystem enforcement proof has two paths:
|
||||
## Configuration
|
||||
|
||||
- A write to a path granted by the sandbox policy succeeds.
|
||||
- A `process_container` write outside the sandbox policy fails with Windows access denied, and the driver reports the failed workload.
|
||||
|
||||
## Configuration (`[openshell.drivers.mxc]`)
|
||||
|
||||
Gateway configuration contains only host runtime settings:
|
||||
The packaged `openshell-supervisor.exe` and `openshell-sandbox.exe` default to
|
||||
siblings of `openshell-gateway.exe`. Override their paths for development
|
||||
builds.
|
||||
|
||||
```toml
|
||||
[openshell.drivers.mxc]
|
||||
wxc_exec_path = "C:\\path\\to\\wxc-exec.exe"
|
||||
# Default: process_container. isolation_session is grant-only and opt-in.
|
||||
wxc_exec_path = "C:\\mxc-kit\\bin\\wxc-exec.exe"
|
||||
supervisor_binary_path = "C:\\OpenShell\\openshell-supervisor.exe"
|
||||
sandbox_binary_path = "C:\\OpenShell\\openshell-sandbox.exe"
|
||||
# Defaults to %LOCALAPPDATA%\OpenShell\mxc.
|
||||
state_dir = "C:\\Users\\operator\\AppData\\Local\\OpenShell\\mxc"
|
||||
# Empty uses the gateway's loopback listener and TLS mode.
|
||||
grpc_endpoint = ""
|
||||
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).
|
||||
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_allow_local_network = true
|
||||
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
|
||||
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.
|
||||
Only `process_container` supports this architecture. `isolation_session` is
|
||||
rejected during sandbox validation.
|
||||
|
||||
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:
|
||||
Supply the workload command and working directory per sandbox:
|
||||
|
||||
```powershell
|
||||
$config = '{"mxc":{"command":["cmd","/c","echo hello > C:\\\\work\\\\demo\\\\hello.txt"],"cwd":"C:\\\\work\\\\demo"}}'
|
||||
openshell sandbox create --name mxc-demo --policy demo.yaml `
|
||||
--driver-config-json $config --env MODE=demo --no-tty
|
||||
$config = '{"mxc":{"command":["C:\\Windows\\System32\\cmd.exe","/d","/c","echo hello"],"cwd":"C:\\work"}}'
|
||||
openshell sandbox create --name mxc-demo --policy policy.yaml `
|
||||
--driver-config-json $config --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 `<cwd>/.openshell-proxy/<sandbox-id>/`, and injects `HTTP_PROXY`/`HTTPS_PROXY` while clearing `NO_PROXY` so inherited bypass rules cannot skip policy enforcement.
|
||||
The command is required. The working directory is required because it contains
|
||||
the generation-scoped bootstrap staging directory. Environment belongs in
|
||||
`--env` or `--env-from`, not gateway configuration.
|
||||
|
||||
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`).
|
||||
When gateway TLS is enabled, configure the gateway-owned `guest_tls_ca`,
|
||||
`guest_tls_cert`, and `guest_tls_key` bundle. The gateway injects those paths
|
||||
into the host supervisor; driver-owned copies of these fields are rejected.
|
||||
|
||||
`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.
|
||||
## Capabilities
|
||||
|
||||
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
|
||||
per-start discriminator. Starting another gateway never stops an existing
|
||||
gateway's capture. Graceful shutdown stops the session by its owned handle. A
|
||||
force-killed gateway can leave a stale session; the audit example removes only
|
||||
matching sessions whose encoded owner process is no longer running.
|
||||
| Capability | Status |
|
||||
|---|---|
|
||||
| Filesystem and UI policy | Mapped to the MXC ProcessContainer fence |
|
||||
| Network policy and provider credentials | Standard host supervisor proxy; proxy-aware workloads only |
|
||||
| Exec, signals, retained output | Authenticated Sandbox Protocol; ConPTY resize is not yet supported |
|
||||
| Dynamic forwarding | Standard supervisor `ForwardTcp` path through sandbox loopback connect |
|
||||
| ETW/OCSF audit | Optional Windows Sandboxing ETW consumer |
|
||||
| Gateway restart recovery | Not yet supported; live MXC generations remain in-memory |
|
||||
|
||||
The gateway-local OCSF JSONL sink is available only for the Windows/MXC path
|
||||
and is opt-in. Set `OPENSHELL_OCSF_JSON=1` to enable it and optionally set
|
||||
`OPENSHELL_OCSF_LOG_DIR` to override its `%PROGRAMDATA%\OpenShell\logs` default.
|
||||
Other gateway deployments do not initialize this local file sink.
|
||||
## Validation
|
||||
|
||||
The ETW callback uses a non-blocking queue capped at 4,096 records and 16 MiB
|
||||
of copied event data. Records that exceed either limit are dropped instead of
|
||||
blocking the ETW pump or growing gateway memory. The gateway emits an immediate
|
||||
warning identifying the audit coverage gap and rate-limits follow-up warnings
|
||||
to once every 30 seconds while overload continues.
|
||||
|
||||
Audit attribution bootstraps only when the driver-owned `wxc-exec` PID and its
|
||||
kernel process start key both match the values attached to the ETW record;
|
||||
command text is never an ownership key. This generation key prevents a recycled
|
||||
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.
|
||||
|
||||
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
|
||||
- `IsoSessionApp.dll` present and registered
|
||||
- `wxc-exec.exe` built with `--features isolation_session`
|
||||
- Any enforced App Control policy allows both `openshell-gateway.exe` and
|
||||
`openshell.exe`. Diagnose executable blocks with event 3077 in the
|
||||
`Microsoft-Windows-CodeIntegrity/Operational` log.
|
||||
|
||||
For off-box smoke tests against the in-process mock shim (no `wxc-exec`,
|
||||
no isolation session needed), set `OPENSHELL_MXC_MOCK_WXC=1`.
|
||||
|
||||
## Policy mapping
|
||||
|
||||
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. 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,
|
||||
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
|
||||
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 `<cwd>/.openshell-proxy/<sandbox-id>` 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 unless the gateway explicitly opts into another environment mode.
|
||||
|
||||
When governed egress is disabled, any network rule fails closed during sandbox creation.
|
||||
|
||||
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.
|
||||
|
||||
## Provider credential example
|
||||
|
||||
[`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.
|
||||
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
| 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 |
|
||||
|
||||
**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,
|
||||
and a `verdicts` object). Run it before the real-MXC lane to understand what
|
||||
will PASS vs SKIP on a given host:
|
||||
|
||||
The probe uses a unique, user-owned Windows temp directory for every run.
|
||||
MXC treats config paths literally (it does not expand `%TEMP%`), and the
|
||||
per-run directory keeps AppContainer+DACL fallback mutations narrowly scoped.
|
||||
Run the Windows build lane on a native Windows MSVC host:
|
||||
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass `
|
||||
-File crates/openshell-driver-mxc/examples/probe-mxc-host.ps1
|
||||
mise run windows:check:x64
|
||||
mise run windows:lint:x64
|
||||
mise run windows:build:x64
|
||||
mise run windows:test:mxc-real:x64
|
||||
```
|
||||
|
||||
**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. 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** — 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
|
||||
The real-MXC tests are skip-safe when `wxc-exec.exe` or the required host
|
||||
capabilities are absent. A complete integration run still requires a qualified
|
||||
Windows MXC host; cross-compilation validates code shape but cannot validate
|
||||
ProcessContainer networking or DACL behavior.
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
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-<timestamp>.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.
|
||||
@@ -1,49 +0,0 @@
|
||||
# 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"
|
||||
@@ -1,42 +0,0 @@
|
||||
# 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: []
|
||||
@@ -12,18 +12,18 @@
|
||||
# smoke tests (set OPENSHELL_MXC_MOCK_WXC=1 instead).
|
||||
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.
|
||||
# The RFC 0012 MXC runtime requires ProcessContainer.
|
||||
backend = "process_container"
|
||||
|
||||
# The packaged runtime binaries default to siblings of openshell-gateway.exe.
|
||||
# Override these paths when running from a different development layout.
|
||||
# supervisor_binary_path = "C:\\path\\to\\openshell-supervisor.exe"
|
||||
# sandbox_binary_path = "C:\\path\\to\\openshell-sandbox.exe"
|
||||
|
||||
# process_container only: request a Less-Privileged AppContainer.
|
||||
# pc_least_privilege = false
|
||||
# process_container only: AppContainer capabilities to grant.
|
||||
# pc_capabilities = []
|
||||
|
||||
# isolation_session only. Never use "small" (known OS bug).
|
||||
default_configuration_id = "composable"
|
||||
|
||||
# Enable --debug on wxc-exec invocations.
|
||||
debug = false
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
# 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"
|
||||
@@ -9,8 +9,8 @@
|
||||
# Detection Finding [2004] — written to a durable JSONL log, just like the Linux
|
||||
# OCSF pipeline.
|
||||
#
|
||||
# run-ocsf-audit.ps1 patches wxc_exec_path, backend, etw_audit and the
|
||||
# egress-proxy switch into a disposable copy of this file. Workload command and
|
||||
# run-ocsf-audit.ps1 patches wxc_exec_path, backend, and etw_audit into a
|
||||
# disposable copy of this file. Workload command and
|
||||
# cwd are sandbox-scoped and passed separately through --driver-config-json.
|
||||
|
||||
[openshell.drivers.mxc]
|
||||
@@ -22,19 +22,10 @@ wxc_exec_path = "C:\\mxc-kit\\bin\\wxc-exec.exe"
|
||||
# captures. (isolation_session is "dark" — it emits no provider events.)
|
||||
backend = "process_container"
|
||||
|
||||
default_configuration_id = "composable"
|
||||
|
||||
debug = false
|
||||
|
||||
# Turn ON the Plane-A ETW -> OCSF audit consumer. This is the core of the example.
|
||||
etw_audit = true
|
||||
|
||||
# Per-sandbox governed egress. Enabling this makes the driver start a host CONNECT
|
||||
# proxy and hand MXC a `network.proxy` redirect, which is what makes MXC emit the
|
||||
# SandboxProxyConfigured event — the config event mapped to OCSF CONFIG [5019]
|
||||
# that completes full event coverage. Requires backend = process_container and a
|
||||
# loopback (127.0.0.1) seed address; the driver allocates a unique ephemeral port
|
||||
# per sandbox from this seed. Run-ocsf-audit.ps1 disables this when passed
|
||||
# -NoProxy.
|
||||
egress_proxy = true
|
||||
egress_proxy_addr = "127.0.0.1:18080"
|
||||
# The driver always provisions the authenticated host supervisor proxy and
|
||||
# generation-scoped Sandbox Protocol transport.
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
# 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
|
||||
@@ -1,44 +0,0 @@
|
||||
# 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
|
||||
@@ -1,33 +0,0 @@
|
||||
# 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
|
||||
@@ -1,36 +0,0 @@
|
||||
# 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"
|
||||
@@ -1,175 +0,0 @@
|
||||
# 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, "<credential-placeholder>")
|
||||
}
|
||||
$safe = [regex]::Replace(
|
||||
$safe,
|
||||
'(?i)(authorization:\s*bearer\s+)\S+',
|
||||
'$1<redacted>'
|
||||
)
|
||||
$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
|
||||
@@ -1,18 +0,0 @@
|
||||
# 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
|
||||
@@ -1,622 +0,0 @@
|
||||
// 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 <port>: start the command in agent-cmd.txt as a child
|
||||
// process, wait for it to bind <port>, 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::<u16>().ok())
|
||||
.expect("Usage: mxc-ws-agent proxy-for <port>");
|
||||
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<String> {
|
||||
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<String> {
|
||||
None
|
||||
}
|
||||
|
||||
// ── Signal / relay address helpers ────────────────────────────────────────────
|
||||
|
||||
fn exe_dir() -> anyhow::Result<std::path::PathBuf> {
|
||||
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<std::path::PathBuf> {
|
||||
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<String> {
|
||||
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=<N>").
|
||||
let local_ws_port = read_host_file(&server_marker)
|
||||
.and_then(|s| s.strip_prefix("port=").and_then(|n| n.parse::<u16>().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<String> = 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(())
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
# 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
|
||||
@@ -7,9 +7,9 @@
|
||||
# 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.
|
||||
# No network policy is needed for this filesystem-focused audit scenario. The
|
||||
# RFC 0012 MXC runtime always provisions its authenticated control and proxy
|
||||
# listeners independently of workload network authorization.
|
||||
version: 1
|
||||
|
||||
filesystem_policy:
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
// 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 install>/openclaw.mjs)
|
||||
// NEMOCLAW_MXC_CAPTURE_LOG absolute path to append captured output to
|
||||
//
|
||||
// Usage: node openclaw-capture.mjs <args...>
|
||||
// Equivalent to: node <NEMOCLAW_MXC_CAPTURE_ENTRY> <args...>, 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);
|
||||
@@ -23,9 +23,6 @@
|
||||
# powershell -NoProfile -ExecutionPolicy Bypass -File .\run-ocsf-audit.ps1 `
|
||||
# -WxcExecPath C:\mxc-kit\bin\wxc-exec.exe
|
||||
#
|
||||
# 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.
|
||||
#
|
||||
# The deliverable is the OCSF audit log (openshell-ocsf.<date>.log) inside the
|
||||
# results-*.zip the script produces. Pass -ShareOut '\\server\share' to also copy
|
||||
# the bundle to a shared location (off by default).
|
||||
@@ -38,8 +35,6 @@ param(
|
||||
[string] $ShareDir = "C:\work\openshell-mxc-demo",
|
||||
# How many sandboxes to create (each drives a full event burst).
|
||||
[int] $SandboxCount = 2,
|
||||
# Disable the per-sandbox egress proxy (omits the SandboxProxyConfigured event).
|
||||
[switch] $NoProxy,
|
||||
# Gateway bind port (matches the gateway default) + CLI registration name.
|
||||
[int] $Port = 17670,
|
||||
[string] $GatewayName = "openshell-mxc-ocsf",
|
||||
@@ -86,6 +81,8 @@ function Get-MxcEtwSessions {
|
||||
|
||||
$gateway = Join-Path $here "openshell-gateway.exe"
|
||||
$cli = Join-Path $here "openshell.exe"
|
||||
$supervisor = Join-Path $here "openshell-supervisor.exe"
|
||||
$sandbox = Join-Path $here "openshell-sandbox.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"
|
||||
@@ -95,12 +92,11 @@ $helloPath = Join-Path $ShareDir "hello.txt"
|
||||
$gw = $null
|
||||
$gatewayEtwSessions = @()
|
||||
$passed = $true
|
||||
$proxyOn = -not $NoProxy
|
||||
|
||||
try {
|
||||
# 1. Validate artifacts + privilege.
|
||||
Step "Validate package artifacts"
|
||||
foreach ($f in @($gateway, $cli, $policySrc, $tomlSrc)) {
|
||||
foreach ($f in @($gateway, $cli, $supervisor, $sandbox, $policySrc, $tomlSrc)) {
|
||||
if (-not (Test-Path $f)) { throw "missing artifact: $f (run this script from inside the package folder)" }
|
||||
Info "found $(Split-Path $f -Leaf)"
|
||||
}
|
||||
@@ -133,12 +129,6 @@ try {
|
||||
} else {
|
||||
$tomlText = [regex]::Replace($tomlText, '(?m)^\[openshell\.drivers\.mxc\]\s*$', "[openshell.drivers.mxc]`r`netw_audit = true")
|
||||
}
|
||||
$proxyVal = if ($proxyOn) { 'true' } else { 'false' }
|
||||
if ($tomlText -match '(?m)^\s*#?\s*egress_proxy\s*=') {
|
||||
$tomlText = [regex]::Replace($tomlText, '(?m)^\s*#?\s*egress_proxy\s*=.*$', "egress_proxy = $proxyVal")
|
||||
} else {
|
||||
$tomlText = [regex]::Replace($tomlText, '(?m)^\[openshell\.drivers\.mxc\]\s*$', "[openshell.drivers.mxc]`r`negress_proxy = $proxyVal")
|
||||
}
|
||||
Set-Content $toml -Value $tomlText -Encoding UTF8
|
||||
|
||||
$shareDirPolicy = $ShareDir.Replace('\', '/')
|
||||
@@ -168,7 +158,7 @@ try {
|
||||
$driverConfig
|
||||
}
|
||||
|
||||
Info "backend=process_container etw_audit=true egress_proxy=$proxyVal"
|
||||
Info "backend=process_container etw_audit=true runtime=supervisor+sandbox"
|
||||
Info "workload cwd=$shareDirPolicy policy grant=$shareDirPolicy"
|
||||
|
||||
# 3. Port must be free. Auto-clear a stale OUR-gateway; refuse anything else.
|
||||
|
||||
@@ -1,722 +0,0 @@
|
||||
# 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\] (?<json>\{.*\})$' -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 }
|
||||
@@ -1,461 +0,0 @@
|
||||
# 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 }
|
||||
@@ -1,747 +0,0 @@
|
||||
# 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: <name>" (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
|
||||
}
|
||||
@@ -1,412 +0,0 @@
|
||||
// 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<HashMap<u64, oneshot::Sender<Value>>>;
|
||||
/// 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<Option<oneshot::Sender<Result<(), String>>>>;
|
||||
|
||||
/// 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 = 3;
|
||||
|
||||
/// 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<ChildStdin>,
|
||||
next_id: AtomicU64,
|
||||
pending: Arc<PendingMap>,
|
||||
}
|
||||
|
||||
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<PendingMap> {
|
||||
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::<Value>(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::<Value>(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<Value, ControlChannelError> {
|
||||
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 request = async {
|
||||
let mut stdin = self.stdin.lock().await;
|
||||
stdin
|
||||
.write_all(line.as_bytes())
|
||||
.await
|
||||
.map_err(ControlChannelError::Write)?;
|
||||
stdin.flush().await.map_err(ControlChannelError::Write)?;
|
||||
drop(stdin);
|
||||
Ok::<_, ControlChannelError>(rx.await.map_err(|_| ControlChannelError::Dropped)?)
|
||||
};
|
||||
|
||||
match tokio::time::timeout(timeout, request).await {
|
||||
Ok(Ok(value)) => Ok(value),
|
||||
Ok(Err(error)) => {
|
||||
self.pending.lock().await.remove(&id);
|
||||
Err(error)
|
||||
}
|
||||
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<Result<(), String>>) {
|
||||
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":3}"#)
|
||||
.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 1 must be rejected");
|
||||
assert!(
|
||||
err.contains('1'),
|
||||
"error should name the offending version: {err}"
|
||||
);
|
||||
assert!(
|
||||
err.contains('3'),
|
||||
"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":3}"#,
|
||||
)
|
||||
.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());
|
||||
}
|
||||
}
|
||||
+1029
-3359
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,141 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! MXC provisioning for the common authenticated Sandbox Protocol.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::net::SocketAddr;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use openshell_isolation_interface::contract::{
|
||||
BackendError, BinaryIdentity, DirectProxyConfiguration, OuterFenceGuarantees,
|
||||
ResolvedWorkloadIdentity,
|
||||
};
|
||||
use openshell_sandbox_backend::boundary_protocol::{
|
||||
BoundaryConfig, BoundaryListener, GatewayVerificationKey, SandboxRuntimeDescriptor,
|
||||
SandboxTlsClientConfig, SandboxTlsServerConfig, SandboxTransport,
|
||||
};
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct MxcOuterFenceEvidence<'a> {
|
||||
generation: &'a str,
|
||||
containment: &'a str,
|
||||
default_deny_filesystem: bool,
|
||||
default_deny_egress: bool,
|
||||
loopback_proxy_only: bool,
|
||||
controller_loss_fails_closed: bool,
|
||||
}
|
||||
|
||||
pub(crate) struct MxcBoundarySpec {
|
||||
pub boundary_id: String,
|
||||
pub generation: String,
|
||||
pub session_id: openshell_core::SandboxSessionId,
|
||||
pub session_rotation: openshell_core::jwt::SessionRotation,
|
||||
pub auth_epoch: openshell_core::jwt::CredentialEpoch,
|
||||
pub gateway_id: String,
|
||||
pub verification_keys: Vec<GatewayVerificationKey>,
|
||||
pub control_addr: SocketAddr,
|
||||
pub supervisor_tls: SandboxTlsClientConfig,
|
||||
pub sandbox_tls: SandboxTlsServerConfig,
|
||||
pub proxy_addr: SocketAddr,
|
||||
pub proxy_authorization: String,
|
||||
pub proxy_url: String,
|
||||
pub workload_binary: PathBuf,
|
||||
pub child_env: HashMap<String, String>,
|
||||
}
|
||||
|
||||
pub(crate) struct MxcBoundaryProvisioning {
|
||||
pub boundary_config: BoundaryConfig,
|
||||
pub runtime_descriptor: SandboxRuntimeDescriptor,
|
||||
}
|
||||
|
||||
impl MxcBoundarySpec {
|
||||
pub fn provision(self) -> Result<MxcBoundaryProvisioning, BackendError> {
|
||||
if !self.control_addr.ip().is_loopback()
|
||||
|| !self.proxy_addr.ip().is_loopback()
|
||||
|| self.control_addr.port() == 0
|
||||
|| self.proxy_addr.port() == 0
|
||||
{
|
||||
return Err(BackendError::Descriptor(
|
||||
"MXC control and proxy listeners must use concrete loopback ports".to_string(),
|
||||
));
|
||||
}
|
||||
let resource_digest = format!("mxc-processcontainer:{}", self.generation);
|
||||
// The common identity envelope is numeric for Unix backends. MXC binds
|
||||
// its AppContainer token through the source and resource digest while
|
||||
// using reserved nonzero numeric sentinels for the common fields.
|
||||
let workload_identity = ResolvedWorkloadIdentity::new(
|
||||
1,
|
||||
1,
|
||||
Vec::new(),
|
||||
"mxc-appcontainer".to_string(),
|
||||
resource_digest,
|
||||
)?;
|
||||
let resource_claims = BTreeMap::from([
|
||||
("mxc.generation".to_string(), self.generation.clone()),
|
||||
(
|
||||
"mxc.appcontainer_profile".to_string(),
|
||||
self.boundary_id.clone(),
|
||||
),
|
||||
]);
|
||||
let evidence = serde_json::to_vec(&MxcOuterFenceEvidence {
|
||||
generation: &self.generation,
|
||||
containment: "process_container",
|
||||
default_deny_filesystem: true,
|
||||
default_deny_egress: true,
|
||||
loopback_proxy_only: true,
|
||||
controller_loss_fails_closed: true,
|
||||
})
|
||||
.map_err(|error| {
|
||||
BackendError::Descriptor(format!("encode MXC outer-fence evidence: {error}"))
|
||||
})?;
|
||||
let outer_fence = OuterFenceGuarantees::confirmed(&self.generation, &evidence)?;
|
||||
let direct_proxy = DirectProxyConfiguration {
|
||||
bind_addr: self.proxy_addr,
|
||||
authorization: self.proxy_authorization,
|
||||
binary_identity: BinaryIdentity {
|
||||
binary_path: self.workload_binary,
|
||||
binary_digest: None,
|
||||
ancestors: Vec::new(),
|
||||
cmdline_paths: Vec::new(),
|
||||
},
|
||||
};
|
||||
Ok(MxcBoundaryProvisioning {
|
||||
boundary_config: BoundaryConfig {
|
||||
boundary_id: self.boundary_id.clone(),
|
||||
generation: self.generation.clone(),
|
||||
session_id: self.session_id,
|
||||
session_rotation: self.session_rotation,
|
||||
auth_epoch: self.auth_epoch,
|
||||
gateway_id: self.gateway_id,
|
||||
verification_keys: self.verification_keys,
|
||||
listener: BoundaryListener::TlsTcp {
|
||||
address: self.control_addr,
|
||||
tls: self.sandbox_tls,
|
||||
},
|
||||
resource_claims: resource_claims.clone(),
|
||||
resource_claim_files: BTreeMap::new(),
|
||||
workload_identity: workload_identity.clone(),
|
||||
outer_fence: outer_fence.clone(),
|
||||
direct_proxy_url: Some(self.proxy_url),
|
||||
child_env: self.child_env,
|
||||
},
|
||||
runtime_descriptor: SandboxRuntimeDescriptor {
|
||||
boundary_id: self.boundary_id,
|
||||
generation: self.generation,
|
||||
session_id: self.session_id,
|
||||
workload_identity,
|
||||
transport: SandboxTransport::Tcp {
|
||||
authority: self.control_addr.to_string(),
|
||||
addresses: vec![self.control_addr],
|
||||
},
|
||||
tls: self.supervisor_tls,
|
||||
host_gateway_ip: Some(self.proxy_addr.ip()),
|
||||
direct_proxy: Some(direct_proxy),
|
||||
resource_claims,
|
||||
outer_fence,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,12 @@
|
||||
//! `OpenShell` MXC compute driver.
|
||||
//!
|
||||
//! Implements the gateway's `ComputeDriver` gRPC contract backed by Microsoft
|
||||
//! MXC (`wxc-exec`) on Windows. The driver is **in-process**, runs the agent
|
||||
//! directly (exec-in-driver), and self-reports `Ready` — there is no
|
||||
//! in-sandbox supervisor, no host-side surrogate, and no `ConnectSupervisor`
|
||||
//! relay.
|
||||
//! MXC (`wxc-exec`) on Windows. The in-process driver provisions an
|
||||
//! `openshell-sandbox` boundary inside the `ProcessContainer` and launches the
|
||||
//! standard `openshell-supervisor --role=isolation-backend` on the host. The
|
||||
//! pair communicates over the authenticated Sandbox Protocol; workload
|
||||
//! lifecycle, forwarding, credentials, and governed networking therefore use
|
||||
//! the same supervisor session as the other RFC 0012 isolation backends.
|
||||
//!
|
||||
//! This crate compiles to an **empty stub** on non-Windows targets so the
|
||||
//! Linux build stays green. All implementation code is gated on
|
||||
@@ -15,13 +17,13 @@
|
||||
|
||||
#![allow(clippy::result_large_err)]
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
mod control_channel;
|
||||
#[cfg(target_os = "windows")]
|
||||
mod driver;
|
||||
#[cfg(target_os = "windows")]
|
||||
mod grpc;
|
||||
#[cfg(target_os = "windows")]
|
||||
mod isolation;
|
||||
#[cfg(target_os = "windows")]
|
||||
mod mxc;
|
||||
#[cfg(target_os = "windows")]
|
||||
mod policy;
|
||||
@@ -34,17 +36,11 @@ 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::{
|
||||
ForwardSink, MxcBackend, MxcComputeBackend, MxcComputeConfig, OpenDynamicForwardError,
|
||||
};
|
||||
pub use driver::{MxcBackend, MxcComputeBackend, MxcComputeConfig};
|
||||
#[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")]
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! `wxc-exec` invoker and MXC request/response types.
|
||||
//!
|
||||
//! Builds state-aware MXC config JSON, base64-encodes it, runs `wxc-exec`,
|
||||
//! and parses the response envelope. The exec phase is special: its stdout is
|
||||
//! live process output (not JSON) and its exit code is the agent exit code.
|
||||
//! `wxc-exec` ProcessContainer launcher and request types.
|
||||
|
||||
use base64::Engine as _;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use serde::Serialize;
|
||||
use std::net::SocketAddr;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use thiserror::Error;
|
||||
use tokio::process::Command;
|
||||
use tracing::{debug, info};
|
||||
@@ -21,14 +15,9 @@ use tracing::{debug, info};
|
||||
/// 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";
|
||||
|
||||
/// Environment flag selecting the in-process mock `wxc-exec` shim. When set to
|
||||
/// `"1"`, the invoker does NOT spawn the real `wxc-exec.exe`; instead it emits
|
||||
/// canned provision/start/stop/deprovision results and simulates `AppContainer`
|
||||
/// filesystem-policy enforcement for the exec phase. This is what makes the
|
||||
/// full create → Ready → policy-proof round trip runnable off the demo box.
|
||||
/// `"1"`, the invoker does not spawn `wxc-exec.exe`; it simulates AppContainer
|
||||
/// filesystem enforcement for the one-shot ProcessContainer launch.
|
||||
pub const MOCK_ENV_VAR: &str = "OPENSHELL_MXC_MOCK_WXC";
|
||||
|
||||
fn mock_enabled() -> bool {
|
||||
@@ -41,22 +30,11 @@ fn mock_normalize(s: &str) -> String {
|
||||
s.replace('/', "\\").to_lowercase()
|
||||
}
|
||||
|
||||
/// 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.
|
||||
fn mock_grants() -> &'static Mutex<HashMap<String, Vec<String>>> {
|
||||
static GRANTS: OnceLock<Mutex<HashMap<String, Vec<String>>>> = OnceLock::new();
|
||||
GRANTS.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
// ── Request types ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// 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` grants and `denied_paths`.
|
||||
#[derive(Debug, Default)]
|
||||
#[allow(clippy::struct_field_names)]
|
||||
pub struct MxcFilesystem {
|
||||
@@ -169,26 +147,16 @@ fn network_json(network: &MxcNetwork) -> serde_json::Value {
|
||||
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)
|
||||
// redirect, which can block the authenticated Sandbox Protocol and
|
||||
// explicit proxy connections to their host loopback listeners. Limit the
|
||||
// exception to 127.0.0.1/32 rather than the broader 127.0.0.0/8 range.
|
||||
// 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.
|
||||
// Deliberately no `ports` restriction: the authenticated Sandbox
|
||||
// Protocol listener, generation-scoped supervisor proxy, and dynamic
|
||||
// forwarding listeners all use independently allocated loopback ports.
|
||||
serde_json::json!({
|
||||
"egress": {
|
||||
"default": "deny",
|
||||
@@ -205,32 +173,6 @@ fn network_json(network: &MxcNetwork) -> serde_json::Value {
|
||||
value
|
||||
}
|
||||
|
||||
fn provision_config_json(
|
||||
configuration_id: &str,
|
||||
filesystem: &MxcFilesystem,
|
||||
network: Option<&MxcNetwork>,
|
||||
) -> serde_json::Value {
|
||||
let mut config = serde_json::json!({
|
||||
"version": MXC_SCHEMA_VERSION,
|
||||
"phase": "provision",
|
||||
"containment": "isolation_session",
|
||||
"filesystem": {
|
||||
"readwritePaths": &filesystem.readwrite_paths,
|
||||
"readonlyPaths": &filesystem.readonly_paths,
|
||||
},
|
||||
"experimental": {
|
||||
"isolation_session": {
|
||||
"configurationId": configuration_id,
|
||||
"provision": {}
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Some(network) = network {
|
||||
config["network"] = network_json(network);
|
||||
}
|
||||
config
|
||||
}
|
||||
|
||||
fn oneshot_config_json(
|
||||
container_id: &str,
|
||||
filesystem: &MxcFilesystem,
|
||||
@@ -290,49 +232,6 @@ fn oneshot_config_json(
|
||||
config
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn mock_configs() -> &'static Mutex<HashMap<String, serde_json::Value>> {
|
||||
static CONFIGS: OnceLock<Mutex<HashMap<String, serde_json::Value>>> = OnceLock::new();
|
||||
CONFIGS.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn mock_recorded_config(id: &str) -> Option<serde_json::Value> {
|
||||
mock_configs().lock().unwrap().get(id).cloned()
|
||||
}
|
||||
|
||||
// ── Response envelope ─────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ProvisionResult {
|
||||
#[serde(rename = "sandboxId")]
|
||||
pub sandbox_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum MxcEnvelope {
|
||||
Ok {
|
||||
#[allow(dead_code)]
|
||||
result: serde_json::Value,
|
||||
},
|
||||
Err {
|
||||
error: MxcErrorBody,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct MxcErrorBody {
|
||||
pub code: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ProvisionEnvelope {
|
||||
pub result: Option<ProvisionResult>,
|
||||
pub error: Option<MxcErrorBody>,
|
||||
}
|
||||
|
||||
// ── Errors ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
@@ -341,55 +240,11 @@ pub enum InvokerError {
|
||||
Spawn(#[from] std::io::Error),
|
||||
#[error("wxc-exec config serialization failed: {0}")]
|
||||
Serialize(#[from] serde_json::Error),
|
||||
#[error("wxc-exec envelope parse failed (stdout={stdout:?}): {source}")]
|
||||
Parse {
|
||||
stdout: String,
|
||||
source: serde_json::Error,
|
||||
},
|
||||
#[error("wxc-exec process failed with no envelope (exit={exit_code}, stderr={stderr:?})")]
|
||||
NoEnvelope { exit_code: i32, stderr: String },
|
||||
#[error("MXC error [{code}]: {message}")]
|
||||
Mxc { code: String, message: String },
|
||||
/// Exec phase returned a non-zero exit code (the agent's own exit status).
|
||||
/// Surfaced through the watch stream rather than as a gRPC error.
|
||||
#[allow(dead_code)]
|
||||
#[error("wxc-exec exec phase exited with code {0}")]
|
||||
ExecNonZero(i32),
|
||||
}
|
||||
|
||||
impl InvokerError {
|
||||
#[allow(dead_code)]
|
||||
pub fn to_tonic_status(&self) -> tonic::Status {
|
||||
match self {
|
||||
Self::Mxc { code, message } => match code.as_str() {
|
||||
"malformed_request" | "unsupported_phase" => {
|
||||
tonic::Status::internal(format!("driver bug: {message}"))
|
||||
}
|
||||
"unsupported_containment"
|
||||
| "not_provisioned"
|
||||
| "not_started"
|
||||
| "already_started"
|
||||
| "already_stopped" => tonic::Status::failed_precondition(message.clone()),
|
||||
"malformed_id" | "stale_id" => tonic::Status::not_found(message.clone()),
|
||||
"policy_validation" => tonic::Status::invalid_argument(message.clone()),
|
||||
"backend_unavailable" => tonic::Status::unavailable(message.clone()),
|
||||
_ => tonic::Status::internal(message.clone()),
|
||||
},
|
||||
Self::Spawn(e) => tonic::Status::internal(format!("wxc-exec spawn: {e}")),
|
||||
Self::Serialize(e) => tonic::Status::internal(format!("config serialize: {e}")),
|
||||
Self::Parse { .. } | Self::NoEnvelope { .. } => {
|
||||
tonic::Status::internal(self.to_string())
|
||||
}
|
||||
Self::ExecNonZero(code) => {
|
||||
tonic::Status::internal(format!("agent exited with code {code}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Invoker ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Wraps `wxc-exec` invocations for the MXC state-aware lifecycle.
|
||||
/// Wraps the one-shot `wxc-exec` ProcessContainer invocation.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WxcExecInvoker {
|
||||
exec_path: PathBuf,
|
||||
@@ -411,247 +266,7 @@ impl WxcExecInvoker {
|
||||
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`).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn mocked(exec_path: impl Into<PathBuf>) -> Self {
|
||||
Self {
|
||||
exec_path: exec_path.into(),
|
||||
debug: false,
|
||||
mock: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode `config` as base64 and invoke wxc-exec, returning the parsed envelope.
|
||||
/// Use this for all **non-exec** phases (provision/start/stop/deprovision).
|
||||
pub async fn run_phase(&self, config: &serde_json::Value) -> Result<(), InvokerError> {
|
||||
if self.mock {
|
||||
// Mock start/stop/deprovision: canned `{"result":{}}` success.
|
||||
debug!(phase = ?config.get("phase"), "mock wxc-exec phase (no-op success)");
|
||||
return Ok(());
|
||||
}
|
||||
let json = serde_json::to_string(config)?;
|
||||
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).arg("--experimental");
|
||||
if self.debug {
|
||||
cmd.arg("--debug");
|
||||
}
|
||||
|
||||
// `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();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
|
||||
|
||||
if !output.status.success() {
|
||||
if let Ok(MxcEnvelope::Err { error }) = serde_json::from_str::<MxcEnvelope>(&stdout) {
|
||||
return Err(InvokerError::Mxc {
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
let code = output.status.code().unwrap_or(-1);
|
||||
return Err(InvokerError::NoEnvelope {
|
||||
exit_code: code,
|
||||
stderr,
|
||||
});
|
||||
}
|
||||
|
||||
// Success — parse envelope to surface any embedded error field.
|
||||
match serde_json::from_str::<MxcEnvelope>(&stdout) {
|
||||
Ok(MxcEnvelope::Err { error }) => Err(InvokerError::Mxc {
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
}),
|
||||
Ok(MxcEnvelope::Ok { .. }) => Ok(()),
|
||||
Err(_) if stdout.trim().is_empty() => {
|
||||
// Some phases return empty stdout on success.
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(InvokerError::Parse { stdout, source: e }),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the provision phase and return the `sandboxId` from the response.
|
||||
pub async fn provision(
|
||||
&self,
|
||||
configuration_id: &str,
|
||||
filesystem: MxcFilesystem,
|
||||
network: Option<MxcNetwork>,
|
||||
) -> Result<String, InvokerError> {
|
||||
if self.mock {
|
||||
// Mock provision: mint a synthetic `iso:` id and record the granted
|
||||
// read-write paths so the mock exec can enforce the policy.
|
||||
let id = format!("iso:mock-{}", uuid::Uuid::new_v4());
|
||||
let grants: Vec<String> = filesystem
|
||||
.readwrite_paths
|
||||
.iter()
|
||||
.map(|p| mock_normalize(p))
|
||||
.collect();
|
||||
mock_grants().lock().unwrap().insert(id.clone(), grants);
|
||||
#[cfg(test)]
|
||||
{
|
||||
let config = provision_config_json(configuration_id, &filesystem, network.as_ref());
|
||||
mock_configs().lock().unwrap().insert(id.clone(), config);
|
||||
}
|
||||
debug!(sandbox_id = %id, "mock wxc-exec provision");
|
||||
return Ok(id);
|
||||
}
|
||||
let config = provision_config_json(configuration_id, &filesystem, network.as_ref());
|
||||
|
||||
let json = serde_json::to_string(&config)?;
|
||||
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).arg("--experimental");
|
||||
if self.debug {
|
||||
cmd.arg("--debug");
|
||||
}
|
||||
|
||||
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();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
|
||||
|
||||
if !output.status.success() {
|
||||
let code = output.status.code().unwrap_or(-1);
|
||||
if let Ok(ProvisionEnvelope {
|
||||
error: Some(error), ..
|
||||
}) = serde_json::from_str::<ProvisionEnvelope>(&stdout)
|
||||
{
|
||||
return Err(InvokerError::Mxc {
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
return Err(InvokerError::NoEnvelope {
|
||||
exit_code: code,
|
||||
stderr,
|
||||
});
|
||||
}
|
||||
|
||||
let env: ProvisionEnvelope =
|
||||
serde_json::from_str(&stdout).map_err(|e| InvokerError::Parse {
|
||||
stdout: stdout.clone(),
|
||||
source: e,
|
||||
})?;
|
||||
|
||||
if let Some(err) = env.error {
|
||||
return Err(InvokerError::Mxc {
|
||||
code: err.code,
|
||||
message: err.message,
|
||||
});
|
||||
}
|
||||
|
||||
env.result
|
||||
.map(|r| r.sandbox_id)
|
||||
.ok_or_else(|| InvokerError::NoEnvelope {
|
||||
exit_code: 0,
|
||||
stderr: "provision result missing sandboxId".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Run the start phase for an already-provisioned sandbox.
|
||||
pub async fn start(&self, iso_sandbox_id: &str) -> Result<(), InvokerError> {
|
||||
let config = serde_json::json!({
|
||||
"version": MXC_SCHEMA_VERSION,
|
||||
"phase": "start",
|
||||
"sandboxId": iso_sandbox_id,
|
||||
"experimental": {
|
||||
"isolation_session": {
|
||||
"start": {}
|
||||
}
|
||||
}
|
||||
});
|
||||
self.run_phase(&config).await
|
||||
}
|
||||
|
||||
/// Spawn the exec phase (agent command). Returns the child process handle.
|
||||
/// **Stdout is raw agent output, not a JSON envelope. Exit code == agent exit code.**
|
||||
pub async fn spawn_exec(
|
||||
&self,
|
||||
iso_sandbox_id: &str,
|
||||
process: MxcProcess,
|
||||
) -> Result<tokio::process::Child, InvokerError> {
|
||||
if self.mock {
|
||||
return Self::mock_spawn_exec(iso_sandbox_id, &process);
|
||||
}
|
||||
let config = serde_json::json!({
|
||||
"version": MXC_SCHEMA_VERSION,
|
||||
"phase": "exec",
|
||||
"sandboxId": iso_sandbox_id,
|
||||
"process": {
|
||||
"commandLine": process.command_line,
|
||||
"cwd": process.cwd,
|
||||
"env": process.env,
|
||||
"timeout": process.timeout,
|
||||
}
|
||||
});
|
||||
|
||||
let json = serde_json::to_string(&config)?;
|
||||
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)
|
||||
.arg("--experimental")
|
||||
// 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");
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
/// Mock exec: simulate `AppContainer` filesystem-policy enforcement.
|
||||
///
|
||||
/// The agent's write target is considered **in-policy** iff the command line
|
||||
/// references one of the granted read-write paths recorded at mock provision.
|
||||
fn mock_spawn_exec(
|
||||
iso_sandbox_id: &str,
|
||||
process: &MxcProcess,
|
||||
) -> Result<tokio::process::Child, InvokerError> {
|
||||
let grants = mock_grants()
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(iso_sandbox_id)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
Self::mock_spawn_with_grants(process, &grants)
|
||||
}
|
||||
|
||||
/// Shared mock enforcement used by both the `isolation_session` exec phase
|
||||
/// and the one-shot `processContainer` path.
|
||||
/// Mock enforcement for the one-shot `processContainer` path.
|
||||
///
|
||||
/// In-policy → run the real agent command (so the positive-proof artifact,
|
||||
/// e.g. `hello.txt`, actually appears on the host shared folder). Out-of-policy
|
||||
@@ -687,10 +302,9 @@ impl WxcExecInvoker {
|
||||
|
||||
/// Build a **one-shot** `processContainer` config (no `phase`) and spawn it.
|
||||
///
|
||||
/// Unlike the `isolation_session` lifecycle (provision → start → exec →
|
||||
/// stop → deprovision), `processContainer` is a single ephemeral
|
||||
/// `AppContainer`: one `wxc-exec` invocation creates the container, runs the
|
||||
/// one process, and tears down when it exits. The `AppContainer` is genuinely
|
||||
/// `processContainer` is a single ephemeral `AppContainer`: one `wxc-exec`
|
||||
/// invocation creates the container, runs the sandbox runtime, and tears it
|
||||
/// down when that runtime exits. The `AppContainer` is genuinely
|
||||
/// default-deny, so a write to any ungranted path is denied by the OS.
|
||||
///
|
||||
/// **Stdout is raw agent output; the exit code is the agent's own exit code.**
|
||||
@@ -717,11 +331,6 @@ impl WxcExecInvoker {
|
||||
.iter()
|
||||
.map(|p| mock_normalize(p))
|
||||
.collect();
|
||||
#[cfg(test)]
|
||||
mock_configs()
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(container_id.to_owned(), config);
|
||||
return Self::mock_spawn_with_grants(&process, &grants);
|
||||
}
|
||||
|
||||
@@ -756,11 +365,7 @@ impl WxcExecInvoker {
|
||||
let mut cmd = Command::new(&self.exec_path);
|
||||
cmd.arg("--config-base64")
|
||||
.arg(&b64)
|
||||
// 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.
|
||||
// Retain child output so the gateway can surface sandbox logs.
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped());
|
||||
@@ -772,41 +377,6 @@ impl WxcExecInvoker {
|
||||
let child = cmd.spawn()?;
|
||||
Ok(child)
|
||||
}
|
||||
|
||||
/// Run the stop phase.
|
||||
///
|
||||
/// `stop`/`deprovision` are **unit** variants in the wxc-exec schema: they
|
||||
/// must serialize as `null`, not `{}`. Empirical (build 26300.8553,
|
||||
/// wxc-exec 2026-06-10): `"stop": {}` is rejected with `malformed_request`
|
||||
/// ("invalid type: map, expected unit"); `provision`/`start` accept maps.
|
||||
pub async fn stop(&self, iso_sandbox_id: &str) -> Result<(), InvokerError> {
|
||||
let config = serde_json::json!({
|
||||
"version": MXC_SCHEMA_VERSION,
|
||||
"phase": "stop",
|
||||
"sandboxId": iso_sandbox_id,
|
||||
"experimental": {
|
||||
"isolation_session": {
|
||||
"stop": null
|
||||
}
|
||||
}
|
||||
});
|
||||
self.run_phase(&config).await
|
||||
}
|
||||
|
||||
/// Run the deprovision phase (unit variant — see [`Self::stop`]).
|
||||
pub async fn deprovision(&self, iso_sandbox_id: &str) -> Result<(), InvokerError> {
|
||||
let config = serde_json::json!({
|
||||
"version": MXC_SCHEMA_VERSION,
|
||||
"phase": "deprovision",
|
||||
"sandboxId": iso_sandbox_id,
|
||||
"experimental": {
|
||||
"isolation_session": {
|
||||
"deprovision": null
|
||||
}
|
||||
}
|
||||
});
|
||||
self.run_phase(&config).await
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests (pure serde — compile and run cross-platform) ──────────────────────
|
||||
@@ -815,65 +385,6 @@ impl WxcExecInvoker {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn provision_envelope_parse_success() {
|
||||
let json = r#"{"result":{"sandboxId":"iso:wxc-abc123","metadata":{}}}"#;
|
||||
let env: ProvisionEnvelope = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(env.result.unwrap().sandbox_id, "iso:wxc-abc123");
|
||||
assert!(env.error.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provision_envelope_parse_error() {
|
||||
let json =
|
||||
r#"{"error":{"code":"backend_unavailable","message":"IsoSessionApp.dll missing"}}"#;
|
||||
let env: ProvisionEnvelope = serde_json::from_str(json).unwrap();
|
||||
assert!(env.result.is_none());
|
||||
let err = env.error.unwrap();
|
||||
assert_eq!(err.code, "backend_unavailable");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mxc_envelope_success_variant() {
|
||||
let json = r#"{"result":{}}"#;
|
||||
let env: MxcEnvelope = serde_json::from_str(json).unwrap();
|
||||
assert!(matches!(env, MxcEnvelope::Ok { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mxc_envelope_error_variant() {
|
||||
let json = r#"{"error":{"code":"not_provisioned","message":"call provision first"}}"#;
|
||||
let env: MxcEnvelope = serde_json::from_str(json).unwrap();
|
||||
assert!(matches!(env, MxcEnvelope::Err { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provision_config_json_shape() {
|
||||
// Verify the JSON we send wxc-exec has the expected shape.
|
||||
let config = serde_json::json!({
|
||||
"version": MXC_SCHEMA_VERSION,
|
||||
"phase": "provision",
|
||||
"containment": "isolation_session",
|
||||
"filesystem": {
|
||||
"readwritePaths": ["C:\\work\\demo"],
|
||||
"readonlyPaths": [],
|
||||
},
|
||||
"experimental": {
|
||||
"isolation_session": {
|
||||
"configurationId": DEFAULT_CONFIGURATION_ID,
|
||||
"provision": {}
|
||||
}
|
||||
}
|
||||
});
|
||||
assert_eq!(config["phase"], "provision");
|
||||
assert_eq!(config["containment"], "isolation_session");
|
||||
assert_eq!(
|
||||
config["experimental"]["isolation_session"]["configurationId"],
|
||||
"composable"
|
||||
);
|
||||
assert_eq!(config["filesystem"]["readwritePaths"][0], "C:\\work\\demo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oneshot_processcontainer_config_json_shape() {
|
||||
// Mirror the JSON `run_oneshot` builds for the one-shot processContainer
|
||||
@@ -906,37 +417,6 @@ mod tests {
|
||||
assert_eq!(config["filesystem"]["deniedPaths"][0], "C:\\secret");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provision_config_json_includes_network_loopback_when_proxy_supplied() {
|
||||
let filesystem = MxcFilesystem {
|
||||
readwrite_paths: vec!["C:\\work\\demo".into()],
|
||||
readonly_paths: Vec::new(),
|
||||
denied_paths: Vec::new(),
|
||||
};
|
||||
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));
|
||||
|
||||
// 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_directional_format() {
|
||||
// MXC 0.8.0-alpha: egress/ingress replaces the legacy
|
||||
@@ -948,8 +428,8 @@ mod tests {
|
||||
};
|
||||
let value = network_json(&network);
|
||||
// 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.
|
||||
// Allows the sandbox to reach its authenticated host-side listeners
|
||||
// without enabling direct Internet access.
|
||||
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.
|
||||
@@ -1000,64 +480,4 @@ mod tests {
|
||||
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]
|
||||
fn stop_and_deprovision_serialize_as_unit_variants() {
|
||||
// Pins the empirical schema contract (test box, build 26300.8553):
|
||||
// stop/deprovision are unit variants and must be `null`; `{}` is
|
||||
// rejected with malformed_request "invalid type: map, expected unit".
|
||||
for phase in ["stop", "deprovision"] {
|
||||
let config = serde_json::json!({
|
||||
"version": MXC_SCHEMA_VERSION,
|
||||
"phase": phase,
|
||||
"sandboxId": "iso:wxc-test",
|
||||
"experimental": {
|
||||
"isolation_session": {
|
||||
phase: null
|
||||
}
|
||||
}
|
||||
});
|
||||
assert!(
|
||||
config["experimental"]["isolation_session"][phase].is_null(),
|
||||
"{phase} must serialize as null (unit variant)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invoker_error_maps_backend_unavailable_to_unavailable() {
|
||||
let err = InvokerError::Mxc {
|
||||
code: "backend_unavailable".into(),
|
||||
message: "missing DLL".into(),
|
||||
};
|
||||
let status = err.to_tonic_status();
|
||||
assert_eq!(status.code(), tonic::Code::Unavailable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invoker_error_maps_policy_validation_to_invalid_argument() {
|
||||
let err = InvokerError::Mxc {
|
||||
code: "policy_validation".into(),
|
||||
message: "path denied".into(),
|
||||
};
|
||||
let status = err.to_tonic_status();
|
||||
assert_eq!(status.code(), tonic::Code::InvalidArgument);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invoker_error_maps_stale_id_to_not_found() {
|
||||
let err = InvokerError::Mxc {
|
||||
code: "stale_id".into(),
|
||||
message: "session expired".into(),
|
||||
};
|
||||
let status = err.to_tonic_status();
|
||||
assert_eq!(status.code(), tonic::Code::NotFound);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -417,7 +417,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_rejects_network_middleware_on_egress_proxy() {
|
||||
fn embedded_preserves_network_middleware_for_supervisor() {
|
||||
let mapper = EmbeddedPolicyMapper;
|
||||
let mut policy = fs_policy(&["C:/work/demo"], &[]);
|
||||
policy.network_middlewares.insert(
|
||||
@@ -439,12 +439,8 @@ mod tests {
|
||||
containment: "processcontainer".into(),
|
||||
};
|
||||
|
||||
let error = mapper.map(Some(&policy), &ctx).unwrap_err();
|
||||
let MapError::Unsupported(loss) = error else {
|
||||
panic!("expected unsupported middleware error");
|
||||
};
|
||||
assert_eq!(loss.len(), 1);
|
||||
assert_eq!(loss[0].rule_kind, "network_middlewares");
|
||||
assert!(loss[0].detail.contains("middleware service registry"));
|
||||
let mapped = mapper.map(Some(&policy), &ctx).unwrap();
|
||||
let trimmed = mapped.trimmed_policy.expect("trimmed proxy policy");
|
||||
assert_eq!(trimmed.network_middlewares, policy.network_middlewares);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,9 +104,10 @@ pub fn map_to_mxc(policy: &SandboxPolicy, opts: &MxcMappingOptions) -> MxcMappin
|
||||
/// 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.
|
||||
/// [`SplitPolicyResult::proxy_policy`] carries the original network policy
|
||||
/// verbatim. The RFC 0012 host supervisor receives the complete policy and the
|
||||
/// gateway middleware service registry through its ordinary session, so the
|
||||
/// MXC outer-fence mapping does not reject middleware configuration.
|
||||
///
|
||||
/// 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.
|
||||
@@ -173,20 +174,6 @@ fn build_split_mxc_config(
|
||||
"The host proxy receives the trimmed policy and enforces network rules.",
|
||||
);
|
||||
}
|
||||
if !policy.network_middlewares.is_empty() {
|
||||
add_loss(
|
||||
items,
|
||||
"network_middlewares",
|
||||
"error",
|
||||
&format!(
|
||||
"{} network middleware config(s) cannot be enforced because the MXC host proxy is not connected to the gateway middleware service registry.",
|
||||
policy.network_middlewares.len()
|
||||
),
|
||||
"network egress middleware",
|
||||
"The MXC sandbox is rejected before launch instead of bypassing fail-open middleware or failing unrelated allowed traffic.",
|
||||
);
|
||||
}
|
||||
|
||||
// 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]) {
|
||||
|
||||
@@ -1,740 +0,0 @@
|
||||
// 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:<hex
|
||||
//! nonce>"` 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> — 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<ControlChannel>,
|
||||
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<ControlChannel>,
|
||||
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;
|
||||
let mut host_eof = false;
|
||||
'connection: loop {
|
||||
// Keep this request alive when host input wins the select. Dropping
|
||||
// an in-flight correlated read loses a response (and potentially
|
||||
// its bytes) when the sandbox replies a moment later.
|
||||
let mut forward_read = tokio::spawn({
|
||||
let control_channel = control_channel.clone();
|
||||
let session_id = session_id.clone();
|
||||
async move {
|
||||
control_channel
|
||||
.request(
|
||||
"forward_read",
|
||||
serde_json::json!({"session_id": session_id}),
|
||||
Duration::from_secs(10),
|
||||
)
|
||||
.await
|
||||
}
|
||||
});
|
||||
loop {
|
||||
tokio::select! {
|
||||
result = host_read.read(&mut host_buf), if !host_eof => match result {
|
||||
Ok(0) => {
|
||||
// Preserve TCP half-close semantics: tell the target
|
||||
// that no more request bytes are coming, then keep
|
||||
// draining its response until target EOF.
|
||||
let response = control_channel.request(
|
||||
"forward_shutdown",
|
||||
serde_json::json!({"session_id": session_id}),
|
||||
Duration::from_secs(10),
|
||||
).await;
|
||||
if !control_response_ok(&response) {
|
||||
break 'connection;
|
||||
}
|
||||
host_eof = true;
|
||||
}
|
||||
Err(_) => break 'connection,
|
||||
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 'connection;
|
||||
}
|
||||
host_to_sandbox_bytes += n as u64;
|
||||
}
|
||||
},
|
||||
response = &mut forward_read => {
|
||||
let Ok(Ok(response)) = response else { break 'connection };
|
||||
if response.get("ok").and_then(serde_json::Value::as_bool) != Some(true) {
|
||||
break 'connection;
|
||||
}
|
||||
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 'connection;
|
||||
}
|
||||
let Some(encoded) = data.get("bytes").and_then(serde_json::Value::as_str) else {
|
||||
break 'connection;
|
||||
};
|
||||
if !encoded.is_empty() {
|
||||
let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(encoded) else {
|
||||
break 'connection;
|
||||
};
|
||||
if host_write.write_all(&bytes).await.is_err() {
|
||||
break 'connection;
|
||||
}
|
||||
sandbox_to_host_bytes += bytes.len() as u64;
|
||||
}
|
||||
break;
|
||||
},
|
||||
_ = &mut shutdown_rx => {
|
||||
shutting_down = true;
|
||||
break 'connection;
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
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<serde_json::Value, crate::control_channel::ControlChannelError>,
|
||||
) -> 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 {
|
||||
let mut drain_response = false;
|
||||
let mut response_sent = false;
|
||||
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();
|
||||
if op == "forward_shutdown" {
|
||||
drain_response = true;
|
||||
}
|
||||
let data = if op == "forward_read" && drain_response {
|
||||
if response_sent {
|
||||
serde_json::json!({"bytes": "", "eof": true})
|
||||
} else {
|
||||
response_sent = true;
|
||||
serde_json::json!({
|
||||
"bytes": base64::engine::general_purpose::STANDARD.encode(b"response"),
|
||||
"eof": false,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
serde_json::json!({"bytes": "", "eof": false})
|
||||
};
|
||||
let response = serde_json::json!({
|
||||
"id": request["id"], "ok": true,
|
||||
"data": data,
|
||||
});
|
||||
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 mut response = Vec::new();
|
||||
tokio::time::timeout(Duration::from_secs(10), client.read_to_end(&mut response))
|
||||
.await
|
||||
.expect("timed out draining the response after client half-close")
|
||||
.unwrap();
|
||||
assert_eq!(response, b"response");
|
||||
}
|
||||
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 ────────────────────────────────────────────────────────────────
|
||||
@@ -1,58 +0,0 @@
|
||||
// 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"));
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
# 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"
|
||||
@@ -902,8 +902,6 @@ async fn pc_https_egress_reads_injected_ca_bundle() {
|
||||
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
|
||||
let config = 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()
|
||||
};
|
||||
let backend = MxcComputeBackend::new(config);
|
||||
|
||||
@@ -221,6 +221,7 @@ pub fn bootstrap_archives(
|
||||
resource_claim_files: BTreeMap::new(),
|
||||
workload_identity: identity.clone(),
|
||||
outer_fence: outer_fence.clone(),
|
||||
direct_proxy_url: None,
|
||||
child_env: child_env.clone(),
|
||||
};
|
||||
let runtime_descriptor = SandboxRuntimeDescriptor {
|
||||
@@ -235,6 +236,7 @@ pub fn bootstrap_archives(
|
||||
trust_anchor_pem: tls.trust_anchor_pem,
|
||||
},
|
||||
host_gateway_ip: None,
|
||||
direct_proxy: None,
|
||||
resource_claims,
|
||||
workload_identity: identity.clone(),
|
||||
outer_fence,
|
||||
|
||||
@@ -100,6 +100,7 @@ impl VmBoundarySpec {
|
||||
resource_claim_files: BTreeMap::new(),
|
||||
workload_identity: workload_identity.clone(),
|
||||
outer_fence: outer_fence.clone(),
|
||||
direct_proxy_url: None,
|
||||
child_env: self.child_env,
|
||||
},
|
||||
runtime_descriptor: SandboxRuntimeDescriptor {
|
||||
@@ -113,6 +114,7 @@ impl VmBoundarySpec {
|
||||
// reserved host aliases terminate at its loopback address
|
||||
// after crossing the authenticated boundary channel.
|
||||
host_gateway_ip: Some(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)),
|
||||
direct_proxy: None,
|
||||
resource_claims,
|
||||
outer_fence,
|
||||
},
|
||||
|
||||
@@ -149,7 +149,15 @@ impl openshell_server::ComputeDriverFactory for MxcFactory {
|
||||
&self,
|
||||
context: openshell_server::ComputeDriverConfigContext<'_>,
|
||||
) -> openshell_core::Result<()> {
|
||||
let _: openshell_driver_mxc::MxcComputeConfig = context.driver_config()?;
|
||||
let mut config: openshell_driver_mxc::MxcComputeConfig = context.driver_config()?;
|
||||
if config.grpc_endpoint.trim().is_empty() {
|
||||
let scheme = if context.gateway_tls_enabled() {
|
||||
"https"
|
||||
} else {
|
||||
"http"
|
||||
};
|
||||
config.grpc_endpoint = format!("{scheme}://127.0.0.1:{}", context.gateway_port());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -157,41 +165,33 @@ impl openshell_server::ComputeDriverFactory for MxcFactory {
|
||||
&self,
|
||||
context: openshell_server::ComputeDriverBuildContext<'_>,
|
||||
) -> openshell_core::Result<openshell_server::ComputeDriverInstance> {
|
||||
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 mut config: openshell_driver_mxc::MxcComputeConfig = context.driver_config()?;
|
||||
require_guest_tls_for_local_driver(&context, "mxc")?;
|
||||
let use_internal_tls_server_name =
|
||||
config.grpc_endpoint.trim().is_empty() && context.gateway_tls_enabled();
|
||||
if config.grpc_endpoint.trim().is_empty() {
|
||||
let scheme = if context.gateway_tls_enabled() {
|
||||
"https"
|
||||
} else {
|
||||
"http"
|
||||
};
|
||||
config.grpc_endpoint = format!("{scheme}://127.0.0.1:{}", context.gateway_port());
|
||||
}
|
||||
let tls = context
|
||||
.guest_tls_paths()
|
||||
.map(|(ca, cert, key)| (ca.to_path_buf(), cert.to_path_buf(), key.to_path_buf()));
|
||||
let endpoint = config.grpc_endpoint.clone();
|
||||
let tls_server_name = use_internal_tls_server_name.then(|| "localhost".to_string());
|
||||
let backend = openshell_driver_mxc::MxcComputeBackend::new_with_gateway(
|
||||
config,
|
||||
endpoint,
|
||||
tls,
|
||||
tls_server_name,
|
||||
);
|
||||
let driver = openshell_driver_mxc::ComputeDriverService::new(backend);
|
||||
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<u8>, Box<dyn std::any::Any + Send>), 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)))
|
||||
Ok(openshell_server::ComputeDriverInstance::InProcess(
|
||||
std::sync::Arc::new(driver),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -489,13 +489,16 @@ fn vm_config(
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
#[cfg(all(
|
||||
not(target_os = "windows"),
|
||||
any(
|
||||
feature = "compute-driver-docker",
|
||||
feature = "compute-driver-podman",
|
||||
feature = "compute-driver-vm"
|
||||
)
|
||||
#[cfg(any(
|
||||
all(
|
||||
not(target_os = "windows"),
|
||||
any(
|
||||
feature = "compute-driver-docker",
|
||||
feature = "compute-driver-podman",
|
||||
feature = "compute-driver-vm"
|
||||
)
|
||||
),
|
||||
all(target_os = "windows", feature = "compute-driver-mxc")
|
||||
))]
|
||||
fn require_guest_tls_for_local_driver(
|
||||
context: &openshell_server::ComputeDriverBuildContext<'_>,
|
||||
@@ -508,13 +511,16 @@ fn require_guest_tls_for_local_driver(
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(all(
|
||||
not(target_os = "windows"),
|
||||
any(
|
||||
feature = "compute-driver-docker",
|
||||
feature = "compute-driver-podman",
|
||||
feature = "compute-driver-vm"
|
||||
)
|
||||
#[cfg(any(
|
||||
all(
|
||||
not(target_os = "windows"),
|
||||
any(
|
||||
feature = "compute-driver-docker",
|
||||
feature = "compute-driver-podman",
|
||||
feature = "compute-driver-vm"
|
||||
)
|
||||
),
|
||||
all(target_os = "windows", feature = "compute-driver-mxc")
|
||||
))]
|
||||
fn validate_local_driver_guest_tls(
|
||||
gateway_tls_enabled: bool,
|
||||
|
||||
@@ -370,6 +370,15 @@ pub trait BoundBoundary: Send {
|
||||
/// Retained by the supervisor before consuming `Bound`.
|
||||
fn network_mediation_source(&self) -> Arc<dyn NetworkMediationSource>;
|
||||
|
||||
/// Driver-provisioned direct proxy listener for backends whose outer
|
||||
/// fence can route workload traffic to a host listener but cannot stage
|
||||
/// individual socket opens. The supervisor owns this listener and its
|
||||
/// policy evaluation; the generation-scoped authorization prevents other
|
||||
/// local processes from entering the sandbox's policy context.
|
||||
fn direct_proxy_configuration(&self) -> Option<DirectProxyConfiguration> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Trusted host-side dial target for the well-known host-gateway aliases.
|
||||
///
|
||||
/// Backends return this when the mediation service runs outside the
|
||||
@@ -386,6 +395,31 @@ pub trait BoundBoundary: Send {
|
||||
async fn confirm(self: Box<Self>) -> Result<ConfirmedBoundary, BackendError>;
|
||||
}
|
||||
|
||||
/// Authenticated host listener used by an isolation backend's explicit-proxy
|
||||
/// path.
|
||||
///
|
||||
/// This is control-plane material and must be delivered through the protected
|
||||
/// runtime descriptor, never command-line arguments or logs.
|
||||
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct DirectProxyConfiguration {
|
||||
pub bind_addr: SocketAddr,
|
||||
/// Exact HTTP `Proxy-Authorization` value required from this generation.
|
||||
pub authorization: String,
|
||||
/// Driver-resolved identity applied to direct-listener requests.
|
||||
pub binary_identity: BinaryIdentity,
|
||||
}
|
||||
|
||||
impl fmt::Debug for DirectProxyConfiguration {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("DirectProxyConfiguration")
|
||||
.field("bind_addr", &self.bind_addr)
|
||||
.field("authorization", &"<redacted>")
|
||||
.field("binary_identity", &self.binary_identity)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Backend-neutral guarantees established by the compute driver's outer fence.
|
||||
///
|
||||
/// Each driver owns its native evidence schema and the code that validates it.
|
||||
@@ -808,7 +842,7 @@ pub trait BoundaryLoopbackConnector: Send + Sync {
|
||||
/// unavailable identity field cannot authorize the connection. How a backend
|
||||
/// resolves identity is private to that backend; the shape and the fail-closed
|
||||
/// semantics do not change.
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BinaryIdentity {
|
||||
/// Absolute path of the executable resolved for the accepted connection.
|
||||
pub binary_path: PathBuf,
|
||||
|
||||
@@ -182,6 +182,92 @@ impl OpenShellSandboxAuditEvidence {
|
||||
}
|
||||
}
|
||||
|
||||
/// Windows `ProcessContainer` evidence measured by the MXC boundary.
|
||||
///
|
||||
/// MXC supplies the outer filesystem and network fence; the in-container
|
||||
/// sandbox supplies authenticated lifecycle and process I/O.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[allow(
|
||||
clippy::struct_excessive_bools,
|
||||
reason = "audit evidence preserves independently measured security results"
|
||||
)]
|
||||
pub struct MxcSandboxAuditEvidence {
|
||||
pub process_container: bool,
|
||||
pub appcontainer_profile: String,
|
||||
pub default_deny_filesystem: bool,
|
||||
pub default_deny_egress: bool,
|
||||
pub loopback_proxy_only: bool,
|
||||
pub authenticated_control: bool,
|
||||
pub generation_scoped_attribution: bool,
|
||||
}
|
||||
|
||||
impl MxcSandboxAuditEvidence {
|
||||
pub fn validate(&self) -> Result<(), BackendError> {
|
||||
if self.process_container
|
||||
&& !self.appcontainer_profile.trim().is_empty()
|
||||
&& self.default_deny_filesystem
|
||||
&& self.default_deny_egress
|
||||
&& self.loopback_proxy_only
|
||||
&& self.authenticated_control
|
||||
&& self.generation_scoped_attribution
|
||||
{
|
||||
Ok(())
|
||||
} else {
|
||||
Err(BackendError::Confirm(
|
||||
"MXC sandbox audit evidence is incomplete".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn properties(&self) -> BoundaryProperties {
|
||||
BoundaryProperties {
|
||||
filesystem_confinement: EnforcedProperty::new(
|
||||
self.default_deny_filesystem,
|
||||
"mxc-processcontainer-appcontainer",
|
||||
),
|
||||
egress_interception: EnforcedProperty::new(
|
||||
self.default_deny_egress && self.loopback_proxy_only,
|
||||
"mxc-wfp-loopback-proxy-fence",
|
||||
),
|
||||
request_attribution: EnforcedProperty::new(
|
||||
self.generation_scoped_attribution,
|
||||
"mxc-generation-authenticated-proxy",
|
||||
),
|
||||
privilege_floor: EnforcedProperty::new(
|
||||
self.process_container,
|
||||
"windows-appcontainer-token",
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Backend-owned audit formats understood by this Sandbox Protocol backend.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "platform", content = "evidence", rename_all = "snake_case")]
|
||||
pub enum OpenShellBoundaryAuditEvidence {
|
||||
Linux(OpenShellSandboxAuditEvidence),
|
||||
WindowsMxc(MxcSandboxAuditEvidence),
|
||||
}
|
||||
|
||||
impl OpenShellBoundaryAuditEvidence {
|
||||
pub fn validate(&self) -> Result<(), BackendError> {
|
||||
match self {
|
||||
Self::Linux(evidence) => evidence.validate(),
|
||||
Self::WindowsMxc(evidence) => evidence.validate(),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn properties(&self) -> BoundaryProperties {
|
||||
match self {
|
||||
Self::Linux(evidence) => evidence.properties(),
|
||||
Self::WindowsMxc(evidence) => evidence.properties(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ephemeral identity of the supervisor process that owns one sandbox runtime.
|
||||
///
|
||||
/// The supervisor generates this value in memory and presents it on every
|
||||
@@ -422,6 +508,12 @@ pub struct SandboxRuntimeDescriptor {
|
||||
/// network supervisor cannot use the boundary's resolver view.
|
||||
#[serde(default)]
|
||||
pub host_gateway_ip: Option<std::net::IpAddr>,
|
||||
/// Optional generation-scoped explicit proxy owned by the host
|
||||
/// supervisor. Backends set this only when their outer fence routes the
|
||||
/// workload to this listener and the boundary cannot provide staged
|
||||
/// socket mediation.
|
||||
#[serde(default)]
|
||||
pub direct_proxy: Option<openshell_isolation_interface::contract::DirectProxyConfiguration>,
|
||||
/// Driver-specific immutable resource coordinates bound at attach (for
|
||||
/// example pod UID, VM generation, or container ID).
|
||||
#[serde(default)]
|
||||
@@ -440,6 +532,7 @@ impl fmt::Debug for SandboxRuntimeDescriptor {
|
||||
.field("transport", &self.transport)
|
||||
.field("tls", &self.tls)
|
||||
.field("host_gateway_ip", &self.host_gateway_ip)
|
||||
.field("direct_proxy", &self.direct_proxy)
|
||||
.field("resource_claims", &self.resource_claims)
|
||||
.field("outer_fence", &self.outer_fence)
|
||||
.finish()
|
||||
@@ -496,6 +589,11 @@ pub struct BoundaryConfig {
|
||||
pub workload_identity: openshell_isolation_interface::contract::ResolvedWorkloadIdentity,
|
||||
/// Backend-neutral projection of the driver-validated outer fence.
|
||||
pub outer_fence: OuterFenceGuarantees,
|
||||
/// Authenticated proxy URL injected into workload children. It is staged
|
||||
/// only in this protected one-use configuration and is never inherited by
|
||||
/// the trusted sandbox process itself.
|
||||
#[serde(default)]
|
||||
pub direct_proxy_url: Option<String>,
|
||||
/// Driver-resolved environment exposed only to workload processes.
|
||||
#[serde(default)]
|
||||
pub child_env: std::collections::HashMap<String, String>,
|
||||
@@ -524,6 +622,10 @@ impl fmt::Debug for BoundaryConfig {
|
||||
.field("resource_claim_files", &self.resource_claim_files)
|
||||
.field("workload_identity", &self.workload_identity)
|
||||
.field("outer_fence", &self.outer_fence)
|
||||
.field(
|
||||
"direct_proxy_url",
|
||||
&self.direct_proxy_url.as_ref().map(|_| "<redacted>"),
|
||||
)
|
||||
.field("child_env_keys", &self.child_env.keys().collect::<Vec<_>>())
|
||||
.finish()
|
||||
}
|
||||
|
||||
@@ -99,6 +99,7 @@ impl IsolationBackend for OpenShellRuntimeBackend {
|
||||
})?;
|
||||
validate_runtime_descriptor(&runtime_descriptor, &sandbox)?;
|
||||
let host_gateway_ip = runtime_descriptor.host_gateway_ip;
|
||||
let direct_proxy = runtime_descriptor.direct_proxy.clone();
|
||||
let resource_claims = runtime_descriptor.resource_claims.clone();
|
||||
let generation = runtime_descriptor.generation.clone();
|
||||
let session_id = runtime_descriptor.session_id;
|
||||
@@ -129,6 +130,7 @@ impl IsolationBackend for OpenShellRuntimeBackend {
|
||||
sandbox_id: sandbox.sandbox_id,
|
||||
mediation: Arc::new(RemoteNetworkMediation { client }),
|
||||
host_gateway_ip,
|
||||
direct_proxy,
|
||||
ca_file_paths: self.ca_file_paths.clone(),
|
||||
provider_credentials: self.provider_credentials.clone(),
|
||||
identity: sandbox.identity,
|
||||
@@ -198,6 +200,18 @@ fn validate_runtime_descriptor(
|
||||
}
|
||||
}
|
||||
validate_client_tls(&runtime_descriptor.tls)?;
|
||||
if let Some(proxy) = &runtime_descriptor.direct_proxy
|
||||
&& (!proxy.bind_addr.ip().is_loopback()
|
||||
|| proxy.bind_addr.port() == 0
|
||||
|| proxy.authorization.trim().is_empty()
|
||||
|| proxy.authorization.contains(['\r', '\n'])
|
||||
|| !proxy.binary_identity.binary_path.is_absolute())
|
||||
{
|
||||
return Err(BackendError::Descriptor(
|
||||
"direct proxy requires a loopback listener, a single-line authorization value, and an absolute binary identity"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -274,6 +288,7 @@ struct RemoteBound {
|
||||
sandbox_id: String,
|
||||
mediation: Arc<RemoteNetworkMediation>,
|
||||
host_gateway_ip: Option<std::net::IpAddr>,
|
||||
direct_proxy: Option<openshell_isolation_interface::contract::DirectProxyConfiguration>,
|
||||
ca_file_paths: Arc<std::sync::Mutex<Option<(PathBuf, PathBuf)>>>,
|
||||
provider_credentials: openshell_core::provider_credentials::ProviderCredentialState,
|
||||
identity: openshell_isolation_interface::contract::ResolvedWorkloadIdentity,
|
||||
@@ -293,6 +308,12 @@ impl BoundBoundary for RemoteBound {
|
||||
self.host_gateway_ip
|
||||
}
|
||||
|
||||
fn direct_proxy_configuration(
|
||||
&self,
|
||||
) -> Option<openshell_isolation_interface::contract::DirectProxyConfiguration> {
|
||||
self.direct_proxy.clone()
|
||||
}
|
||||
|
||||
async fn confirm(self: Box<Self>) -> Result<ConfirmedBoundary, BackendError> {
|
||||
let response = self.client.call_idempotent(Request::Confirm).await?;
|
||||
let Response::Confirmed { confirmation } = response else {
|
||||
@@ -308,7 +329,7 @@ impl BoundBoundary for RemoteBound {
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
let audit: crate::boundary_protocol::OpenShellSandboxAuditEvidence =
|
||||
let audit: crate::boundary_protocol::OpenShellBoundaryAuditEvidence =
|
||||
serde_json::from_value(confirmation.backend_audit.clone()).map_err(|error| {
|
||||
BackendError::Confirm(format!("decode OpenShell sandbox audit evidence: {error}"))
|
||||
})?;
|
||||
@@ -2365,6 +2386,7 @@ mod tests {
|
||||
},
|
||||
tls,
|
||||
host_gateway_ip: None,
|
||||
direct_proxy: None,
|
||||
resource_claims: std::collections::BTreeMap::new(),
|
||||
outer_fence: test_outer_fence(),
|
||||
}
|
||||
@@ -2509,6 +2531,7 @@ mod tests {
|
||||
},
|
||||
tls: certificate.client_tls.clone(),
|
||||
host_gateway_ip: Some(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)),
|
||||
direct_proxy: None,
|
||||
resource_claims: std::collections::BTreeMap::new(),
|
||||
outer_fence: test_outer_fence(),
|
||||
};
|
||||
@@ -2529,6 +2552,7 @@ mod tests {
|
||||
},
|
||||
tls: test_certificate().client_tls,
|
||||
host_gateway_ip: None,
|
||||
direct_proxy: None,
|
||||
resource_claims: std::collections::BTreeMap::new(),
|
||||
outer_fence: test_outer_fence(),
|
||||
};
|
||||
@@ -2551,6 +2575,7 @@ mod tests {
|
||||
},
|
||||
tls: test_certificate().client_tls,
|
||||
host_gateway_ip: None,
|
||||
direct_proxy: None,
|
||||
resource_claims: std::collections::BTreeMap::new(),
|
||||
outer_fence: test_outer_fence(),
|
||||
};
|
||||
@@ -2573,6 +2598,7 @@ mod tests {
|
||||
},
|
||||
tls: test_certificate().client_tls,
|
||||
host_gateway_ip: None,
|
||||
direct_proxy: None,
|
||||
resource_claims: std::collections::BTreeMap::new(),
|
||||
outer_fence: test_outer_fence(),
|
||||
};
|
||||
@@ -2766,6 +2792,7 @@ mod tests {
|
||||
},
|
||||
tls: certificate.client_tls,
|
||||
host_gateway_ip: None,
|
||||
direct_proxy: None,
|
||||
resource_claims: std::collections::BTreeMap::new(),
|
||||
outer_fence: test_outer_fence(),
|
||||
},
|
||||
@@ -2853,6 +2880,7 @@ mod tests {
|
||||
},
|
||||
tls: certificate.client_tls,
|
||||
host_gateway_ip: None,
|
||||
direct_proxy: None,
|
||||
resource_claims: std::collections::BTreeMap::new(),
|
||||
outer_fence: test_outer_fence(),
|
||||
},
|
||||
|
||||
@@ -61,6 +61,7 @@ tokio-rustls = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
url = { workspace = true }
|
||||
|
||||
# Logging
|
||||
tracing = { workspace = true }
|
||||
@@ -77,6 +78,9 @@ seccompiler = "0.5"
|
||||
socket2 = { workspace = true }
|
||||
tempfile = "3"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
rcgen = { workspace = true }
|
||||
tempfile = "3"
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
mod windows;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux {
|
||||
use std::fs::File;
|
||||
@@ -59,11 +62,12 @@ mod linux {
|
||||
use openshell_sandbox_backend::boundary_protocol::{
|
||||
AgentSpecWire, BinaryIdentityWire, BoundaryConfig, BoundaryErrorKind,
|
||||
BoundaryListener as BoundaryListenerConfig, DnsQueryResultWire, ExecSpecWire,
|
||||
ExitStatusWire, MediationTimingWire, OpenShellSandboxAuditEvidence, OutputWindowWire,
|
||||
ProcessKindWire, ProcessSnapshotWire, Request, RequestEnvelope, Response, ResponseEnvelope,
|
||||
STREAM_EXIT, STREAM_NETWORK_DECISION, STREAM_STDERR, STREAM_STDIN, STREAM_STDIN_CLOSED,
|
||||
STREAM_STDOUT, SandboxPolicyWire, SessionSnapshotWire, SignalWire, encode_frame,
|
||||
read_frame, read_stream_frame, validate_resource_claims, write_frame, write_stream_frame,
|
||||
ExitStatusWire, MediationTimingWire, OpenShellBoundaryAuditEvidence,
|
||||
OpenShellSandboxAuditEvidence, OutputWindowWire, ProcessKindWire, ProcessSnapshotWire,
|
||||
Request, RequestEnvelope, Response, ResponseEnvelope, STREAM_EXIT, STREAM_NETWORK_DECISION,
|
||||
STREAM_STDERR, STREAM_STDIN, STREAM_STDIN_CLOSED, STREAM_STDOUT, SandboxPolicyWire,
|
||||
SessionSnapshotWire, SignalWire, encode_frame, read_frame, read_stream_frame,
|
||||
validate_resource_claims, write_frame, write_stream_frame,
|
||||
};
|
||||
|
||||
const CONTROL_IO_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
@@ -2298,7 +2302,7 @@ mod linux {
|
||||
// Keeping that decision at the verifier also lets lifecycle tests
|
||||
// exercise the protocol without claiming host-kernel enforcement.
|
||||
let properties = audit.properties();
|
||||
let backend_audit = serde_json::to_value(audit)
|
||||
let backend_audit = serde_json::to_value(OpenShellBoundaryAuditEvidence::Linux(audit))
|
||||
.map_err(|error| format!("encode OpenShell sandbox audit evidence: {error}"))?;
|
||||
Ok(BoundaryConfirmation {
|
||||
generation: self.config.generation.clone(),
|
||||
@@ -3650,6 +3654,7 @@ mod linux {
|
||||
resource_claim_files: std::collections::BTreeMap::new(),
|
||||
workload_identity: test_workload_identity(),
|
||||
outer_fence: test_outer_fence(),
|
||||
direct_proxy_url: None,
|
||||
child_env: std::collections::HashMap::new(),
|
||||
};
|
||||
let debug = format!("{config:?}");
|
||||
@@ -3801,6 +3806,7 @@ mod linux {
|
||||
resource_claim_files: std::collections::BTreeMap::new(),
|
||||
workload_identity: test_workload_identity(),
|
||||
outer_fence: test_outer_fence(),
|
||||
direct_proxy_url: None,
|
||||
child_env: std::collections::HashMap::new(),
|
||||
},
|
||||
tokio::runtime::Handle::current(),
|
||||
@@ -4316,6 +4322,7 @@ mod linux {
|
||||
resource_claim_files: std::collections::BTreeMap::new(),
|
||||
workload_identity: test_workload_identity(),
|
||||
outer_fence: test_outer_fence(),
|
||||
direct_proxy_url: None,
|
||||
child_env: std::collections::HashMap::new(),
|
||||
};
|
||||
|
||||
@@ -4351,6 +4358,7 @@ mod linux {
|
||||
)]),
|
||||
workload_identity: test_workload_identity(),
|
||||
outer_fence: test_outer_fence(),
|
||||
direct_proxy_url: None,
|
||||
child_env: std::collections::HashMap::new(),
|
||||
};
|
||||
|
||||
@@ -4391,6 +4399,7 @@ mod linux {
|
||||
resource_claim_files: std::collections::BTreeMap::new(),
|
||||
workload_identity: test_workload_identity(),
|
||||
outer_fence: test_outer_fence(),
|
||||
direct_proxy_url: None,
|
||||
child_env: std::collections::HashMap::new(),
|
||||
},
|
||||
tokio::runtime::Handle::current(),
|
||||
@@ -4566,6 +4575,7 @@ mod linux {
|
||||
resource_claim_files: std::collections::BTreeMap::new(),
|
||||
workload_identity: test_workload_identity(),
|
||||
outer_fence: test_outer_fence(),
|
||||
direct_proxy_url: None,
|
||||
child_env: std::collections::HashMap::new(),
|
||||
},
|
||||
process_runtime.handle().clone(),
|
||||
@@ -4839,6 +4849,7 @@ mod linux {
|
||||
resource_claim_files: std::collections::BTreeMap::new(),
|
||||
workload_identity: test_workload_identity(),
|
||||
outer_fence: test_outer_fence(),
|
||||
direct_proxy_url: None,
|
||||
child_env: std::collections::HashMap::new(),
|
||||
},
|
||||
process_runtime.handle().clone(),
|
||||
@@ -5092,10 +5103,18 @@ pub fn run_boundary(
|
||||
linux::run_boundary(config_path, qualification)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn run_boundary(
|
||||
config_path: &Path,
|
||||
qualification: crate::RuntimeQualification,
|
||||
) -> Result<(), String> {
|
||||
windows::run_boundary(config_path, qualification)
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||
pub fn run_boundary(
|
||||
_config_path: &Path,
|
||||
_qualification: crate::RuntimeQualification,
|
||||
) -> Result<(), String> {
|
||||
Err("boundary mode is supported only on Linux".to_string())
|
||||
Err("boundary mode is supported only on Linux and Windows".to_string())
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,9 +5,12 @@
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod accept_interrupt;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod boundary_exec;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod boundary_io;
|
||||
mod boundary_server;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod child_env;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) mod delegated;
|
||||
@@ -15,6 +18,7 @@ pub(crate) mod delegated;
|
||||
pub mod identity;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod main_session;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod managed_children;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod network_broker;
|
||||
@@ -22,7 +26,9 @@ mod network_broker;
|
||||
pub mod perf;
|
||||
#[cfg(unix)]
|
||||
pub mod process;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod pty;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod sandbox;
|
||||
|
||||
/// Results of actively qualifying the admitted workload runtime before the
|
||||
|
||||
@@ -9,11 +9,11 @@ use std::path::Path;
|
||||
|
||||
use clap::Parser;
|
||||
use miette::{IntoDiagnostic, Result};
|
||||
#[cfg(target_os = "linux")]
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
use openshell_ocsf::OcsfShorthandLayer;
|
||||
#[cfg(target_os = "linux")]
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
use tracing_subscriber::EnvFilter;
|
||||
#[cfg(target_os = "linux")]
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
use tracing_subscriber::{Layer, layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
/// Subcommand name used to self-copy the sandbox binary into a shared volume.
|
||||
@@ -24,7 +24,7 @@ use tracing_subscriber::{Layer, layer::SubscriberExt, util::SubscriberInitExt};
|
||||
const COPY_SELF_SUBCOMMAND: &str = "copy-self";
|
||||
const BOOTSTRAP_SUBCOMMAND: &str = "bootstrap";
|
||||
const SEED_WORKSPACE_SUBCOMMAND: &str = "seed-workspace";
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
#[cfg(target_os = "linux")]
|
||||
const KUBERNETES_BOOTSTRAP_SECRET_FILES: [&str; 3] = ["boundary.json", "tls.crt", "tls.key"];
|
||||
#[cfg(target_os = "linux")]
|
||||
const BOOTSTRAP_INPUT_ROOT: &str = "/.openshell/bootstrap-input";
|
||||
@@ -1624,7 +1624,7 @@ fn run_kubernetes_bootstrap() -> Result<()> {
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
#[cfg(target_os = "linux")]
|
||||
fn stage_kubernetes_bootstrap_at(source: &Path, runtime: &Path, state: &Path) -> Result<()> {
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
@@ -1681,7 +1681,7 @@ fn stage_kubernetes_bootstrap_at(source: &Path, runtime: &Path, state: &Path) ->
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
#[cfg(target_os = "linux")]
|
||||
fn copy_projected_secret_file(
|
||||
source_root: &Path,
|
||||
name: &str,
|
||||
@@ -1699,7 +1699,7 @@ fn copy_projected_secret_file(
|
||||
copy_regular_file(&canonical_source, destination, mode)
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
#[cfg(target_os = "linux")]
|
||||
fn copy_regular_file(source: &Path, destination: &Path, mode: u32) -> Result<()> {
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::{Read as _, Write as _};
|
||||
@@ -1736,10 +1736,12 @@ fn copy_regular_file(source: &Path, destination: &Path, mode: u32) -> Result<()>
|
||||
|
||||
/// Seed the persistent workspace from the agent image as the final workload
|
||||
/// identity. This replaces the former root shell/tar init container.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn seed_kubernetes_workspace() -> Result<()> {
|
||||
seed_kubernetes_workspace_at(Path::new("/sandbox"), Path::new("/mnt/openshell-workspace"))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn copy_workspace_tree(source: &Path, destination: &Path) -> Result<()> {
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::{Read as _, Write as _};
|
||||
@@ -1792,6 +1794,7 @@ fn copy_workspace_tree(source: &Path, destination: &Path) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn seed_kubernetes_workspace_at(source: &Path, destination: &Path) -> Result<()> {
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::Write as _;
|
||||
@@ -1836,6 +1839,13 @@ fn seed_kubernetes_workspace_at(source: &Path, destination: &Path) -> Result<()>
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn seed_kubernetes_workspace() -> Result<()> {
|
||||
Err(miette::miette!(
|
||||
"Kubernetes workspace seeding is supported only on Linux"
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn run_boundary(bootstrap: &Path, log_level: &str) -> Result<()> {
|
||||
let console_filter =
|
||||
@@ -1851,9 +1861,25 @@ fn run_boundary(bootstrap: &Path, log_level: &str) -> Result<()> {
|
||||
openshell_sandbox::run(bootstrap, qualification)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
#[cfg(target_os = "windows")]
|
||||
fn run_boundary(bootstrap: &Path, log_level: &str) -> Result<()> {
|
||||
let console_filter =
|
||||
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(log_level));
|
||||
let _ = tracing_subscriber::registry()
|
||||
.with(
|
||||
OcsfShorthandLayer::new(std::io::stderr())
|
||||
.with_non_ocsf(true)
|
||||
.with_filter(console_filter),
|
||||
)
|
||||
.try_init();
|
||||
openshell_sandbox::run(bootstrap, openshell_sandbox::RuntimeQualification)
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||
fn run_boundary(_bootstrap: &Path, _log_level: &str) -> Result<()> {
|
||||
Err(miette::miette!("openshell-sandbox requires Linux"))
|
||||
Err(miette::miette!(
|
||||
"openshell-sandbox requires Linux or Windows"
|
||||
))
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
@@ -1903,7 +1929,7 @@ fn main() -> Result<()> {
|
||||
run_boundary(&args.bootstrap, &args.log_level)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(all(test, target_os = "linux"))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
@@ -698,9 +698,6 @@ pub struct ComputeRuntime {
|
||||
lifecycle_gates: Arc<LifecycleGateRegistry>,
|
||||
gateway_listener_requirements: Vec<GatewayListenerRequirement>,
|
||||
replica_id: String,
|
||||
/// Dynamic TCP forward capability contributed by the active in-process
|
||||
/// driver, if it has one. See `forward_sink`.
|
||||
forward_sink: Option<Arc<dyn crate::ComputeDriverForwardSink>>,
|
||||
/// 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.
|
||||
@@ -836,7 +833,6 @@ impl ComputeRuntime {
|
||||
lifecycle_gates: Arc::new(LifecycleGateRegistry::default()),
|
||||
gateway_listener_requirements,
|
||||
replica_id: lease::replica_id(),
|
||||
forward_sink: None,
|
||||
rootfs_tar_staging,
|
||||
})
|
||||
}
|
||||
@@ -889,22 +885,6 @@ 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<dyn crate::ComputeDriverForwardSink>) {
|
||||
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<dyn crate::ComputeDriverForwardSink>> {
|
||||
self.forward_sink.as_ref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn default_image(&self) -> &str {
|
||||
&self.default_image
|
||||
@@ -5482,7 +5462,6 @@ pub 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()),
|
||||
}
|
||||
}
|
||||
@@ -6421,7 +6400,6 @@ 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()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ use crate::persistence::{
|
||||
ObjectLabels, ObjectListQuery, ObjectType, WriteCondition, generate_name,
|
||||
};
|
||||
use futures::future;
|
||||
use openshell_core::net::{connect_tcp_nodelay_best_effort, set_tcp_nodelay_best_effort};
|
||||
use openshell_core::net::set_tcp_nodelay_best_effort;
|
||||
use openshell_core::proto::datamodel::v1::ObjectMeta;
|
||||
use openshell_core::proto::{
|
||||
AttachSandboxProviderRequest, AttachSandboxProviderResponse, CreateSandboxRequest,
|
||||
@@ -2042,67 +2042,6 @@ 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::<Result<TcpForwardFrame, Status>>(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<dyn tokio_stream::Stream<Item = Result<TcpForwardFrame, Status>> + Send + 'static>,
|
||||
> = Box::pin(ReceiverStream::new(rx));
|
||||
return Ok(Response::new(stream));
|
||||
}
|
||||
|
||||
let (channel_id, relay_rx) = state
|
||||
.supervisor_sessions
|
||||
.open_relay_with_target(
|
||||
|
||||
@@ -1115,28 +1115,6 @@ pub enum ComputeDriverInstance {
|
||||
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<u8>, Box<dyn std::any::Any + Send>), String>;
|
||||
}
|
||||
|
||||
/// Factory for a compute driver linked into a gateway binary.
|
||||
#[async_trait::async_trait]
|
||||
pub trait ComputeDriverFactory: Send + Sync {
|
||||
@@ -1455,7 +1433,6 @@ impl ComputeDriverConfigContext<'_> {
|
||||
pub struct ComputeDriverBuildContext<'a> {
|
||||
config: ComputeDriverConfigContext<'a>,
|
||||
shutdown_rx: watch::Receiver<bool>,
|
||||
forward_sink: Arc<Mutex<Option<Arc<dyn ComputeDriverForwardSink>>>>,
|
||||
}
|
||||
|
||||
impl ComputeDriverBuildContext<'_> {
|
||||
@@ -1523,13 +1500,6 @@ 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<dyn ComputeDriverForwardSink>) {
|
||||
*self.forward_sink.lock().unwrap() = Some(sink);
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -1569,8 +1539,6 @@ async fn build_compute_runtime(
|
||||
|
||||
let runtime = match driver {
|
||||
ConfiguredComputeDriver::Registered(registration) => {
|
||||
let forward_sink: Arc<Mutex<Option<Arc<dyn ComputeDriverForwardSink>>>> =
|
||||
Arc::new(Mutex::new(None));
|
||||
let build_context = ComputeDriverBuildContext {
|
||||
config: ComputeDriverConfigContext {
|
||||
driver_name: ®istration.name,
|
||||
@@ -1580,57 +1548,42 @@ 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) => {
|
||||
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::InProcess(driver) => 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}"))
|
||||
})?,
|
||||
ComputeDriverInstance::InProcessWithProviderCredentials {
|
||||
driver,
|
||||
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
|
||||
}
|
||||
} => 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}"))
|
||||
})?,
|
||||
ComputeDriverInstance::ManagedRemote(mut endpoint) => {
|
||||
endpoint.name = registration.name;
|
||||
ComputeRuntime::new_remote_driver(
|
||||
|
||||
@@ -203,6 +203,7 @@ pub async fn run_networking(
|
||||
host_gateway_ip: Option<IpAddr>,
|
||||
#[cfg(target_os = "linux")] transparent_runtime: Option<TransparentRuntimeSetup>,
|
||||
network_mediation_source: Option<Arc<dyn NetworkMediationSource>>,
|
||||
direct_proxy: Option<openshell_isolation_interface::contract::DirectProxyConfiguration>,
|
||||
) -> Result<Networking> {
|
||||
// Build the policy-local route context. The orchestrator's policy poll
|
||||
// loop also holds an `Arc` clone (via `Networking::policy_local_ctx`) so
|
||||
@@ -469,10 +470,15 @@ pub async fn run_networking(
|
||||
// originating inside the namespace can reach the proxy. Otherwise the
|
||||
// proxy falls back to the policy-declared http_addr (loopback in
|
||||
// tests, etc.).
|
||||
let bind_addr = proxy_bind_ip.map(|ip| {
|
||||
let port = proxy_policy.http_addr.map_or(3128, |addr| addr.port());
|
||||
SocketAddr::new(ip, port)
|
||||
});
|
||||
let bind_addr = direct_proxy
|
||||
.as_ref()
|
||||
.map(|proxy| proxy.bind_addr)
|
||||
.or_else(|| {
|
||||
proxy_bind_ip.map(|ip| {
|
||||
let port = proxy_policy.http_addr.map_or(3128, |addr| addr.port());
|
||||
SocketAddr::new(ip, port)
|
||||
})
|
||||
});
|
||||
|
||||
let proxy_handle = ProxyHandle::start_with_bind_addr(
|
||||
proxy_policy,
|
||||
@@ -493,8 +499,12 @@ pub async fn run_networking(
|
||||
mediated_policy_dns
|
||||
.as_ref()
|
||||
.map(|runtime| runtime.store.clone()),
|
||||
None,
|
||||
None,
|
||||
direct_proxy
|
||||
.as_ref()
|
||||
.map(|proxy| proxy.binary_identity.clone()),
|
||||
direct_proxy
|
||||
.as_ref()
|
||||
.map(|proxy| Arc::<str>::from(proxy.authorization.clone())),
|
||||
)
|
||||
.await?;
|
||||
Some(proxy_handle)
|
||||
|
||||
@@ -11,8 +11,10 @@ use miette::Result;
|
||||
use openshell_isolation_interface::contract::{
|
||||
BoundaryExec, BoundaryLoopbackConnector, BoundaryProcess,
|
||||
};
|
||||
#[cfg(unix)]
|
||||
use openshell_ocsf::{ActivityId, AppLifecycleBuilder, SeverityId, StatusId, ocsf_emit};
|
||||
|
||||
#[cfg(unix)]
|
||||
fn ocsf_ctx() -> &'static openshell_ocsf::EventContext {
|
||||
openshell_ocsf::ctx::ctx()
|
||||
}
|
||||
@@ -77,6 +79,7 @@ impl Drop for BoundaryAccess {
|
||||
/// Start the supervisor access plane using sandbox-supplied exec and
|
||||
/// loopback-forwarding capabilities.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
#[cfg_attr(not(unix), allow(unused_variables))]
|
||||
pub async fn start_boundary_access(
|
||||
sandbox_id: Option<&str>,
|
||||
openshell_endpoint: Option<&str>,
|
||||
@@ -100,105 +103,114 @@ pub async fn start_boundary_access(
|
||||
main_session: None,
|
||||
});
|
||||
};
|
||||
#[cfg(not(unix))]
|
||||
return Err(miette::miette!(
|
||||
"SSH access sockets are unsupported by the Windows supervisor"
|
||||
));
|
||||
|
||||
let attachment = agent
|
||||
.attach()
|
||||
.await
|
||||
.map_err(|error| miette::miette!(error.to_string()))?;
|
||||
let main_session = crate::main_session::MainSession::from_boundary(attachment, agent);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let attachment = agent
|
||||
.attach()
|
||||
.await
|
||||
.map_err(|error| miette::miette!(error.to_string()))?;
|
||||
let main_session = crate::main_session::MainSession::from_boundary(attachment, agent);
|
||||
|
||||
let (ssh_ready_tx, ssh_ready_rx) = tokio::sync::oneshot::channel();
|
||||
let listen_path = ssh_socket_path.clone();
|
||||
let ssh_port_forward = port_forward.clone();
|
||||
let ssh_main_session = main_session.clone();
|
||||
let ssh_task = tokio::spawn(async move {
|
||||
if let Err(error) = crate::ssh::run_ssh_server(
|
||||
listen_path,
|
||||
ssh_ready_tx,
|
||||
ca_file_paths,
|
||||
shared_ssh_socket,
|
||||
ssh_port_forward,
|
||||
boundary_exec,
|
||||
Some(ssh_main_session),
|
||||
)
|
||||
.await
|
||||
{
|
||||
ocsf_emit!(
|
||||
AppLifecycleBuilder::new(ocsf_ctx())
|
||||
.activity(ActivityId::Fail)
|
||||
.severity(SeverityId::Critical)
|
||||
.status(StatusId::Failure)
|
||||
.message(format!("SSH server failed: {error}"))
|
||||
.build()
|
||||
);
|
||||
}
|
||||
});
|
||||
let (ssh_ready_tx, ssh_ready_rx) = tokio::sync::oneshot::channel();
|
||||
let listen_path = ssh_socket_path.clone();
|
||||
let ssh_port_forward = port_forward.clone();
|
||||
let ssh_main_session = main_session.clone();
|
||||
let ssh_task = tokio::spawn(async move {
|
||||
if let Err(error) = crate::ssh::run_ssh_server(
|
||||
listen_path,
|
||||
ssh_ready_tx,
|
||||
ca_file_paths,
|
||||
shared_ssh_socket,
|
||||
ssh_port_forward,
|
||||
boundary_exec,
|
||||
Some(ssh_main_session),
|
||||
)
|
||||
.await
|
||||
{
|
||||
ocsf_emit!(
|
||||
AppLifecycleBuilder::new(ocsf_ctx())
|
||||
.activity(ActivityId::Fail)
|
||||
.severity(SeverityId::Critical)
|
||||
.status(StatusId::Failure)
|
||||
.message(format!("SSH server failed: {error}"))
|
||||
.build()
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
match tokio::time::timeout(Duration::from_secs(10), ssh_ready_rx).await {
|
||||
Ok(Ok(Ok(()))) => {}
|
||||
Ok(Ok(Err(error))) => {
|
||||
ssh_task.abort();
|
||||
return Err(error.context("SSH server failed during startup"));
|
||||
}
|
||||
Ok(Err(_)) => {
|
||||
ssh_task.abort();
|
||||
return Err(miette::miette!(
|
||||
"SSH server task ended before signaling readiness"
|
||||
));
|
||||
}
|
||||
Err(_) => {
|
||||
ssh_task.abort();
|
||||
return Err(miette::miette!(
|
||||
"SSH server did not start within 10 seconds"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let (session_task, session_readiness) = match (openshell_endpoint, sandbox_id) {
|
||||
(Some(endpoint), Some(id)) => {
|
||||
let (task, mut accepted) = crate::supervisor_session::spawn_with_readiness(
|
||||
endpoint.to_string(),
|
||||
id.to_string(),
|
||||
ssh_socket_path,
|
||||
port_forward,
|
||||
None,
|
||||
terminating.clone(),
|
||||
crate::supervisor_session::SessionRuntimeContext {
|
||||
instance_id: instance_id.clone(),
|
||||
session_id_updates: supervisor_session_updates,
|
||||
},
|
||||
);
|
||||
let accepted_result =
|
||||
tokio::time::timeout(Duration::from_secs(10), accepted.wait_for(|ready| *ready))
|
||||
.await
|
||||
.map(|result| result.map(|_| ()));
|
||||
match accepted_result {
|
||||
Ok(Ok(())) => (Some(task), Some(accepted)),
|
||||
Ok(Err(_)) => {
|
||||
task.abort();
|
||||
return Err(miette::miette!(
|
||||
"supervisor session ended before gateway acceptance"
|
||||
));
|
||||
}
|
||||
Err(_) => {
|
||||
task.abort();
|
||||
return Err(miette::miette!(
|
||||
"gateway did not accept supervisor session within 10 seconds"
|
||||
));
|
||||
}
|
||||
match tokio::time::timeout(Duration::from_secs(10), ssh_ready_rx).await {
|
||||
Ok(Ok(Ok(()))) => {}
|
||||
Ok(Ok(Err(error))) => {
|
||||
ssh_task.abort();
|
||||
return Err(error.context("SSH server failed during startup"));
|
||||
}
|
||||
Ok(Err(_)) => {
|
||||
ssh_task.abort();
|
||||
return Err(miette::miette!(
|
||||
"SSH server task ended before signaling readiness"
|
||||
));
|
||||
}
|
||||
Err(_) => {
|
||||
ssh_task.abort();
|
||||
return Err(miette::miette!(
|
||||
"SSH server did not start within 10 seconds"
|
||||
));
|
||||
}
|
||||
}
|
||||
_ => (None, None),
|
||||
};
|
||||
|
||||
Ok(BoundaryAccess {
|
||||
instance_id,
|
||||
terminating,
|
||||
ssh_task: Some(ssh_task),
|
||||
session_task,
|
||||
session_readiness,
|
||||
main_session: Some(main_session),
|
||||
})
|
||||
let (session_task, session_readiness) = match (openshell_endpoint, sandbox_id) {
|
||||
(Some(endpoint), Some(id)) => {
|
||||
let (task, mut accepted) = crate::supervisor_session::spawn_with_readiness(
|
||||
endpoint.to_string(),
|
||||
id.to_string(),
|
||||
ssh_socket_path,
|
||||
port_forward,
|
||||
None,
|
||||
terminating.clone(),
|
||||
crate::supervisor_session::SessionRuntimeContext {
|
||||
instance_id: instance_id.clone(),
|
||||
session_id_updates: supervisor_session_updates,
|
||||
},
|
||||
);
|
||||
let accepted_result = tokio::time::timeout(
|
||||
Duration::from_secs(10),
|
||||
accepted.wait_for(|ready| *ready),
|
||||
)
|
||||
.await
|
||||
.map(|result| result.map(|_| ()));
|
||||
match accepted_result {
|
||||
Ok(Ok(())) => (Some(task), Some(accepted)),
|
||||
Ok(Err(_)) => {
|
||||
task.abort();
|
||||
return Err(miette::miette!(
|
||||
"supervisor session ended before gateway acceptance"
|
||||
));
|
||||
}
|
||||
Err(_) => {
|
||||
task.abort();
|
||||
return Err(miette::miette!(
|
||||
"gateway did not accept supervisor session within 10 seconds"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => (None, None),
|
||||
};
|
||||
|
||||
Ok(BoundaryAccess {
|
||||
instance_id,
|
||||
terminating,
|
||||
ssh_task: Some(ssh_task),
|
||||
session_task,
|
||||
session_readiness,
|
||||
main_session: Some(main_session),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Report the canonical process exit until the gateway acknowledges it.
|
||||
|
||||
@@ -12,7 +12,9 @@ pub mod delegated;
|
||||
pub mod log_push;
|
||||
pub mod main_session;
|
||||
pub mod skills;
|
||||
#[cfg(unix)]
|
||||
pub mod ssh;
|
||||
pub mod supervisor_session;
|
||||
|
||||
#[cfg(unix)]
|
||||
mod unix_socket;
|
||||
|
||||
@@ -4,14 +4,19 @@
|
||||
//! Retained I/O multiplexer for the canonical sandbox process.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
#[cfg(unix)]
|
||||
use std::io::{Read, Write};
|
||||
#[cfg(unix)]
|
||||
use std::os::fd::AsRawFd;
|
||||
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use bytes::Bytes;
|
||||
#[cfg(unix)]
|
||||
use nix::fcntl::{FcntlArg, OFlag, fcntl};
|
||||
#[cfg(unix)]
|
||||
use nix::pty::Winsize;
|
||||
#[cfg(unix)]
|
||||
use tokio::io::unix::AsyncFd;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::sync::Notify;
|
||||
@@ -24,6 +29,7 @@ use openshell_isolation_interface::contract::{
|
||||
const OUTPUT_BUFFER_BYTES: usize = 1024 * 1024;
|
||||
|
||||
/// Canonical-process I/O retained by the supervisor session multiplexer.
|
||||
#[cfg(unix)]
|
||||
pub enum ProcessIo {
|
||||
Pty(std::fs::File),
|
||||
Pipes {
|
||||
@@ -191,12 +197,14 @@ impl MainOutputCursor {
|
||||
}
|
||||
|
||||
pub struct MainSession {
|
||||
#[cfg(unix)]
|
||||
pid: u32,
|
||||
terminal: bool,
|
||||
input: tokio::sync::mpsc::Sender<Vec<u8>>,
|
||||
output: Arc<OutputLog>,
|
||||
input_owner: Mutex<Option<u64>>,
|
||||
next_owner: AtomicU64,
|
||||
#[cfg(unix)]
|
||||
pty_master: Option<Arc<std::fs::File>>,
|
||||
boundary_process: Option<Arc<dyn BoundaryProcess>>,
|
||||
boundary_terminal: Option<Arc<dyn BoundaryTerminal>>,
|
||||
@@ -213,12 +221,14 @@ impl MainSession {
|
||||
pub fn inert() -> Arc<Self> {
|
||||
let (input, _input_rx) = tokio::sync::mpsc::channel(64);
|
||||
Arc::new(Self {
|
||||
#[cfg(unix)]
|
||||
pid: 1,
|
||||
terminal: false,
|
||||
input,
|
||||
output: OutputLog::new(),
|
||||
input_owner: Mutex::new(None),
|
||||
next_owner: AtomicU64::new(1),
|
||||
#[cfg(unix)]
|
||||
pty_master: None,
|
||||
boundary_process: None,
|
||||
boundary_terminal: None,
|
||||
@@ -234,7 +244,7 @@ impl MainSession {
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(all(test, unix))]
|
||||
pub fn terminal_for_test() -> (Arc<Self>, std::fs::File) {
|
||||
let pty = nix::pty::openpty(None, None).expect("open test PTY");
|
||||
let slave = std::fs::File::from(pty.slave);
|
||||
@@ -244,7 +254,7 @@ impl MainSession {
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(all(test, unix))]
|
||||
#[allow(unsafe_code)]
|
||||
pub fn terminal_size_for_test(&self) -> (u16, u16) {
|
||||
let master = self.pty_master.as_ref().expect("terminal PTY master");
|
||||
@@ -255,6 +265,7 @@ impl MainSession {
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
#[cfg(unix)]
|
||||
pub fn new(io: ProcessIo, pid: u32) -> Arc<Self> {
|
||||
let terminal = matches!(io, ProcessIo::Pty(_));
|
||||
let (input, input_rx) = tokio::sync::mpsc::channel::<Vec<u8>>(64);
|
||||
@@ -272,6 +283,7 @@ impl MainSession {
|
||||
output: OutputLog::new(),
|
||||
input_owner: Mutex::new(None),
|
||||
next_owner: AtomicU64::new(1),
|
||||
#[cfg(unix)]
|
||||
pty_master,
|
||||
boundary_process: None,
|
||||
boundary_terminal: None,
|
||||
@@ -306,12 +318,14 @@ impl MainSession {
|
||||
let terminal_mode = terminal.is_some();
|
||||
let (input, mut input_rx) = tokio::sync::mpsc::channel::<Vec<u8>>(64);
|
||||
let session = Arc::new(Self {
|
||||
#[cfg(unix)]
|
||||
pid: 0,
|
||||
terminal: terminal_mode,
|
||||
input,
|
||||
output: OutputLog::new(),
|
||||
input_owner: Mutex::new(None),
|
||||
next_owner: AtomicU64::new(1),
|
||||
#[cfg(unix)]
|
||||
pty_master: None,
|
||||
boundary_process: Some(process),
|
||||
boundary_terminal: terminal,
|
||||
@@ -364,6 +378,7 @@ impl MainSession {
|
||||
session
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn start_io(
|
||||
this: &Arc<Self>,
|
||||
io: ProcessIo,
|
||||
@@ -649,38 +664,47 @@ impl MainSession {
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
let Some(master) = self.pty_master.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let winsize = Winsize {
|
||||
ws_row: u16::try_from(rows.max(1)).unwrap_or(u16::MAX),
|
||||
ws_col: u16::try_from(columns.max(1)).unwrap_or(u16::MAX),
|
||||
ws_xpixel: u16::try_from(pixel_width).unwrap_or(u16::MAX),
|
||||
ws_ypixel: u16::try_from(pixel_height).unwrap_or(u16::MAX),
|
||||
};
|
||||
#[allow(unsafe_code)]
|
||||
unsafe {
|
||||
libc::ioctl(master.as_raw_fd(), libc::TIOCSWINSZ, &winsize);
|
||||
#[cfg(not(unix))]
|
||||
let _ = (columns, rows, pixel_width, pixel_height);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let Some(master) = self.pty_master.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let winsize = Winsize {
|
||||
ws_row: u16::try_from(rows.max(1)).unwrap_or(u16::MAX),
|
||||
ws_col: u16::try_from(columns.max(1)).unwrap_or(u16::MAX),
|
||||
ws_xpixel: u16::try_from(pixel_width).unwrap_or(u16::MAX),
|
||||
ws_ypixel: u16::try_from(pixel_height).unwrap_or(u16::MAX),
|
||||
};
|
||||
#[allow(unsafe_code)]
|
||||
unsafe {
|
||||
libc::ioctl(master.as_raw_fd(), libc::TIOCSWINSZ, &winsize);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn signal_group(&self, signal: nix::sys::signal::Signal) -> Result<(), String> {
|
||||
pub async fn signal_group(&self, signal: BoundarySignal) -> Result<(), String> {
|
||||
if let Some(process) = self.boundary_process.as_ref() {
|
||||
let signal = match signal {
|
||||
nix::sys::signal::Signal::SIGHUP => BoundarySignal::Hup,
|
||||
nix::sys::signal::Signal::SIGINT => BoundarySignal::Int,
|
||||
nix::sys::signal::Signal::SIGKILL => BoundarySignal::Kill,
|
||||
nix::sys::signal::Signal::SIGTERM => BoundarySignal::Term,
|
||||
other => return Err(format!("boundary signal {other:?} is unsupported")),
|
||||
};
|
||||
return process
|
||||
.signal(signal)
|
||||
.await
|
||||
.map_err(|error| error.to_string());
|
||||
}
|
||||
let pid = i32::try_from(self.pid).unwrap_or(i32::MAX);
|
||||
nix::sys::signal::kill(nix::unistd::Pid::from_raw(-pid), signal)
|
||||
.map_err(|error| error.to_string())
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let pid = i32::try_from(self.pid).unwrap_or(i32::MAX);
|
||||
let signal = match signal {
|
||||
BoundarySignal::Hup => nix::sys::signal::Signal::SIGHUP,
|
||||
BoundarySignal::Int => nix::sys::signal::Signal::SIGINT,
|
||||
BoundarySignal::Kill => nix::sys::signal::Signal::SIGKILL,
|
||||
BoundarySignal::Term => nix::sys::signal::Signal::SIGTERM,
|
||||
};
|
||||
nix::sys::signal::kill(nix::unistd::Pid::from_raw(-pid), signal)
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
Err("local process-group signaling is unsupported on Windows".to_string())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
@@ -694,6 +718,7 @@ impl MainSession {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn set_nonblocking(file: &std::fs::File) -> Result<(), nix::errno::Errno> {
|
||||
let flags = fcntl(file.as_raw_fd(), FcntlArg::F_GETFL)?;
|
||||
let flags = OFlag::from_bits_truncate(flags);
|
||||
@@ -778,10 +803,7 @@ mod tests {
|
||||
|
||||
session.resize(120, 40, 0, 0).await;
|
||||
assert_eq!(*terminal.size.lock().unwrap(), Some((120, 40)));
|
||||
session
|
||||
.signal_group(nix::sys::signal::Signal::SIGINT)
|
||||
.await
|
||||
.unwrap();
|
||||
session.signal_group(BoundarySignal::Int).await.unwrap();
|
||||
assert_eq!(*process.signals.lock().unwrap(), vec![BoundarySignal::Int]);
|
||||
}
|
||||
|
||||
|
||||
@@ -841,11 +841,10 @@ impl russh::server::Handler for SshHandler {
|
||||
.is_some_and(|state| state.main_attached)
|
||||
{
|
||||
let signal = match signal {
|
||||
Sig::HUP => Some(nix::sys::signal::Signal::SIGHUP),
|
||||
Sig::INT => Some(nix::sys::signal::Signal::SIGINT),
|
||||
Sig::KILL => Some(nix::sys::signal::Signal::SIGKILL),
|
||||
Sig::QUIT => Some(nix::sys::signal::Signal::SIGQUIT),
|
||||
Sig::TERM => Some(nix::sys::signal::Signal::SIGTERM),
|
||||
Sig::HUP => Some(openshell_isolation_interface::contract::BoundarySignal::Hup),
|
||||
Sig::INT => Some(openshell_isolation_interface::contract::BoundarySignal::Int),
|
||||
Sig::KILL => Some(openshell_isolation_interface::contract::BoundarySignal::Kill),
|
||||
Sig::TERM => Some(openshell_isolation_interface::contract::BoundarySignal::Term),
|
||||
_ => None,
|
||||
};
|
||||
if let (Some(signal), Some(main_session)) = (signal, self.main_session.as_ref())
|
||||
|
||||
@@ -769,22 +769,29 @@ async fn open_target(
|
||||
port_forward: &Arc<dyn BoundaryLoopbackConnector>,
|
||||
expected_ssh_peer_pid: Option<u32>,
|
||||
) -> Result<Box<dyn TargetStream>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
#[cfg(not(unix))]
|
||||
let _ = (ssh_socket_path, expected_ssh_peer_pid);
|
||||
match relay_open.target.as_ref() {
|
||||
Some(relay_open::Target::Tcp(target)) => open_tcp_target(target, port_forward).await,
|
||||
Some(relay_open::Target::Ssh(_)) | None => {
|
||||
let runtime_path = crate::unix_socket::runtime_path(ssh_socket_path);
|
||||
let stream = tokio::net::UnixStream::connect(runtime_path.as_ref()).await?;
|
||||
if let Some(expected_pid) = expected_ssh_peer_pid {
|
||||
let credentials = stream.peer_cred()?;
|
||||
let actual_pid = credentials.pid().and_then(|pid| u32::try_from(pid).ok());
|
||||
if actual_pid != Some(expected_pid) {
|
||||
return Err(format!(
|
||||
#[cfg(not(unix))]
|
||||
return Err("SSH relay targets are unsupported by the Windows supervisor".into());
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let runtime_path = crate::unix_socket::runtime_path(ssh_socket_path);
|
||||
let stream = tokio::net::UnixStream::connect(runtime_path.as_ref()).await?;
|
||||
if let Some(expected_pid) = expected_ssh_peer_pid {
|
||||
let credentials = stream.peer_cred()?;
|
||||
let actual_pid = credentials.pid().and_then(|pid| u32::try_from(pid).ok());
|
||||
if actual_pid != Some(expected_pid) {
|
||||
return Err(format!(
|
||||
"SSH relay peer PID mismatch: expected {expected_pid}, got {actual_pid:?}"
|
||||
)
|
||||
.into());
|
||||
}
|
||||
}
|
||||
Ok(Box::new(stream))
|
||||
}
|
||||
Ok(Box::new(stream))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
# 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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,32 +0,0 @@
|
||||
// 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);
|
||||
}
|
||||
@@ -1,788 +0,0 @@
|
||||
// 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<BufReader<ChildStdout>>,
|
||||
// `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<tokio::sync::mpsc::UnboundedReceiver<String>>,
|
||||
}
|
||||
|
||||
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"], 3);
|
||||
}
|
||||
|
||||
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:<nonce>` -- 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<TcpStream> {
|
||||
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_shutdown_half_closes_target_and_preserves_its_response() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let target_port = listener.local_addr().unwrap().port();
|
||||
let (request_tx, request_rx) = tokio::sync::oneshot::channel();
|
||||
tokio::spawn(async move {
|
||||
let (mut stream, _) = listener.accept().await.unwrap();
|
||||
let mut request = Vec::new();
|
||||
stream.read_to_end(&mut request).await.unwrap();
|
||||
request_tx.send(request).unwrap();
|
||||
stream.write_all(b"response-after-eof").await.unwrap();
|
||||
});
|
||||
|
||||
let mut relay = RelayProcess::spawn(0).await;
|
||||
relay.expect_ready().await;
|
||||
let session_id = "c".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}));
|
||||
|
||||
for (id, payload) in [(2, b"request-".as_slice()), (3, b"body".as_slice())] {
|
||||
relay
|
||||
.send(json!({
|
||||
"id": id, "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": id, "ok": true}));
|
||||
}
|
||||
|
||||
relay
|
||||
.send(json!({
|
||||
"id": 4, "op": "forward_shutdown", "data": {"session_id": session_id},
|
||||
}))
|
||||
.await;
|
||||
assert_eq!(relay.next_json().await, json!({"id": 4, "ok": true}));
|
||||
assert_eq!(
|
||||
tokio::time::timeout(TIMEOUT, request_rx)
|
||||
.await
|
||||
.expect("target did not observe EOF")
|
||||
.unwrap(),
|
||||
b"request-body"
|
||||
);
|
||||
|
||||
let mut response = Vec::new();
|
||||
let mut saw_eof = false;
|
||||
for id in 5..100 {
|
||||
relay
|
||||
.send(json!({
|
||||
"id": id, "op": "forward_read", "data": {"session_id": session_id},
|
||||
}))
|
||||
.await;
|
||||
let frame = relay.next_json().await;
|
||||
assert_eq!(frame["ok"], true, "forward_read failed: {frame}");
|
||||
let data = &frame["data"];
|
||||
let encoded = data["bytes"].as_str().unwrap();
|
||||
response.extend(
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(encoded)
|
||||
.unwrap(),
|
||||
);
|
||||
if data["eof"] == true {
|
||||
saw_eof = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(saw_eof, "target response never reached EOF");
|
||||
assert_eq!(response, b"response-after-eof");
|
||||
|
||||
relay
|
||||
.send(json!({
|
||||
"id": 100, "op": "forward_close", "data": {"session_id": session_id},
|
||||
}))
|
||||
.await;
|
||||
assert_eq!(relay.next_json().await, json!({"id": 100, "ok": true}));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn slow_forward_read_does_not_block_other_control_requests() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let target_port = listener.local_addr().unwrap().port();
|
||||
let target = tokio::spawn(async move {
|
||||
let (_stream, _) = listener.accept().await.unwrap();
|
||||
std::future::pending::<()>().await;
|
||||
});
|
||||
let mut relay = RelayProcess::spawn(0).await;
|
||||
relay.expect_ready().await;
|
||||
let session_id = "b".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}));
|
||||
|
||||
// forward_read long-polls the silent socket for 100 ms. A serial control
|
||||
// loop returns id 2 first; independent request tasks let ping complete
|
||||
// immediately while the forwarding session remains blocked.
|
||||
relay
|
||||
.send(json!({
|
||||
"id": 2, "op": "forward_read", "data": {"session_id": session_id},
|
||||
}))
|
||||
.await;
|
||||
relay.send(json!({"id": 3, "op": "ping"})).await;
|
||||
assert_eq!(
|
||||
relay.next_json().await,
|
||||
json!({"id": 3, "ok": true, "data": "pong"})
|
||||
);
|
||||
|
||||
target.abort();
|
||||
}
|
||||
|
||||
#[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:?}"),
|
||||
}
|
||||
}
|
||||
@@ -89,11 +89,13 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
struct ControlReadiness {
|
||||
task: tokio::task::JoinHandle<()>,
|
||||
path: std::path::PathBuf,
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
impl ControlReadiness {
|
||||
fn start(
|
||||
path: std::path::PathBuf,
|
||||
@@ -221,6 +223,7 @@ fn prepare_control_readiness_path(path: &std::path::Path) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
impl Drop for ControlReadiness {
|
||||
fn drop(&mut self) {
|
||||
self.task.abort();
|
||||
@@ -228,6 +231,21 @@ impl Drop for ControlReadiness {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
struct ControlReadiness;
|
||||
|
||||
#[cfg(not(unix))]
|
||||
impl ControlReadiness {
|
||||
fn start(
|
||||
_path: std::path::PathBuf,
|
||||
_session_readiness: Option<tokio::sync::watch::Receiver<bool>>,
|
||||
) -> Result<Self> {
|
||||
Err(miette::miette!(
|
||||
"supervisor readiness sockets require a Unix host"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether the live supervisor owns its private readiness socket.
|
||||
#[cfg(unix)]
|
||||
pub fn check_control_readiness(path: &std::path::Path) -> Result<()> {
|
||||
@@ -509,6 +527,7 @@ pub async fn run_network_proxy(
|
||||
#[cfg(target_os = "linux")]
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -869,7 +888,10 @@ pub async fn run_sandbox(
|
||||
// API read the current value so proposals target the correct workspace.
|
||||
let (workspace_tx, workspace_rx) = tokio::sync::watch::channel(String::new());
|
||||
|
||||
let remote_network_source = remote_boundary.0.network_mediation_source();
|
||||
let direct_proxy = remote_boundary.0.direct_proxy_configuration();
|
||||
let remote_network_source = direct_proxy
|
||||
.is_none()
|
||||
.then(|| remote_boundary.0.network_mediation_source());
|
||||
let remote_host_gateway_ip = remote_boundary.0.host_gateway_ip();
|
||||
let (remote_ready, backend_name, ca_file_paths) = {
|
||||
let (bound, backend_name, ca_file_paths) = remote_boundary;
|
||||
@@ -907,7 +929,8 @@ pub async fn run_sandbox(
|
||||
remote_host_gateway_ip,
|
||||
#[cfg(target_os = "linux")]
|
||||
None,
|
||||
Some(remote_network_source),
|
||||
remote_network_source,
|
||||
direct_proxy,
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
@@ -4360,6 +4383,7 @@ mod tests {
|
||||
assert!(prepare_network_proxy_tls_dir(Some(writable)).is_err());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn control_readiness_exists_only_while_guard_is_live() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
@@ -4373,6 +4397,7 @@ mod tests {
|
||||
assert!(check_control_readiness(&path).is_err());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn control_readiness_tracks_supervisor_session() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
@@ -4401,6 +4426,7 @@ mod tests {
|
||||
.expect("replacement session restores readiness socket");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn control_readiness_rejects_relative_path() {
|
||||
let error = prepare_control_readiness_path(std::path::Path::new("health.sock"))
|
||||
|
||||
@@ -660,13 +660,14 @@ log_level = "info"
|
||||
compute_driver = "mxc"
|
||||
|
||||
[openshell.drivers.mxc]
|
||||
wxc_exec_path = "C:\\mxc-kit\\bin\\wxc-exec.exe"
|
||||
backend = "process_container"
|
||||
default_configuration_id = "composable"
|
||||
pc_least_privilege = false
|
||||
pc_capabilities = []
|
||||
debug = false
|
||||
etw_audit = true
|
||||
wxc_exec_path = "C:\\mxc-kit\\bin\\wxc-exec.exe"
|
||||
supervisor_binary_path = "C:\\OpenShell\\openshell-supervisor.exe"
|
||||
sandbox_binary_path = "C:\\OpenShell\\openshell-sandbox.exe"
|
||||
backend = "process_container"
|
||||
pc_least_privilege = false
|
||||
pc_capabilities = []
|
||||
debug = false
|
||||
etw_audit = true
|
||||
```
|
||||
|
||||
`etw_audit` defaults to `false`. When enabled, the gateway account must be an
|
||||
@@ -890,42 +891,47 @@ runtime-selected 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.
|
||||
The MXC driver is Windows-only and opt-in. It links into the gateway and invokes Microsoft MXC through `wxc-exec.exe`. The driver starts `openshell-supervisor --role=isolation-backend` on the host and `openshell-sandbox` inside each ProcessContainer. The standard authenticated Sandbox Protocol and supervisor session provide lifecycle, exec, forwarding, provider refresh, and network policy.
|
||||
|
||||
```toml
|
||||
[openshell]
|
||||
version = 1
|
||||
version = 2
|
||||
|
||||
[openshell.gateway]
|
||||
bind_address = "127.0.0.1:17670"
|
||||
log_level = "info"
|
||||
compute_drivers = ["mxc"]
|
||||
compute_driver = "mxc"
|
||||
# Required when gateway TLS is enabled. The gateway injects this bundle into
|
||||
# the host supervisor.
|
||||
guest_tls_ca = "C:\\OpenShell\\certs\\ca.pem"
|
||||
guest_tls_cert = "C:\\OpenShell\\certs\\client.pem"
|
||||
guest_tls_key = "C:\\OpenShell\\certs\\client-key.pem"
|
||||
|
||||
[openshell.drivers.mxc]
|
||||
wxc_exec_path = "C:\\mxc\\wxc-exec.exe"
|
||||
# process_container (default) or isolation_session.
|
||||
supervisor_binary_path = "C:\\OpenShell\\openshell-supervisor.exe"
|
||||
sandbox_binary_path = "C:\\OpenShell\\openshell-sandbox.exe"
|
||||
# Defaults to %LOCALAPPDATA%\OpenShell\mxc.
|
||||
state_dir = "C:\\Users\\operator\\AppData\\Local\\OpenShell\\mxc"
|
||||
# Empty derives the gateway loopback URL and its TLS mode.
|
||||
grpc_endpoint = ""
|
||||
# The RFC 0012 MXC path requires process_container.
|
||||
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 = ""
|
||||
pc_allow_local_network = true
|
||||
pc_minimal_env = false
|
||||
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 `<cwd>/.openshell-proxy/<sandbox-id>/`. 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 packaged supervisor and sandbox binaries default to siblings of `openshell-gateway.exe`; explicit paths are useful for development layouts. The driver protects host supervisor tokens and descriptors with an owner-only Windows DACL.
|
||||
|
||||
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 working directory through `sandbox create --driver-config-json`, for example `{"mxc":{"command":["C:\\Windows\\System32\\cmd.exe","/d","/c","echo hello"],"cwd":"C:\\work"}}`. Both are required. Supply workload environment through `sandbox create --env` or `--env-from`; it is not part of gateway configuration.
|
||||
|
||||
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.
|
||||
The driver assigns distinct Sandbox Protocol and proxy listeners plus fresh credentials to every generation. The host proxy rejects missing, invalid, duplicate, or cross-sandbox proxy credentials before forwarding. MXC denies direct Internet egress and permits only the `127.0.0.1/32` route required by the authenticated transport and proxy. That loopback exception does not isolate unrelated host services. The current explicit-proxy path attributes descendant traffic to the admitted main workload binary rather than resolving each Windows socket owner. Treat the gateway host as trusted and avoid policies that rely on different network rights for child executables.
|
||||
|
||||
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.
|
||||
Attached provider credentials use the ordinary live supervisor refresh path. Static values stay in the host supervisor's endpoint-bound resolver and are injected only for matching requests. Dynamic token grants remain request-time operations in the host proxy.
|
||||
|
||||
### MicroVM
|
||||
|
||||
|
||||
@@ -332,7 +332,7 @@ status = "not_run"
|
||||
[[capabilities]]
|
||||
id = "mxc-windows-driver-configuration"
|
||||
topics = ["mxc"]
|
||||
origin_main_access_paths = ["[openshell.drivers.mxc].{wxc_exec_path,backend,pc_least_privilege,pc_capabilities,default_configuration_id,debug}"]
|
||||
origin_main_access_paths = ["[openshell.drivers.mxc].{wxc_exec_path,supervisor_binary_path,sandbox_binary_path,state_dir,grpc_endpoint,backend,pc_least_privilege,pc_capabilities,pc_allow_local_network,pc_minimal_env,debug,etw_audit}"]
|
||||
schema_v2_access_paths = ["same [openshell.drivers.mxc] fields"]
|
||||
behavioral_oracle = "The Windows gateway selects MXC and passes the configured wxc-exec path, backend, AppContainer capabilities, isolation configuration, and debug flag to MXC."
|
||||
required_environment = "Windows host with MXC/wxc-exec; mock fixture for deterministic smoke variant"
|
||||
|
||||
@@ -5,461 +5,197 @@ state: review
|
||||
links:
|
||||
- https://github.com/NVIDIA/OpenShell/issues/2050
|
||||
- https://github.com/NVIDIA/OpenShell/pull/2071
|
||||
- https://github.com/NVIDIA/OpenShell/pull/3370
|
||||
---
|
||||
|
||||
# RFC 0013 - Native Windows Support via the MXC Compute Driver
|
||||
|
||||
<!--
|
||||
See rfc/README.md for the full RFC process and state definitions.
|
||||
-->
|
||||
|
||||
## Summary
|
||||
|
||||
This RFC proposes extending OpenShell to run natively on Windows 11 (x64 and
|
||||
ARM64) without a Linux VM, Docker Desktop, or WSL. It will produce a new
|
||||
compute driver, `openshell-driver-mxc`, that will use Microsoft
|
||||
Execution Containers (MXC, via `wxc-exec.exe`) as the sandbox primitive.
|
||||
OpenShell runs natively on Windows 11 by using Microsoft Execution Containers
|
||||
(MXC, through `wxc-exec.exe`) as an RFC 0012 isolation backend. The gateway's
|
||||
in-process MXC compute driver provisions a host
|
||||
`openshell-supervisor --role=isolation-backend` and an `openshell-sandbox`
|
||||
boundary inside each ProcessContainer.
|
||||
|
||||
The central architectural conclusion is that OpenShell rejects porting its Linux
|
||||
in-sandbox supervisor to Windows as part of this design. The value layers the
|
||||
supervisor delivers on Linux — egress policy enforcement (OPA), L7 HTTP
|
||||
inspection — are relocated to the host by running OpenShell's existing CONNECT
|
||||
proxy inside the driver process and pointing MXC's built-in `network.proxy`
|
||||
redirect at it. The integration therefore collapses to three moving parts: the
|
||||
native Windows OpenShell gateway, a Windows-only MXC compute driver crate, and an
|
||||
unmodified `wxc-exec` binary, with no OpenShell binary running inside the
|
||||
sandbox.
|
||||
The authenticated Sandbox Protocol is the only runtime control and forwarding
|
||||
transport. MXC supplies the Windows outer fence; the existing supervisor owns
|
||||
policy evaluation, credentials, network proxying, and the gateway session.
|
||||
|
||||
## Motivation
|
||||
|
||||
OpenShell sandboxes autonomous AI agents. On Linux it does so through the
|
||||
Docker, Podman, Kubernetes, and libkrun-VM compute drivers, each pairing a
|
||||
compute backend with an in-sandbox `openshell-sandbox` supervisor that enforces
|
||||
policy and runs the agent. None of those drivers give a first-class experience on
|
||||
Windows: Docker Desktop uses a WSL2-backed VM for Linux containers and adds
|
||||
licensing and resource overhead, WSL2 adds install and networking complexity, and
|
||||
Hyper-V/Windows Sandbox are heavy and require elevation. Today a Windows
|
||||
developer or enterprise host cannot run an OpenShell sandbox without standing up
|
||||
a Linux runtime underneath it.
|
||||
Docker Desktop and WSL2 add a Linux VM to Windows workflows. MXC provides a
|
||||
native AppContainer and ProcessContainer boundary, but the earlier
|
||||
supervisor-free prototype duplicated lifecycle, credential, forwarding, and
|
||||
proxy behavior in the driver and a workload relay. That duplicated security
|
||||
protocols and diverged from sandbox authentication introduced in the common
|
||||
runtime.
|
||||
|
||||
Windows is a primary environment for the agents OpenShell targets, particularly
|
||||
for GeForce and enterprise Windows users. We want native, OS-level isolation
|
||||
that runs unelevated, with the same policy, inference, and audit guarantees users
|
||||
get on Linux. Windows 11-Preview Builds now supports MXC (`processcontainer`, backed by
|
||||
AppContainer + a Low Integrity token) as an OS-native sandbox primitive that
|
||||
already honors a `network.proxy` egress redirect. That makes a supervisor-free,
|
||||
host-enforced design feasible without any changes to Microsoft's runtime.
|
||||
|
||||
This is worth an RFC rather than a single issue because it is a cross-cutting
|
||||
architectural decision: it adds a new compute-driver model (in-process,
|
||||
supervisor-free), a new platform target with its own build/CI lane, a new
|
||||
policy-translation seam between OpenShell policy and MXC config, and a new
|
||||
host-side enforcement model for native Windows sandboxes. It also commits
|
||||
OpenShell to a set of dependencies on the Microsoft MXC team. These decisions
|
||||
deserve broad review and a durable record.
|
||||
|
||||
If we leave the current design unchanged, OpenShell remains Linux-only in
|
||||
practice, Windows users are pushed toward heavyweight VM-based workarounds, and
|
||||
the Windows work continues to live outside the public project.
|
||||
Reusing RFC 0012 keeps Windows backend-specific code at the isolation edge and
|
||||
preserves one supervisor session model across Docker, Podman, Kubernetes, VM,
|
||||
and MXC. It also lets forwarding and provider credential refresh use existing
|
||||
authenticated paths instead of MXC-only side channels.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Porting `openshell-sandbox` (the Linux supervisor) to Windows, or shipping any
|
||||
in-sandbox OpenShell binary. This RFC rejects that path for native Windows.
|
||||
- Making Windows a Docker, Podman, Kubernetes, or VM runtime host. Those drivers
|
||||
remain compile-only configuration stubs that return an unsupported error.
|
||||
- Starting MXC sandboxes from OCI images or Dockerfiles in the MVP. MXC runs
|
||||
against the host Windows OS with policy/configuration, not a separate Linux
|
||||
container image.
|
||||
- Named-pipe driver IPC, a cross-process MXC driver binary, or a tonic
|
||||
`ComputeDriverService` adapter for MXC. The driver is in-process.
|
||||
- Full L7/port/binary-scoped policy enforcement inside MXC itself. MXC network
|
||||
filtering is host/IP/CIDR-level; rich policy stays on the host proxy.
|
||||
- MSI/WinGet packaging, installer UX, auto-start, and background gateway
|
||||
management.
|
||||
- GPU passthrough into MXC sandboxes.
|
||||
- Changing Linux or macOS build, runtime, or driver behavior. All Windows code is
|
||||
gated behind `cfg(target_os = "windows")`.
|
||||
- Supporting Docker, Kubernetes, Podman, VM, WSL, or Hyper-V compute drivers on
|
||||
Windows.
|
||||
- Supporting MXC `isolation_session`; the initial runtime requires
|
||||
`process_container`.
|
||||
- Starting Windows sandboxes from OCI images.
|
||||
- MSI, WinGet, Windows service, or background gateway installation.
|
||||
- GPU passthrough.
|
||||
- Full terminal resize before a Windows ConPTY implementation is available.
|
||||
- Durable recovery of live MXC generations after gateway restart.
|
||||
|
||||
## Proposal
|
||||
|
||||
### Layered architecture
|
||||
|
||||
OpenShell on Windows is a four-layer stack with a single hard trust boundary at
|
||||
the MXC sandbox. For the current scope, the gateway runs as a user-launched
|
||||
native Windows process. The MXC compute driver and per-sandbox host CONNECT proxy
|
||||
tasks live inside that process. The agent runs inside an MXC AppContainer with
|
||||
all egress redirected to its assigned host proxy listener.
|
||||
### Runtime composition
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
clients["Clients<br/>CLI · TUI · SDK"]
|
||||
upstreams["Internet / configured upstreams"]
|
||||
flowchart TD
|
||||
Gateway[Gateway / in-process MXC driver]
|
||||
Supervisor[openshell-supervisor<br/>role=isolation-backend]
|
||||
Sandbox[openshell-sandbox<br/>inside MXC ProcessContainer]
|
||||
Workload[Workload process tree]
|
||||
|
||||
subgraph host["Windows host"]
|
||||
direction LR
|
||||
|
||||
subgraph gateway["openshell-gateway.exe - one native Windows process"]
|
||||
direction TB
|
||||
|
||||
control["Control plane<br/>auth · sandbox state · policy · audit"]
|
||||
driver["openshell-driver-mxc (in-process)<br/>one backend · N sandbox entries"]
|
||||
|
||||
subgraph proxies["Per-sandbox host proxy tasks and listeners (N)"]
|
||||
direction TB
|
||||
proxy_a["Proxy A<br/>127.0.0.1:port_A<br/>policy A · agent identity A"]
|
||||
proxy_n["Proxy N<br/>127.0.0.1:port_N<br/>policy N · agent identity N"]
|
||||
end
|
||||
|
||||
control --> driver
|
||||
driver -->|"owns HostProxyHandle A"| proxy_a
|
||||
driver -->|"owns HostProxyHandle N"| proxy_n
|
||||
end
|
||||
|
||||
wxc["wxc-exec.exe invocation(s)"]
|
||||
|
||||
subgraph sandboxes["MXC AppContainers (N)"]
|
||||
direction TB
|
||||
sandbox_a["Sandbox A<br/>agent workload A only<br/>no OpenShell supervisor"]
|
||||
sandbox_n["Sandbox N<br/>agent workload N only<br/>no OpenShell supervisor"]
|
||||
end
|
||||
|
||||
driver -->|"launch and configure"| wxc
|
||||
wxc -->|"creates and runs"| sandbox_a
|
||||
wxc -->|"creates and runs"| sandbox_n
|
||||
|
||||
sandbox_a -.->|"MXC network.proxy<br/>localhost:port_A"| proxy_a
|
||||
sandbox_n -.->|"MXC network.proxy<br/>localhost:port_N"| proxy_n
|
||||
end
|
||||
|
||||
clients -->|"gRPC + mTLS"| control
|
||||
proxy_a -->|"policy-filtered egress"| upstreams
|
||||
proxy_n -->|"policy-filtered egress"| upstreams
|
||||
Gateway -->|policy + launch authentication| Supervisor
|
||||
Gateway -->|MXC config + one-use bootstrap| Sandbox
|
||||
Supervisor <-->|generation-scoped TLS + sandbox JWT| Sandbox
|
||||
Sandbox --> Workload
|
||||
```
|
||||
|
||||
The gateway-to-sandbox relationship is 1:N for control and lifecycle, but the
|
||||
proxy-listener-to-sandbox relationship is 1:1. Each sandbox registry entry owns
|
||||
one `HostProxyHandle`, one sandbox-specific policy, and one unique ephemeral
|
||||
loopback listener. The listener identifies the sandbox without attributing
|
||||
connections arriving on a shared proxy port.
|
||||
The driver owns provisioning and pairwise lifecycle monitoring. If either the
|
||||
host supervisor or ProcessContainer exits unexpectedly, the driver terminates
|
||||
the other. Stop and delete wait for pair termination before publishing success.
|
||||
The gateway treats the standard supervisor session, not a driver-specific port
|
||||
probe, as runtime readiness.
|
||||
|
||||
The defining property is that OpenShell network enforcement lives on the host
|
||||
inside the gateway process, not inside the sandbox. The current host-mode path
|
||||
provides L4 policy and plaintext/forward-proxy L7 handling. HTTPS MITM trust
|
||||
bootstrap, inference/privacy routing, and gateway event-bus wiring remain
|
||||
follow-up work.
|
||||
### Outer fence and confirmation
|
||||
|
||||
This still allows the existing supervisor networking code to be reused as a
|
||||
host-side proxy component. The boundary is that Windows does not run
|
||||
`ConnectSupervisor`, a sandbox relay, or any OpenShell process inside the MXC
|
||||
sandbox.
|
||||
MXC receives the mapped filesystem, UI, and network constraints before
|
||||
`openshell-sandbox` starts. The boundary consumes and deletes its one-use
|
||||
configuration and TLS private key before releasing workload code. It confirms
|
||||
the ProcessContainer generation, resource claims, filesystem fence, egress
|
||||
fence, authenticated control transport, and controller-loss behavior through
|
||||
the backend-neutral isolation contract.
|
||||
|
||||
### Part 1 - Native Windows build
|
||||
The boundary terminates owned workload processes if no authenticated supervisor
|
||||
recovers within the bounded reconnect deadline. Host auth bundles and runtime
|
||||
descriptors are stored beneath an owner-only Windows DACL.
|
||||
|
||||
This effort compiles the gateway and CLI for
|
||||
`x86_64-pc-windows-msvc` and `aarch64-pc-windows-msvc` while keeping Linux and
|
||||
macOS unchanged. The dominant change is a consistent cfg-gating pattern: each
|
||||
crate whose implementation is Unix-specific moves its Unix body into a
|
||||
`*_unix.rs` module and adds a small `windows.rs` (or stub), preserving public
|
||||
library entry points and configuration structs on both platforms.
|
||||
### Networking and credentials
|
||||
|
||||
- Unsupported drivers (Docker, Podman, Kubernetes, VM) become compile-only
|
||||
configuration stubs. The gateway still parses existing config files for every
|
||||
driver name and returns a clear unsupported error at gateway construction
|
||||
(Docker/Kubernetes/Podman) or at spawn (VM), never a parse failure or silent
|
||||
no-op.
|
||||
- `openshell-core/build.rs` selects a vendored `protoc` per platform
|
||||
(`protoc-bin-vendored` on Windows, `protobuf-src` elsewhere) so proto
|
||||
compilation succeeds under MSVC.
|
||||
- Windows path defaults resolve configuration under `%APPDATA%` and state/data
|
||||
under `%LOCALAPPDATA%`.
|
||||
- Validation runs through a dedicated `mise` lane (`tasks/windows.toml` →
|
||||
`tasks/scripts/windows-msvc.ps1`) invoked as `mise run --skip-tools windows:*`,
|
||||
separate from the default Linux `ci` task. The wrapper discovers Visual
|
||||
Studio's `VsDevCmd.bat`, adds rustup MSVC targets, and clears inherited
|
||||
`RUSTC_WRAPPER`.
|
||||
- A `windows-msvc` GitHub Actions job runs x64 check/build/test plus the
|
||||
unsupported-driver contract tests on `windows-2025`; ARM64 is scaffolded and
|
||||
disabled until an ARM64 runner is available.
|
||||
MXC denies direct Internet egress and permits the loopback route used by the
|
||||
Sandbox Protocol and explicit proxy. The host supervisor owns a distinct proxy
|
||||
listener and random authorization value for every sandbox generation.
|
||||
`openshell-sandbox` injects the proxy URL and public CA paths only into workload
|
||||
children. The supervisor retains private CA keys and provider secrets, applies
|
||||
network policy, and refreshes provider state through the ordinary session.
|
||||
|
||||
The Windows build target intentionally includes both `openshell.exe` and
|
||||
`openshell-gateway.exe`. Running the gateway only as a Linux container while a
|
||||
remote Windows MXC driver manages native sandboxes would keep the main repo's
|
||||
Windows target smaller, but it would reintroduce a Linux container/VM dependency
|
||||
and does not meet the native, low-overhead client-system goal of this RFC.
|
||||
The listener rejects missing, duplicate, malformed, and cross-generation proxy
|
||||
authorization before policy evaluation. The initial implementation assigns
|
||||
requests to the admitted main workload binary because Windows socket-owner
|
||||
identity is not yet carried by the explicit-proxy transport. Policies that rely
|
||||
on different network rights for descendant executables are therefore outside
|
||||
the initial enforcement contract. The loopback exception also does not isolate
|
||||
unrelated services bound to `127.0.0.1`; the gateway host remains trusted.
|
||||
|
||||
### Part 2 - The MXC compute driver
|
||||
### Process lifecycle and forwarding
|
||||
|
||||
`openshell-driver-mxc` is a library crate entirely behind
|
||||
`cfg(target_os = "windows")` (an empty shell elsewhere). It is linked
|
||||
in-process into `openshell-server` and implements a plain Rust `ComputeBackend`
|
||||
trait — there is no separate binary, no surrogate, and no tonic adapter.
|
||||
The Windows boundary implements authenticated start, exec, attach, wait,
|
||||
signal, terminate, retained stdout/stderr, provider environment refresh, and
|
||||
loopback connect operations. Standard gateway dynamic forwarding reaches the
|
||||
target through `BoundaryLoopbackConnector`; there is no reverse WebSocket,
|
||||
stdin/stdout JSON protocol, or MXC-specific relay binary.
|
||||
|
||||
| Module | Responsibility |
|
||||
|---|---|
|
||||
| `driver.rs` (`MxcComputeBackend`) | Orchestrator: owns the registry, validates specs, runs the lifecycle, drives `wxc-exec` phases, runs the agent, resolves provider credentials (Windows Credential Manager) and injects them, self-reports readiness, emits watch events. |
|
||||
| registry | In-memory `Arc<Mutex<…>>` mapping OpenShell sandbox id/name ⇄ MXC session id + phase state + exec/PTY handles. Source of truth for Get/List/Watch (MXC has no list-sessions API). |
|
||||
| `mxc.rs` | Builds MXC config JSON, base64-encodes it, runs `wxc-exec`, parses envelopes; encapsulates exec-vs-non-exec stdout semantics and error-code mapping. |
|
||||
| `policy.rs` | Translates the `SandboxPolicy` proto → MXC config and rejects unenforceable rules. Delegates to the embedded policy mapper (see Part 3). |
|
||||
| `openshell-supervisor-network::host` | Starts one host CONNECT proxy task per sandbox on a unique `127.0.0.1:<ephemeral-port>` listener, applies that sandbox's trimmed network policy and static agent identity, and retains its `HostProxyHandle` in the driver registry. |
|
||||
ProcessContainer teardown remains the outer kill boundary. ConPTY terminal
|
||||
resize is deferred; non-terminal exec and byte-stream I/O are supported first.
|
||||
|
||||
#### Workload and software availability
|
||||
### Configuration and packaging
|
||||
|
||||
MXC does not consume the OCI image model used by Linux container runtimes. For current support, the sandbox runs Windows
|
||||
software already present on the host or made available through explicit MXC
|
||||
filesystem grants, with the driver supplying the agent command, working
|
||||
directory, environment, credentials, and policy-derived MXC configuration.
|
||||
The Windows release contains `openshell-gateway.exe`, `openshell.exe`,
|
||||
`openshell-supervisor.exe`, and `openshell-sandbox.exe`. The runtime binaries
|
||||
default to siblings of the gateway and may be overridden for development.
|
||||
Gateway TLS uses the gateway-owned guest certificate bundle.
|
||||
|
||||
That is different from Linux, where a sandbox image can carry a separate userland
|
||||
and dependency set. The MXC `processcontainer` and AppContainer paths share the
|
||||
host Windows OS; the policy/configuration creates the isolation boundary. If MXC
|
||||
later grows a Windows VM-backed image model, OpenShell can add a separate
|
||||
bootstrap/image workflow for that backend.
|
||||
|
||||
#### The `wxc-exec` interface contract
|
||||
|
||||
The `mxc.rs` invoker is the boundary to MXC. Invocation is always
|
||||
`wxc-exec.exe --config-base64 <base64(JSON)> --experimental [--debug]`;
|
||||
`configurationId` defaults to `composable` (never `small` — a known OS bug). The
|
||||
invoker must branch on phase for I/O semantics:
|
||||
|
||||
| Phase(s) | stdout | Exit code | Parse as |
|
||||
|---|---|---|---|
|
||||
| `provision` / `start` / `stop` / `deprovision` | single JSON envelope `{"result":…}` or `{"error":…}` | 0 success / 1 error | JSON envelope |
|
||||
| `exec` | live process output (not JSON) | the script's exit code | raw bytes / stream |
|
||||
|
||||
`provision` returns the session id to capture. MXC `error.code` values
|
||||
(`not_provisioned`, `already_started`, `policy_validation`,
|
||||
`backend_unavailable`, …) map to typed errors. A non-zero `exec` exit is the
|
||||
script's result, not a driver error.
|
||||
|
||||
#### State model and lifecycle
|
||||
|
||||
MXC has no remote inventory API, so the in-memory registry is the single source
|
||||
of truth.
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Pending: CreateSandbox (validated, reserved)
|
||||
Pending --> Provisioned: wxc-exec provision (capture session id)
|
||||
Provisioned --> Started: wxc-exec start
|
||||
Started --> Ready: wxc-exec exec (agent running) — driver self-reports
|
||||
Ready --> Stopped: StopSandbox (wxc-exec stop)
|
||||
Stopped --> Deleted: DeleteSandbox (wxc-exec deprovision)
|
||||
Pending --> Failed: provision/start/exec error
|
||||
Failed --> Deleted: cleanup
|
||||
Deleted --> [*]
|
||||
```
|
||||
|
||||
`Ready` is self-reported once the agent launches; it does not depend on any
|
||||
supervisor connection. Every transition emits a `WatchSandboxes` event.
|
||||
`CreateSandbox` translates policy → MXC config, resolves and injects credentials,
|
||||
then runs `provision → start → exec`. Live `connect`/`exec` spawns a fresh
|
||||
`wxc-exec phase=exec` in a ConPTY and bridges the gateway's bidi stream to its
|
||||
stdin/stdout — no `ConnectSupervisor`, no in-sandbox SSH server, no relay socket.
|
||||
|
||||
There is currently no reconciliation loop in the MVP. If an operator deletes an
|
||||
OpenShell-managed MXC/AppContainer resource outside OpenShell, `get`, `list`, and
|
||||
`watch` will continue to reflect the driver's registry until a later operation
|
||||
touches the missing MXC resource and can mark the sandbox failed or not found.
|
||||
Durable reconciliation is follow-up work: persist the OpenShell sandbox id ⇄ MXC
|
||||
session id mapping in SQLite, probe or deprovision known sessions on startup, and
|
||||
add a periodic reconcile loop when MXC exposes a list/inspect API.
|
||||
|
||||
#### Governed egress
|
||||
|
||||
Governed egress is the core value layer. When it is enabled,
|
||||
`egress_proxy_addr` serves as a loopback address seed. For each sandbox, the MXC
|
||||
driver preserves the configured IP and binds port `0` to allocate a fresh
|
||||
ephemeral port. It starts the existing OpenShell host CONNECT proxy with that
|
||||
sandbox's trimmed network-only policy, then writes the allocated port to MXC's
|
||||
`network.proxy = { localhost: N }` redirect. Loopback inside an AppContainer is
|
||||
host loopback, so sandbox egress reaches the proxy running in the in-process MXC
|
||||
driver inside the gateway process.
|
||||
|
||||
The host-listener-to-sandbox topology is **1:1**, not many-to-one. Multiple
|
||||
sandboxes share `127.0.0.1`, but each active sandbox owns a unique ephemeral port
|
||||
and host proxy handle in the driver registry. The resulting
|
||||
`127.0.0.1:<port>` tuple scopes every inbound proxy connection to exactly one
|
||||
sandbox, eliminating the need to infer sandbox identity from shared loopback
|
||||
traffic. Dropping the handle when the sandbox stops, exits, or fails terminates
|
||||
that sandbox's proxy accept loop.
|
||||
|
||||
Because MXC does not expose Linux procfs socket ownership, the proxy evaluates
|
||||
each connection against the listener's sandbox policy and a static sandbox-agent
|
||||
identity derived from the configured `agent_command`. The current host-mode path
|
||||
evaluates L4 host:port allow/deny via OPA, handles plaintext and forward-proxy L7
|
||||
traffic, and emits OCSF events. HTTPS MITM trust bootstrap and gateway
|
||||
denial/activity bus wiring remain follow-up work. The default `processcontainer`
|
||||
backend already honors `network.proxy`, so this design requires no MXC changes.
|
||||
|
||||
### Part 3 - Policy translation between OpenShell and MXC
|
||||
|
||||
OpenShell policy is authored as YAML and parsed to the `SandboxPolicy` proto by
|
||||
the shared cross-platform `openshell-policy` crate. The MXC driver does not
|
||||
re-parse YAML; a dedicated Rust policy mapper (embedded in the driver and called
|
||||
automatically) maps the proto IR to MXC `ContainerConfig` and **rejects rather
|
||||
than silently drops** anything MXC cannot enforce. MXC imposes a provision-time
|
||||
vs exec-time split.
|
||||
|
||||
| OpenShell policy | Where enforced | MXC mapping | When |
|
||||
|---|---|---|---|
|
||||
| filesystem read/write paths | MXC | `filesystem.readwritePaths` | provision |
|
||||
| filesystem read-only paths | MXC | `filesystem.readonlyPaths` | provision |
|
||||
| filesystem denied paths | MXC | limited / unsupported | provision |
|
||||
| process (uid/gid/seccomp) | — (no analog) | reject (default) | — |
|
||||
| network (OPA / L7 / inference / privacy) | host CONNECT proxy | `network.proxy = { localhost: N }` redirect | provision |
|
||||
|
||||
The primary governed-egress design does **not** try to map the full OpenShell
|
||||
network policy into MXC network policy. MXC receives a fail-closed redirect
|
||||
layer: `network.defaultPolicy = "block"`, empty direct allowlists, and
|
||||
`network.proxy = { localhost: N }`. The original OpenShell `network_policies`
|
||||
are preserved and handed to the host CONNECT proxy, which remains responsible
|
||||
for ports, binaries, L7 rules, `inference.local`, privacy routing, and audit.
|
||||
|
||||
The coarse MXC-only mapper is a separate fallback and analysis path for cases
|
||||
where no proxy is in the loop. In that mode, MXC can roughly express literal
|
||||
host/IP/CIDR allowlists, but it cannot encode ports, protocols, per-binary scope,
|
||||
TLS inspection behavior, credential rewrite, inference routing, or REST,
|
||||
WebSocket, and GraphQL rules. The mapper emits a structured loss report and
|
||||
rejects error-severity losses rather than silently broadening access. Critically,
|
||||
MXC defaults to `defaultPolicy: "allow"` when the network block is omitted, so
|
||||
both paths must explicitly emit `network.defaultPolicy: "block"`.
|
||||
|
||||
Across the five OpenShell example policies, the MXC-only coarse mapping is
|
||||
schema-valid but lossy: an aggregate of 64 access-broadening errors, 32
|
||||
warnings, and 4 info items. The dominant gaps are binary-scoped network policy,
|
||||
port-scoped outbound policy, protocol-aware (REST/WebSocket/GraphQL) policy, and
|
||||
access presets. Those losses do not apply to the governed-egress split because
|
||||
the host CONNECT proxy receives and enforces the original OpenShell network
|
||||
policy.
|
||||
|
||||
To consume OpenShell policy more faithfully over time, MXC would need
|
||||
kernel-enforceable additions such as port-scoped network endpoints, a filesystem
|
||||
`defaultPolicy`, per-process/binary network scoping, and DNS/wildcard handling. A
|
||||
proposed two-surface direction for Microsoft keeps `ContainerConfig` as the
|
||||
execution manifest (add portable kernel-enforceable fields such as ports and
|
||||
filesystem `defaultPolicy`) and adds a separate `policyProxy` surface for L7 and
|
||||
dynamic policy so HTTP/WebSocket/GraphQL parsing, credential rewrite, audit, and
|
||||
hot-reload stay out of every backend runner. None of these are required for the
|
||||
host-enforced design proposed here; they are enhancements that would deepen
|
||||
kernel-level defense-in-depth.
|
||||
|
||||
### Design decisions (D1–D4)
|
||||
|
||||
- **D1 — MXC as the Windows sandbox primitive**, over Docker Desktop, WSL2, and
|
||||
Windows Sandbox/Hyper-V. MXC is OS-native, needs no VM, and runs unelevated.
|
||||
Default backend `processcontainer`; `isolation_session` opt-in. Requires
|
||||
Windows 11 build ≥ 26100 and `wxc-exec.exe` present.
|
||||
- **D2 — Reject porting the supervisor for native Windows.** Use a host proxy +
|
||||
MXC `network.proxy` redirect for governed egress, plus driver-owned host-side
|
||||
behavior for credentials and exec. No in-sandbox OpenShell binary is part of
|
||||
this RFC. Consequence: governed egress on the opt-in `isolation_session`
|
||||
backend depends on Microsoft extending `network.proxy` to that backend; until
|
||||
then the design defaults to `processcontainer`, where it works today.
|
||||
- **D3 — User-launched native Windows gateway for the current scope.** Run
|
||||
`openshell-gateway.exe` as a regular user process. Clients connect over gRPC
|
||||
(loopback or remote mTLS), and existing per-user configuration and state paths
|
||||
remain in effect. Installation, auto-start, background process management,
|
||||
and Windows Event Log integration are outside this RFC.
|
||||
- **D4 — Reduce the gRPC footprint to the client-facing API only.** Supervisor
|
||||
removal deletes the supervisor and sandbox-relay boundaries; in-process MXC
|
||||
removes the wire protocol on the gateway↔driver boundary. Only client↔gateway
|
||||
gRPC survives on Windows.
|
||||
The MXC driver configuration contains only host/runtime settings. Workload
|
||||
command, working directory, environment, and policy stay sandbox-scoped.
|
||||
Relay paths, relay target ports, and driver-owned proxy enable/seed settings are
|
||||
removed.
|
||||
|
||||
## Implementation plan
|
||||
|
||||
All Windows code is gated behind `cfg(target_os = "windows")`, so Linux and macOS
|
||||
are never affected and the changes can land additively.
|
||||
|
||||
- **Compile.** Land the MSVC cfg-gating, per-platform `protoc` selection,
|
||||
Windows path defaults, the `mise` Windows lane, and the `windows-msvc` CI job.
|
||||
Unsupported drivers become contract stubs with tests asserting they return
|
||||
unsupported.
|
||||
- **MXC driver and host proxy.** Add `openshell-driver-mxc` driving the default
|
||||
`processcontainer` backend: lifecycle, policy translation, one host CONNECT
|
||||
proxy listener per sandbox, credential injection, and interactive exec via
|
||||
the driver's ConPTY bridge. Unenforceable policy is rejected in
|
||||
`ValidateSandboxCreate` with `invalid_argument` naming the rule. HTTPS MITM,
|
||||
inference/privacy routing, and gateway event-bus wiring follow after host-mode
|
||||
trust bootstrap is available.
|
||||
- **Gateway runtime.** Run `openshell-gateway.exe` directly as a regular user
|
||||
process with the existing per-user configuration, SQLite, TLS, and logging
|
||||
paths. Installation, auto-start, and background process management are
|
||||
follow-up work.
|
||||
- **Hardening.** Validate collision-free per-sandbox ephemeral port allocation
|
||||
and `processcontainer` concurrency. Persist the sandbox-id ⇄ session-id
|
||||
mapping so a gateway restart can reconcile or clean up orphaned sessions, and
|
||||
add a periodic reconcile loop once MXC exposes a list/inspect API.
|
||||
- **Opt-in `isolation_session` egress.** Becomes available if and when Microsoft
|
||||
extends `network.proxy` to that backend; the same host proxy then governs its
|
||||
egress.
|
||||
|
||||
Validation follows a layered pyramid: pure-Rust unit tests for the JSON
|
||||
builders/parsers and policy mapper (on the Windows MSVC test lane), a mock
|
||||
`wxc-exec` shim (`OPENSHELL_MXC_MOCK_WXC=1`) for lifecycle logic, egress-proxy
|
||||
component tests for redirect → listener-scoped policy → OPA decision, gated
|
||||
integration tests against a real `wxc-exec` (`#[ignore]` unless present), and
|
||||
manual E2E on a real Windows 11 host. User-facing configuration is documented in
|
||||
the gateway config reference and the architecture docs.
|
||||
1. Make the sandbox, supervisor, and supervisor-process crates compile on
|
||||
Windows without enabling Linux-only controls.
|
||||
2. Add the Windows Sandbox Protocol boundary and MXC confirmation evidence.
|
||||
3. Provision the host supervisor and in-ProcessContainer sandbox as one
|
||||
generation from the MXC driver.
|
||||
4. Reuse the supervisor network and process sessions for forwarding,
|
||||
credentials, exec, output, and controller-loss handling.
|
||||
5. Remove the relay crate and MXC-only forwarding/credential side channels.
|
||||
6. Build all four Windows binaries on x64 and ARM64, then validate on a native
|
||||
MXC host.
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| MXC `allowedHosts`/`blockedHosts` not enforced on Windows yet, so there is no kernel-level defense-in-depth beneath the host proxy. | Rely on the host proxy for host-level allow/deny |
|
||||
| Per-sandbox proxy routing must remain collision-free when multiple sandboxes run concurrently. | Bind a fresh ephemeral port on `127.0.0.1` for each sandbox and retain its proxy handle in the driver registry, making the listener-to-sandbox mapping 1:1. |
|
||||
| `--config-base64` carries credentials in argv (briefly visible in process listings). | Zero-fill after invocation; prefer passing config on stdin. |
|
||||
| Concurrency: `isolation_session` is single-session; `processcontainer` limits are unverified. | Validate `processcontainer` concurrency and document any cap. |
|
||||
| OCSF fidelity: with no in-sandbox supervisor, arbitrary in-process events are not visible (only network + lifecycle). | Accept reduced fidelity; an ETW/callback hook from the Microsoft MXC team will help restore in-process visibility later. |
|
||||
| Restart and external deletion: the registry is in-memory, so a gateway restart or out-of-band MXC deletion is not immediately reflected in OpenShell state. | Persist the sandbox-id ⇄ session-id mapping in SQLite, reconcile or deprovision orphans on startup, and add periodic reconcile when MXC exposes list/inspect. |
|
||||
| Policy fidelity: MXC cannot enforce port/binary/L7 policy, so the MXC-only tier is a coarse approximation. | Fail-safe mapper (always `block`, never silently broaden) + host proxy as the real enforcer + a published loss report. |
|
||||
| Microsoft dependency: several deepening improvements are outside OpenShell's control. | Ship the host-enforced design with no MXC changes required; treat MXC enhancements as optional, not blockers. |
|
||||
- MXC and AppContainer networking can differ across Windows preview builds.
|
||||
Native-host qualification remains required in addition to cross-compilation.
|
||||
- The explicit proxy cannot yet distinguish descendant executable identities.
|
||||
This limitation is documented and must fail review for policies that require
|
||||
per-child network separation until socket-owner attribution is added.
|
||||
- Loopback transport exposes unrelated host listeners to the AppContainer if
|
||||
those listeners lack their own authentication. OpenShell listeners always
|
||||
require generation-scoped credentials, but operators must treat the host as
|
||||
trusted.
|
||||
- Gateway restart recovery is not durable. Orphan discovery and persisted
|
||||
generation reconciliation are follow-up work.
|
||||
- Windows process-tree and terminal semantics differ from Unix. The
|
||||
ProcessContainer remains the final teardown boundary while ConPTY support is
|
||||
incomplete.
|
||||
|
||||
## Alternatives Considered
|
||||
## Alternatives
|
||||
|
||||
- **Run OpenShell on Windows via Docker Desktop, WSL2, or Hyper-V/Windows
|
||||
Sandbox.** Reuses the existing Linux drivers unchanged, but reintroduces a
|
||||
Linux VM, heavier install/network complexity, licensing constraints, and (for
|
||||
Hyper-V/Windows Sandbox) elevation. It defeats the goal of OS-native,
|
||||
unelevated Windows isolation.
|
||||
- **Port `openshell-sandbox` to Windows (in-sandbox supervisor).** Maximizes
|
||||
Linux parity and defense-in-depth, but requires Windows analogs of
|
||||
Landlock/seccomp/netns, a Windows relay protocol, and an in-sandbox binary —
|
||||
far more surface for the same user-visible feature set, which the host-proxy
|
||||
design already delivers. This RFC rejects that path; any future
|
||||
defense-in-depth revisit should be a new design rather than assumed follow-up
|
||||
work.
|
||||
- **Out-of-tree remote MXC driver with a containerized Linux gateway.** This
|
||||
would reduce the main repo's Windows build surface to the CLI if the gateway
|
||||
could stay containerized. It does not meet this RFC's native Windows goal:
|
||||
the MXC driver needs Windows-host access to `wxc-exec`, AppContainer/MXC
|
||||
state, loopback proxy routing, and Windows credentials, while a Linux
|
||||
containerized gateway would reintroduce the VM/container dependency this RFC
|
||||
is removing. It would also require extending the remote driver protocol to
|
||||
carry policy and proxy state that the current in-process design can share
|
||||
directly.
|
||||
- **Cross-compile the Windows binaries from Linux only.** Cheaper CI, but cannot
|
||||
validate runtime correctness on real Windows hardware, which is essential for
|
||||
MXC integration.
|
||||
- **A cross-process MXC driver binary (like the VM driver) with a tonic
|
||||
adapter.** Matches the existing VM driver shape, but adds a wire protocol and a
|
||||
second process for no benefit when the driver can be linked in-process and the
|
||||
supervisor is gone.
|
||||
- **Do nothing.** OpenShell stays Linux-only in practice and Windows users rely
|
||||
on VM-based workarounds.
|
||||
### Driver-owned relay and proxy
|
||||
|
||||
The prototype launched a relay inside MXC and implemented forwarding,
|
||||
credentials, readiness, and proxy lifecycle in the driver. It reduced the
|
||||
initial Windows porting work but created a second security protocol and repeated
|
||||
existing supervisor patterns. This proposal removes it.
|
||||
|
||||
### Supervisor-only host proxy without `openshell-sandbox`
|
||||
|
||||
Running only the host supervisor cannot provide authenticated in-boundary
|
||||
process lifecycle, retained I/O, controller-loss handling, or loopback target
|
||||
connection. It also leaves the driver responsible for these behaviors.
|
||||
|
||||
### Windows VM or WSL2
|
||||
|
||||
The existing Linux runtime can run inside a VM, but that does not deliver the
|
||||
native, low-overhead Windows isolation workflow this RFC targets.
|
||||
|
||||
### Do nothing
|
||||
|
||||
Keeping the supervisor-free prototype would preserve protocol duplication and
|
||||
make sandbox authentication, forwarding, credential rotation, and policy fixes
|
||||
diverge by platform.
|
||||
|
||||
## Prior art
|
||||
|
||||
- **OpenShell's existing compute drivers** (Docker, Podman, Kubernetes, VM)
|
||||
establish the `ComputeDriver`/`ComputeBackend` contract and the
|
||||
driver-selection model this RFC extends with an in-process, supervisor-free
|
||||
variant.
|
||||
- **RFC 0001 (core architecture)** and `architecture/sandbox.md` define the
|
||||
supervisor/relay model that the Windows design deliberately removes.
|
||||
- **Microsoft MXC (`wxc-exec`)** provides the Windows AppContainer-based
|
||||
sandbox primitive and the `network.proxy` redirect that makes host-side
|
||||
enforcement possible without an in-sandbox agent.
|
||||
- **Open Policy Agent and OpenShell's CONNECT proxy / L7 / inference / privacy
|
||||
stack** are reused unchanged on the host, demonstrating that the value layers
|
||||
are already cross-platform Rust.
|
||||
- RFC 0012 defines the backend-neutral isolation contract and authenticated
|
||||
supervisor/boundary pairing used here.
|
||||
- The VM backend already runs the supervisor on the host while a capability-free
|
||||
boundary runs inside a stronger isolation primitive.
|
||||
- Docker, Podman, and Kubernetes use the same supervisor session for network
|
||||
policy, credentials, lifecycle, and forwarding.
|
||||
- Windows AppContainer and ProcessContainer provide the native outer fence but
|
||||
not OpenShell's application-layer policy semantics.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Which Windows API should provide race-resistant socket-owner executable
|
||||
identity for per-descendant binary network policy?
|
||||
- Should MXC restart recovery persist enough generation metadata to reconnect,
|
||||
or should startup always terminate and recreate orphaned ProcessContainers?
|
||||
- What ConPTY surface is required before Windows interactive exec is considered
|
||||
complete?
|
||||
|
||||
@@ -50,7 +50,7 @@ if (-not [int]::TryParse($BuildJobsValue, [ref] $WindowsBuildJobs) -or $WindowsB
|
||||
}
|
||||
$WindowsCargoMutex = [System.Threading.Mutex]::new($false, "Local\OpenShellWindowsMsvcCargo")
|
||||
|
||||
$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 --exclude openshell-supervisor-process --exclude openshell-vfio"
|
||||
$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-vfio"
|
||||
$WindowsClippyPackageExcludes = $UnsupportedDriverPackageExcludes
|
||||
$WindowsClippyLintArgs = "-D warnings -A dead-code -A unused-imports -A clippy::unused-async"
|
||||
$PrebuiltZ3WorkspaceFeatures = "--features openshell-prover/prebuilt-z3"
|
||||
@@ -512,7 +512,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 --bin openshell-supervisor-relay $Z3WorkspaceFeatures" `
|
||||
-CargoArgs "cargo build --release --target $RustTarget --bin openshell-gateway --bin openshell --bin openshell-supervisor --bin openshell-sandbox $Z3WorkspaceFeatures" `
|
||||
-LogName "build-$RustTarget-release.log"
|
||||
}
|
||||
|
||||
@@ -581,7 +581,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.exe", "openshell-sandbox.exe")) {
|
||||
$path = Join-Path $TargetDir "$rustTarget\release\$binary"
|
||||
if (-not (Test-Path $path)) {
|
||||
continue
|
||||
|
||||
Reference in New Issue
Block a user