From 27548fad44208d358f5959fb6c677a68ebe6d192 Mon Sep 17 00:00:00 2001 From: Shailendra Singh Date: Fri, 4 Sep 2026 12:55:30 -0700 Subject: [PATCH 01/26] feat(policy): add portable UI policy controls Signed-off-by: Shailendra Singh --- .../build-openshell-mxc-windows/SKILL.md | 6 + architecture/compute-runtimes.md | 13 +- architecture/security-policy.md | 24 +++- crates/openshell-cli/src/commands/gateway.rs | 17 +++ crates/openshell-core/src/policy.rs | 27 ++++ crates/openshell-driver-docker/src/lib.rs | 1 + .../openshell-driver-kubernetes/src/driver.rs | 1 + crates/openshell-driver-mxc/README.md | 14 +- crates/openshell-driver-mxc/src/driver.rs | 84 ++++++++++- crates/openshell-driver-mxc/src/mxc.rs | 83 ++++++++++- crates/openshell-driver-mxc/src/policy.rs | 102 ++++++++++++- .../src/policy_map/loss.rs | 2 +- .../src/policy_map/map.rs | 93 ++++++++++-- .../tests/policy_mapper_matrix.rs | 97 ++++++++++++- .../tests/wxc_exec_real.rs | 134 +++++++++++++++++- crates/openshell-driver-podman/src/driver.rs | 1 + crates/openshell-driver-vm/src/driver.rs | 1 + crates/openshell-policy-schema/src/lib.rs | 34 +++++ crates/openshell-policy/src/lib.rs | 124 +++++++++++++++- crates/openshell-server/src/compute/mod.rs | 114 +++++++++++++++ crates/openshell-server/src/grpc/mod.rs | 1 + crates/openshell-server/src/grpc/policy.rs | 29 ++++ .../openshell-server/src/grpc/validation.rs | 34 ++++- crates/openshell-server/src/test_support.rs | 1 + .../openshell-supervisor-network/src/opa.rs | 16 +++ docs/reference/policy-schema.mdx | 33 +++++ docs/sandboxes/policies.mdx | 10 +- proto/compute_driver.proto | 4 + proto/openshell.proto | 4 + proto/sandbox.proto | 30 ++++ .../v1/internal/converter/coverage_test.go | 1 + .../openshell/v1/internal/converter/health.go | 1 + .../v1/internal/converter/health_test.go | 8 +- .../openshell/v1/internal/converter/policy.go | 54 +++++++ .../v1/internal/converter/policy_test.go | 42 ++++++ sdk/go/openshell/v1/types/health.go | 7 +- sdk/go/openshell/v1/types/policy.go | 32 ++++- sdk/go/proto/openshellv1/openshell.pb.go | 19 ++- skills/generate-sandbox-policy/SKILL.md | 30 +++- skills/generate-sandbox-policy/examples.md | 2 +- skills/openshell-cli/SKILL.md | 4 +- tasks/scripts/windows-msvc.ps1 | 21 ++- tasks/windows.toml | 9 +- 43 files changed, 1315 insertions(+), 49 deletions(-) diff --git a/.agents/skills/build-openshell-mxc-windows/SKILL.md b/.agents/skills/build-openshell-mxc-windows/SKILL.md index 9b6229d96b..cbc6b2b4a1 100644 --- a/.agents/skills/build-openshell-mxc-windows/SKILL.md +++ b/.agents/skills/build-openshell-mxc-windows/SKILL.md @@ -155,6 +155,8 @@ mise run --skip-tools windows:build:x64 mise run --skip-tools windows:build:arm64 mise run --skip-tools windows:test:x64 mise run --skip-tools windows:test:unsupported:x64 +mise run --skip-tools windows:test:mxc-real:x64 +mise run --skip-tools windows:test:mxc-real:arm64 ``` For full validation, detect the Windows host architecture first and choose the @@ -246,6 +248,8 @@ crypto dependency builds. | `windows:test:arm64` | Runs native ARM64 workspace tests with `--no-fail-fast` and the same package exclusions. Rejects non-ARM64 hosts. | | `windows:test:unsupported:x64` | Re-runs focused `openshell-gateway` tests for unsupported Windows driver behavior. | | `windows:test:unsupported:arm64` | Re-runs the same focused contracts natively on ARM64. Rejects non-ARM64 hosts. | +| `windows:test:mxc-real:x64` | Runs the serial, ignored real-`wxc-exec` integration suite natively on x64 through the MSVC wrapper. Rejects non-x64 hosts. | +| `windows:test:mxc-real:arm64` | Runs the same real-`wxc-exec` suite natively on ARM64. Rejects non-ARM64 hosts. | | `windows:artifacts` | Reports size and SHA256 for release artifacts that exist. | | `windows:ci` | Runs the full ordered x64-host Windows CI lane, plus ARM64 check/build when not skipped. | @@ -315,6 +319,8 @@ Useful log files: | `test-aarch64-pc-windows-msvc.log` | Full native ARM64 workspace test output. | | `test-x86_64-pc-windows-msvc-unsupported-*.log` | Focused unsupported-driver contract output. | | `test-aarch64-pc-windows-msvc-unsupported-*.log` | Focused native ARM64 contract output. | +| `test-x86_64-pc-windows-msvc-mxc-real.log` | Native x64 real-MXC integration output. | +| `test-aarch64-pc-windows-msvc-mxc-real.log` | Native ARM64 real-MXC integration output. | The first check downloads the pinned official Z3 archive for the target architecture through `z3-sys`. GitHub Actions authenticates the lookup with its diff --git a/architecture/compute-runtimes.md b/architecture/compute-runtimes.md index a2bb25c312..8da70004c6 100644 --- a/architecture/compute-runtimes.md +++ b/architecture/compute-runtimes.md @@ -44,8 +44,8 @@ launches and monitors the policy-constrained workload itself. `compute_driver.proto` is the supported gateway/driver extension boundary. At initialization the gateway snapshots the driver's identity, version, -default image, gateway-lifecycle preference, and -`driver_reports_runtime_readiness` from `GetCapabilities`. The gateway includes +default image, gateway-lifecycle preference, runtime-readiness ownership, and +complete UI-policy enforcement support from `GetCapabilities`. The gateway includes the canonical `SandboxPolicy` in `DriverSandboxSpec.policy` for validation and creation. Drivers that enforce policy outside the standard supervisor fetch later revisions through `GetSandboxConfig` and acknowledge them through @@ -118,6 +118,15 @@ The capability RPC reports driver identity, version, and the default sandbox image used by the gateway. GPU availability stays driver-local and is validated when a sandbox create request asks for GPU resources. +UI policy is capability-negotiated at the configured driver-instance boundary. +`supports_ui_policy = true` means the driver completely enforces every field in +the current portable `SandboxPolicy.ui` contract; partial implementations must +report false. When `ui` is explicitly present, including as `{}`, the gateway +rejects create before the driver validation RPC or provisioning unless this +capability is true. An absent section bypasses this gate and preserves the +runtime's existing behavior. The startup snapshot is also exposed through +gateway info so clients can discover the selected runtime's support. + The gateway records driver identity and version from the startup capability response. Elevated gateway info reports that initialized driver snapshot instead of re-querying drivers on each request. diff --git a/architecture/security-policy.md b/architecture/security-policy.md index e669ea2fd5..43714a0778 100644 --- a/architecture/security-policy.md +++ b/architecture/security-policy.md @@ -8,17 +8,26 @@ per-request egress decisions. For the field-by-field YAML reference, use [Policy Schema Reference](../docs/reference/policy-schema.mdx). +## Windows MXC Static Enforcement + +On native Windows, the MXC driver cannot rely on Linux Landlock or setuid. It +maps portable static filesystem and UI controls from `SandboxPolicy` into MXC +configuration. UI controls are available only with the MXC +`process_container` backend; all omitted UI fields retain deny-by-default +values, and other compute runtimes reject an explicit UI policy. + ## Policy Areas | Area | Enforcement | |---|---| | Filesystem | Landlock restricts read-only and read-write paths. | | Process | The supervisor launches the agent as an unprivileged user with reduced capabilities. | +| UI | Within an explicit UI section, omitted display, clipboard, and input-injection fields deny. The MXC driver's OpenShell `process_container` backend (MXC containment `processcontainer`) can selectively enable them. Other configured backends reject the entire explicit section before provisioning. | | Network | The proxy evaluates destination, port, calling binary, and optional L7 rules. | | Provider access | Attached provider profiles contribute endpoint and binary rules; credentials remain bound to profile-authorized endpoints. | | Runtime settings | Typed settings are delivered with policy and can be global or sandbox scoped. | -Filesystem and process policy are startup-time controls. Network policy is +Filesystem, process, and UI policy are startup-time controls. Network policy is dynamic and can be hot-reloaded when the new policy validates successfully. ### Authored policy boundary @@ -40,6 +49,19 @@ before any consumer-specific projection runs. There is no permissive parsing profile: unsupported policy fields always invalidate the document. Middleware `config`, query and persisted-query names, and recursive MCP parameter names are open user-data maps rather than schema extensions. +The UI schema names portable capabilities rather than Windows primitives: +graphical output, directional clipboard access, and synthetic input. The +configured compute driver advertises whether it completely enforces this +contract. Any explicit section, including `{}`, is rejected before driver +validation or provisioning when that capability is false. The MXC +`process_container` mapper translates the fields to MXC's top-level `ui` object +under its `processcontainer` containment value and treats omitted fields inside +the section as deny. That object is common to MXC's 0.8 stable and 0.9 +development schemas. Both schema lines reject it for `isolation_session`, so +that backend advertises no support and the mapper also rejects it in depth. +Linux, macOS, and other non-MXC paths advertise no support: explicit UI policy +fails closed, while an absent section leaves their runtime behavior unchanged. + Before applying Landlock, the supervisor enriches baseline filesystem paths that the runtime needs. Missing baseline paths are skipped so one absent runtime path does not weaken the whole ruleset. When GPU devices are present, GPU baseline diff --git a/crates/openshell-cli/src/commands/gateway.rs b/crates/openshell-cli/src/commands/gateway.rs index 0e1fe92426..6073ac3a4b 100644 --- a/crates/openshell-cli/src/commands/gateway.rs +++ b/crates/openshell-cli/src/commands/gateway.rs @@ -45,6 +45,7 @@ struct ComputeDriverInfoView { struct ComputeDriverCapabilitiesView { driver_name: String, driver_version: String, + supports_ui_policy: bool, } /// Show gateway status. @@ -392,6 +393,7 @@ pub async fn gateway_info( capabilities: ComputeDriverCapabilitiesView { driver_name: capabilities.driver_name, driver_version: capabilities.driver_version, + supports_ui_policy: capabilities.supports_ui_policy, }, } }) @@ -446,6 +448,15 @@ fn print_compute_driver_info(drivers: &[ComputeDriverInfoView]) { "Driver version:".dimmed(), driver.capabilities.driver_version ); + println!( + " {} {}", + "UI policy:".dimmed(), + if driver.capabilities.supports_ui_policy { + "supported" + } else { + "unsupported" + } + ); } } @@ -464,6 +475,7 @@ fn gateway_info_to_json(view: &GatewayInfoView) -> serde_json::Value { "capabilities": { "driver_name": &driver.capabilities.driver_name, "driver_version": &driver.capabilities.driver_version, + "supports_ui_policy": driver.capabilities.supports_ui_policy, }, })) .collect::>(), @@ -1822,6 +1834,7 @@ mod tests { capabilities: ComputeDriverCapabilitiesView { driver_name: "podman".to_string(), driver_version: "0.0.75".to_string(), + supports_ui_policy: false, }, }], }; @@ -1840,6 +1853,10 @@ mod tests { json["compute_drivers"][0]["capabilities"]["driver_version"], "0.0.75" ); + assert_eq!( + json["compute_drivers"][0]["capabilities"]["supports_ui_policy"], + false + ); } #[test] diff --git a/crates/openshell-core/src/policy.rs b/crates/openshell-core/src/policy.rs index af474325c5..8bf2828698 100644 --- a/crates/openshell-core/src/policy.rs +++ b/crates/openshell-core/src/policy.rs @@ -113,6 +113,10 @@ impl TryFrom for SandboxPolicy { type Error = miette::Report; fn try_from(proto: ProtoSandboxPolicy) -> Result { + // UI capabilities are intentionally absent from the portable supervisor + // runtime. Non-Windows compute paths do not expose them, so even a + // schema-level UI allowance cannot grant a UI surface there. The MXC + // driver consumes the typed proto directly on Windows. // In cluster mode we always run with proxy networking so all egress // can be evaluated by OPA. let network = NetworkPolicy { @@ -192,6 +196,7 @@ impl From for ProcessPolicy { #[cfg(test)] mod tests { use super::*; + use crate::proto::{UiClipboardAccess, UiPolicy}; #[test] fn try_from_maps_known_compatibility_values() { @@ -233,4 +238,26 @@ mod tests { assert!(!is_valid_landlock_compatibility("nope")); assert!(!is_valid_landlock_compatibility("BestEffort")); } + + #[test] + fn portable_runtime_does_not_activate_ui_allowances() { + let converted = SandboxPolicy::try_from(ProtoSandboxPolicy { + version: 1, + ui: Some(UiPolicy { + allow_graphical_ui: true, + clipboard: UiClipboardAccess::All as i32, + allow_input_injection: true, + }), + ..Default::default() + }) + .expect("portable policy conversion succeeds"); + + assert_eq!(converted.version, 1); + assert!(matches!(converted.network.mode, NetworkMode::Proxy)); + assert!(converted.network.proxy.is_some()); + assert!(converted.filesystem.read_only.is_empty()); + assert!(converted.filesystem.read_write.is_empty()); + assert!(converted.process.run_as_user.is_none()); + assert!(converted.process.run_as_group.is_none()); + } } diff --git a/crates/openshell-driver-docker/src/lib.rs b/crates/openshell-driver-docker/src/lib.rs index 89d77b3d9d..eb580aa6a2 100644 --- a/crates/openshell-driver-docker/src/lib.rs +++ b/crates/openshell-driver-docker/src/lib.rs @@ -1033,6 +1033,7 @@ impl DockerComputeDriver { }), rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, + supports_ui_policy: false, } } diff --git a/crates/openshell-driver-kubernetes/src/driver.rs b/crates/openshell-driver-kubernetes/src/driver.rs index c9e2682983..bda6973ba4 100644 --- a/crates/openshell-driver-kubernetes/src/driver.rs +++ b/crates/openshell-driver-kubernetes/src/driver.rs @@ -763,6 +763,7 @@ impl KubernetesComputeDriver { }), rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, + supports_ui_policy: false, }) } diff --git a/crates/openshell-driver-mxc/README.md b/crates/openshell-driver-mxc/README.md index 9c235b85c7..46b8839a70 100644 --- a/crates/openshell-driver-mxc/README.md +++ b/crates/openshell-driver-mxc/README.md @@ -19,6 +19,7 @@ readiness; there is no in-sandbox supervisor or `ConnectSupervisor` relay. | Capability | MXC driver | Closing it requires | |---|---|---| | Filesystem policy (read-write / read-only grants) | ✅ provision-time AppContainer shares | — | +| 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. | MXC support for persistent sessions | | Governed egress (CONNECT proxy + OPA + L7) | Available behind `egress_proxy` on `process_container`; the driver starts a per-sandbox host CONNECT proxy, generates HTTPS MITM trust material, and injects the CA bundle into the sandbox process env | Gateway event-bus wiring follow-on | | Network policy | Split into MXC `network.proxy` + trimmed OpenShell policy on `process_container`; `isolation_session` still rejects network config | MXC feedback item M1 for persistent sessions | | Network middleware | ❌ rejected before launch because the MXC host proxy does not receive the gateway middleware registry | Gateway middleware-registry injection | @@ -141,9 +142,19 @@ environment. The development export surface remains the [`policy-to-mxc`](examples/policy-to-mxc.rs) example; there is no production `openshell policy export-mxc` subcommand yet. +The mapper normalizes filesystem paths to Windows form and does not add +gateway-configured host paths. The policy supplied for the sandbox is the only +source of filesystem grants. For `process_container`, the driver advertises +`supports_ui_policy = true` and emits the top-level `ui` object shared by MXC's +0.8 stable and 0.9 development schemas, with restrictive defaults or the exact +requested clipboard direction, graphical UI setting, and input-injection +setting. `isolation_session` advertises false, so the gateway rejects explicit +UI policy before the driver RPC; the mapper also rejects it before lifecycle +side effects as defense in depth. + If governed egress is disabled, any network rule fails closed rather than launching without an enforcement path. -Parity and matrix tests under [`tests/`](tests/) cover the mapper on the Windows MSVC lane. The driver performs this mapping automatically; there is no separate policy-export command or example. +Parity and matrix tests under [`tests/`](tests/) cover the mapper on the Windows MSVC lane. The 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. ## Packaging the demo for the demo box @@ -163,6 +174,7 @@ exits 0 rather than failing. | Task | What it runs | When to use | |---|---|---| | `windows:test:mxc-real:x64` | `tests/wxc_exec_real.rs` — Tier-2 invoker tests with `--ignored --test-threads=1`, including an HTTPS request through the host proxy | Pre-merge on any Windows host that has `wxc-exec`; dry-run tests always pass; enforcement tests probe-gate themselves | +| `windows:test:mxc-real:arm64` | Native ARM64 `tests/wxc_exec_real.rs` with the same contract | Pre-merge on an ARM64 Windows host with `wxc-exec` | | `windows:e2e:mxc` | `examples/run-mxc-e2e.ps1` — Tier-3 scenario runner, real binary, probe-gated | Demo box / nightly; needs the gateway + CLI binaries in the script directory | | `windows:e2e:mxc:mock` | Same runner with `-Mock` — wiring-only, no real `wxc-exec` needed | Any Windows host (CI, dev machine); validates wiring and the network-reject scenario | diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index 708aa3ae90..52518a43d3 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -49,6 +49,15 @@ pub enum MxcBackend { ProcessContainer, } +impl MxcBackend { + const fn containment(self) -> &'static str { + match self { + Self::IsolationSession => "isolation_session", + Self::ProcessContainer => "processcontainer", + } + } +} + /// Configuration for the MXC compute driver. /// /// Loaded from `[openshell.drivers.mxc]` in the gateway TOML file, or from @@ -462,6 +471,7 @@ impl MxcComputeBackend { resource_capabilities: None, rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, + supports_ui_policy: self.config.backend == MxcBackend::ProcessContainer, } } @@ -501,6 +511,7 @@ impl MxcComputeBackend { &MapCtx { sandbox_id: sandbox_id.to_string(), egress, + containment: self.config.backend.containment().into(), }, ) .map_err(|error| tonic::Status::invalid_argument(error.to_string())) @@ -890,6 +901,7 @@ async fn run_lifecycle( let mut readwrite_paths = mapped.readwrite_paths; append_tls_readwrite_grant(&mut readwrite_paths, host_proxy_ca_paths.as_ref()); let readonly_paths = mapped.readonly_paths; + let ui = mapped.ui; let filesystem = MxcFilesystem { readwrite_paths, readonly_paths, @@ -973,7 +985,14 @@ async fn run_lifecycle( capabilities: config.pc_capabilities.clone(), }; match invoker - .run_oneshot(&sandbox_id, filesystem, process_container, process, network) + .run_oneshot( + &sandbox_id, + filesystem, + process_container, + process, + network, + ui, + ) .await { Ok(child) => child, @@ -1223,6 +1242,7 @@ mod lifecycle_tests { use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; use openshell_core::proto::{ FilesystemPolicy, MiddlewareEndpointSelector, NetworkMiddlewareConfig, SandboxPolicy, + UiClipboardAccess, UiPolicy, }; use std::time::Duration; @@ -1230,6 +1250,18 @@ mod lifecycle_tests { driver_sandbox_with_command(id, "", vec!["cmd".into(), "/c".into(), "exit 0".into()]) } + #[test] + fn ui_policy_capability_tracks_configured_backend() { + let process_container = MxcComputeBackend::new_mocked(MxcComputeConfig::default()); + assert!(process_container.capabilities().supports_ui_policy); + + let isolation_session = MxcComputeBackend::new_mocked(MxcComputeConfig { + backend: MxcBackend::IsolationSession, + ..Default::default() + }); + assert!(!isolation_session.capabilities().supports_ui_policy); + } + fn driver_sandbox_with_command(id: &str, cwd: &str, command: Vec) -> DriverSandbox { let serde_json::Value::Object(driver_config) = serde_json::json!({ "command": command, @@ -1496,6 +1528,9 @@ mod lifecycle_tests { recorded.get("network").is_none(), "coarse path must not emit an MXC network block" ); + assert_eq!(recorded["ui"]["disable"], true); + assert_eq!(recorded["ui"]["clipboard"], "none"); + assert_eq!(recorded["ui"]["injection"], false); let host_path = std::path::Path::new(tmp.path()).join("hello.txt"); let mut found = false; @@ -1554,6 +1589,7 @@ mod lifecycle_tests { driver_sandbox_with_command("sb-egress", &share, cmd), policy.clone(), ); + backend .create_sandbox(&sandbox) .await @@ -1623,6 +1659,52 @@ mod lifecycle_tests { assert!(saw_redirect, "expected EgressRedirect platform event"); } + #[tokio::test] + async fn processcontainer_live_config_carries_explicit_ui_policy() { + let backend = MxcComputeBackend::new_mocked(MxcComputeConfig::default()); + let mut policy = fs_policy(&[]); + policy.ui = Some(UiPolicy { + allow_graphical_ui: true, + clipboard: UiClipboardAccess::All as i32, + allow_input_injection: true, + }); + let sandbox = with_policy(driver_sandbox("sb-pc-ui"), policy); + backend + .create_sandbox(&sandbox) + .await + .expect("create accepted"); + let _ = wait_for(&backend, "sb-pc-ui", |_| { + crate::mxc::mock_recorded_config("sb-pc-ui").is_some() + }) + .await; + let recorded = crate::mxc::mock_recorded_config("sb-pc-ui") + .expect("mock recorded processContainer config"); + assert_eq!(recorded["ui"]["disable"], false); + assert_eq!(recorded["ui"]["clipboard"], "all"); + assert_eq!(recorded["ui"]["injection"], true); + } + + #[tokio::test] + async fn isolation_session_rejects_ui_before_lifecycle_side_effects() { + let backend = MxcComputeBackend::new_mocked(MxcComputeConfig { + backend: MxcBackend::IsolationSession, + ..Default::default() + }); + let policy = SandboxPolicy { + ui: Some(UiPolicy::default()), + ..Default::default() + }; + let sandbox = with_policy(driver_sandbox("sb-iso-ui"), policy); + let error = backend + .create_sandbox(&sandbox) + .await + .expect_err("isolation UI must be rejected synchronously"); + assert_eq!(error.code(), tonic::Code::InvalidArgument); + assert!(error.message().contains("ui")); + assert!(backend.list_sandboxes().await.is_empty()); + assert!(crate::mxc::mock_recorded_config("sb-iso-ui").is_none()); + } + #[tokio::test] async fn negative_out_of_policy_write_is_denied_with_event() { let share_tmp = tempfile::tempdir().unwrap(); diff --git a/crates/openshell-driver-mxc/src/mxc.rs b/crates/openshell-driver-mxc/src/mxc.rs index 0a77565f83..1bb2304c1b 100644 --- a/crates/openshell-driver-mxc/src/mxc.rs +++ b/crates/openshell-driver-mxc/src/mxc.rs @@ -71,6 +71,42 @@ pub struct MxcNetwork { pub proxy: Option, } +/// Directional clipboard access in the MXC top-level `ui` policy. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MxcClipboardAccess { + None, + Read, + Write, + All, +} + +impl MxcClipboardAccess { + const fn as_str(self) -> &'static str { + match self { + Self::None => "none", + Self::Read => "read", + Self::Write => "write", + Self::All => "all", + } + } +} + +/// Cross-platform MXC UI policy emitted for a process container. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MxcUi { + pub disable: bool, + pub clipboard: MxcClipboardAccess, + pub injection: bool, +} + +fn ui_json(ui: &MxcUi) -> serde_json::Value { + serde_json::json!({ + "disable": ui.disable, + "clipboard": ui.clipboard.as_str(), + "injection": ui.injection, + }) +} + /// `processContainer`-specific knobs (one-shot `AppContainer` backend). #[derive(Debug, Default, Clone)] pub struct MxcProcessContainer { @@ -142,6 +178,7 @@ fn oneshot_config_json( pc: &MxcProcessContainer, process: &MxcProcess, network: Option<&MxcNetwork>, + ui: Option<&MxcUi>, ) -> serde_json::Value { let mut filesystem_json = serde_json::Map::new(); if !filesystem.readwrite_paths.is_empty() { @@ -182,6 +219,9 @@ fn oneshot_config_json( if let Some(network) = network { config["network"] = network_json(network); } + if let Some(ui) = ui { + config["ui"] = ui_json(ui); + } config } @@ -575,9 +615,16 @@ impl WxcExecInvoker { pc: MxcProcessContainer, process: MxcProcess, network: Option, + ui: Option, ) -> Result { - let config = - oneshot_config_json(container_id, &filesystem, &pc, &process, network.as_ref()); + let config = oneshot_config_json( + container_id, + &filesystem, + &pc, + &process, + network.as_ref(), + ui.as_ref(), + ); if self.mock { let grants: Vec = filesystem .readwrite_paths @@ -803,9 +850,39 @@ mod tests { env: Vec::new(), timeout: 0, }; - let config = oneshot_config_json("sb-1", &filesystem, &pc, &process, None); + let config = oneshot_config_json("sb-1", &filesystem, &pc, &process, None, None); assert!(config.get("network").is_none()); + assert!(config.get("ui").is_none()); + } + + #[test] + fn oneshot_config_json_emits_typed_ui_policy() { + let filesystem = MxcFilesystem::default(); + let pc = MxcProcessContainer::default(); + let process = MxcProcess { + command_line: "cmd /c exit 0".into(), + cwd: "C:\\work\\demo".into(), + env: Vec::new(), + timeout: 0, + }; + let ui = MxcUi { + disable: false, + clipboard: MxcClipboardAccess::Write, + injection: true, + }; + let config = oneshot_config_json("sb-ui", &filesystem, &pc, &process, None, Some(&ui)); + + assert_eq!(config["ui"]["disable"], false); + assert_eq!(config["ui"]["clipboard"], "write"); + assert_eq!(config["ui"]["injection"], true); + } + + #[test] + fn isolation_provision_config_never_synthesizes_ui() { + let config = + provision_config_json(DEFAULT_CONFIGURATION_ID, &MxcFilesystem::default(), None); + assert!(config.get("ui").is_none()); } #[test] diff --git a/crates/openshell-driver-mxc/src/policy.rs b/crates/openshell-driver-mxc/src/policy.rs index 30a0ca56da..0e387442c6 100644 --- a/crates/openshell-driver-mxc/src/policy.rs +++ b/crates/openshell-driver-mxc/src/policy.rs @@ -19,6 +19,7 @@ use std::net::SocketAddr; +use crate::mxc::{MxcClipboardAccess, MxcUi}; use openshell_core::proto::SandboxPolicy; use thiserror::Error; @@ -38,6 +39,9 @@ pub struct MappedConfig { /// Loopback address MXC redirects sandbox egress to. `None` when governed /// egress is disabled. pub proxy_addr: Option, + /// Top-level MXC UI policy for process containers. Isolation sessions keep + /// this absent because current MXC rejects the section on presence. + pub ui: Option, } /// Context passed to the mapper alongside the policy. @@ -49,6 +53,8 @@ pub struct MapCtx { /// Pattern-C governed-egress redirect address. When set, the embedded /// mapper uses `split_policy`; otherwise it uses the coarse MXC map. pub egress: Option, + /// MXC containment backend selected by the live driver. + pub containment: String, } /// A policy rule that the active mapper cannot enforce. @@ -109,6 +115,39 @@ fn extract_paths(config: &serde_json::Value, key: &str) -> Vec { .unwrap_or_default() } +fn extract_ui(config: &serde_json::Value) -> Result, MapError> { + let Some(ui) = config.get("ui") else { + return Ok(None); + }; + let disable = ui["disable"] + .as_bool() + .ok_or_else(|| MapError::Internal("mapped MXC ui.disable is not a boolean".into()))?; + let clipboard = match ui["clipboard"].as_str() { + Some("none") => MxcClipboardAccess::None, + Some("read") => MxcClipboardAccess::Read, + Some("write") => MxcClipboardAccess::Write, + Some("all") => MxcClipboardAccess::All, + Some(value) => { + return Err(MapError::Internal(format!( + "mapped MXC ui.clipboard has unknown value '{value}'" + ))); + } + None => { + return Err(MapError::Internal( + "mapped MXC ui.clipboard is not a string".into(), + )); + } + }; + let injection = ui["injection"] + .as_bool() + .ok_or_else(|| MapError::Internal("mapped MXC ui.injection is not a boolean".into()))?; + Ok(Some(MxcUi { + disable, + clipboard, + injection, + })) +} + impl PolicyMapper for EmbeddedPolicyMapper { fn map(&self, policy: Option<&SandboxPolicy>, ctx: &MapCtx) -> Result { let policy = policy.ok_or_else(|| { @@ -122,7 +161,7 @@ impl PolicyMapper for EmbeddedPolicyMapper { // Pattern C: MXC handles filesystem + a proxy redirect, while the // host CONNECT proxy receives the network-only trimmed policy. let opts = crate::policy_map::MxcMappingOptions { - containment: "processcontainer".to_owned(), + containment: ctx.containment.clone(), container_id: ctx.sandbox_id.clone(), proxy_redirect: Some(addr), ..Default::default() @@ -144,7 +183,7 @@ impl PolicyMapper for EmbeddedPolicyMapper { // yields an `error` loss for any host allowlist, which rejects // network policy below. let opts = crate::policy_map::MxcMappingOptions { - containment: "isolation_session".to_owned(), + containment: ctx.containment.clone(), container_id: ctx.sandbox_id.clone(), ..Default::default() }; @@ -176,12 +215,14 @@ impl PolicyMapper for EmbeddedPolicyMapper { .iter() .map(|p| normalize_path(p)) .collect(); + let ui = extract_ui(&config)?; Ok(MappedConfig { readwrite_paths: readwrite, readonly_paths: readonly, trimmed_policy, proxy_addr, + ui, }) } } @@ -197,6 +238,15 @@ mod tests { MapCtx { sandbox_id: "sb-test".into(), egress: None, + containment: "isolation_session".into(), + } + } + + fn processcontainer_ctx() -> MapCtx { + MapCtx { + sandbox_id: "sb-test".into(), + egress: None, + containment: "processcontainer".into(), } } @@ -255,6 +305,53 @@ mod tests { assert!(matches!(err, MapError::Unsupported(_))); } + #[test] + fn embedded_rejects_explicit_ui_on_isolation_session() { + use openshell_core::proto::UiPolicy; + + let mapper = EmbeddedPolicyMapper; + let policy = SandboxPolicy { + ui: Some(UiPolicy::default()), + ..Default::default() + }; + let err = mapper.map(Some(&policy), &demo_ctx()).unwrap_err(); + match err { + MapError::Unsupported(items) => { + assert_eq!(items.len(), 1); + assert_eq!(items[0].rule_kind, "ui"); + } + MapError::Internal(message) => { + panic!("expected unsupported UI, got internal error: {message}") + } + } + } + + #[test] + fn embedded_carries_typed_ui_for_processcontainer() { + use openshell_core::proto::{UiClipboardAccess, UiPolicy}; + + let mapper = EmbeddedPolicyMapper; + let policy = SandboxPolicy { + ui: Some(UiPolicy { + allow_graphical_ui: true, + clipboard: UiClipboardAccess::Read as i32, + allow_input_injection: true, + }), + ..Default::default() + }; + let result = mapper + .map(Some(&policy), &processcontainer_ctx()) + .expect("processContainer UI maps"); + assert_eq!( + result.ui, + Some(MxcUi { + disable: false, + clipboard: MxcClipboardAccess::Read, + injection: true, + }) + ); + } + #[test] fn embedded_split_normalizes_paths_and_returns_proxy_handoff() { use openshell_core::proto::{NetworkBinary, NetworkEndpoint, NetworkPolicyRule}; @@ -281,6 +378,7 @@ mod tests { sandbox_id: "sb-egress".into(), egress: Some(proxy_addr), + containment: "processcontainer".into(), }; let config = mapper.map(Some(&policy), &ctx).unwrap(); diff --git a/crates/openshell-driver-mxc/src/policy_map/loss.rs b/crates/openshell-driver-mxc/src/policy_map/loss.rs index cf366b0b9d..6af3b9a32b 100644 --- a/crates/openshell-driver-mxc/src/policy_map/loss.rs +++ b/crates/openshell-driver-mxc/src/policy_map/loss.rs @@ -25,7 +25,7 @@ pub struct LossItem { /// MXC capabilities that have no `OpenShell` *policy* equivalent. Surfaced in the /// loss report so reviewers understand the mapping is not symmetric. pub const OPEN_SHELL_SUPERSET_GAPS: &[&str] = &[ - "MXC UI policy has no OpenShell policy equivalent: ui.disable, ui.clipboard, and ui.injection.", + "MXC processContainer UI refinements have no portable OpenShell policy equivalent: isolation, desktopSystemControl, systemSettings, and ime.", "MXC lifecycle fields have no OpenShell policy equivalent: destroyOnExit, preservePolicy, phase, and sandboxId.", "MXC backend selection and backend-specific blocks are outside OpenShell policy YAML.", "MXC process command, cwd, env, and timeout are runtime config fields, not OpenShell policy fields.", diff --git a/crates/openshell-driver-mxc/src/policy_map/map.rs b/crates/openshell-driver-mxc/src/policy_map/map.rs index 4bfcc316a8..1d660cc5c5 100644 --- a/crates/openshell-driver-mxc/src/policy_map/map.rs +++ b/crates/openshell-driver-mxc/src/policy_map/map.rs @@ -11,7 +11,9 @@ use std::net::SocketAddr; -use openshell_core::proto::{NetworkEndpoint, NetworkPolicyRule, SandboxPolicy}; +use openshell_core::proto::{ + NetworkEndpoint, NetworkPolicyRule, SandboxPolicy, UiClipboardAccess, UiPolicy, +}; use serde_json::{Value, json}; use super::config::{ @@ -222,12 +224,10 @@ fn build_split_mxc_config( "process": process, "filesystem": filesystem, "network": network, - "ui": { - "disable": true, - "clipboard": "none", - "injection": false, - }, }); + if let Some(ui) = map_ui(policy.ui.as_ref(), &opts.containment, items) { + config["ui"] = ui; + } // No network hosts, so backend-specific network blocks (processContainer // internetClient, etc.) are not added — correct for the proxy path. @@ -275,18 +275,89 @@ fn build_mxc_config( "process": process, "filesystem": filesystem, "network": network, - "ui": { - "disable": true, - "clipboard": "none", - "injection": false, - }, }); + if let Some(ui) = map_ui(policy.ui.as_ref(), &opts.containment, items) { + config["ui"] = ui; + } add_backend_specific_config(&mut config, &opts.containment, &allowed_hosts, items); add_static_policy_loss(policy, opts, items); config } +fn map_ui(ui: Option<&UiPolicy>, containment: &str, items: &mut Vec) -> Option { + let restrictive = || { + json!({ + "disable": true, + "clipboard": "none", + "injection": false, + }) + }; + + match containment { + "processcontainer" | "process" => { + let Some(ui) = ui else { + // Preserve the mapper's existing deny posture for policies + // authored before the optional OpenShell UI section existed. + return Some(restrictive()); + }; + let clipboard = match UiClipboardAccess::try_from(ui.clipboard) { + Ok(UiClipboardAccess::Unspecified | UiClipboardAccess::None) => "none", + Ok(UiClipboardAccess::Read) => "read", + Ok(UiClipboardAccess::Write) => "write", + Ok(UiClipboardAccess::All) => "all", + Err(_) => { + add_loss( + items, + "ui.clipboard", + "error", + &format!( + "OpenShell UI clipboard policy has unknown enum value {}.", + ui.clipboard + ), + "directional clipboard access", + "MXC receives the restrictive clipboard=none fallback; sandbox creation is rejected.", + ); + "none" + } + }; + Some(json!({ + "disable": !ui.allow_graphical_ui, + "clipboard": clipboard, + "injection": ui.allow_input_injection, + })) + } + "isolation_session" => { + if ui.is_some() { + add_loss( + items, + "ui", + "error", + "MXC isolation_session rejects every explicitly supplied top-level UI policy, including an empty or deny-only policy.", + "OpenShell UI policy", + "The UI block is omitted and sandbox creation is rejected before wxc-exec is invoked.", + ); + } + None + } + _ => { + if ui.is_some() { + add_loss( + items, + "ui", + "error", + &format!( + "OpenShell UI policy enforcement is not supported by the MXC `{containment}` mapping target." + ), + "OpenShell UI policy", + "The generated config remains at the mapper's restrictive UI defaults and the caller must reject the mapping.", + ); + } + Some(restrictive()) + } + } +} + fn map_filesystem( policy: &SandboxPolicy, opts: &MxcMappingOptions, diff --git a/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs b/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs index a4ed71117a..d5322a9ecf 100644 --- a/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs +++ b/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs @@ -24,7 +24,7 @@ use openshell_core::proto::{ FilesystemPolicy, GraphqlOperation, L7Allow, L7DenyRule, L7Rule, LandlockPolicy, MiddlewareEndpointSelector, NetworkBinary, NetworkEndpoint, NetworkMiddlewareConfig, - NetworkPolicyRule, ProcessPolicy, SandboxPolicy, + NetworkPolicyRule, ProcessPolicy, SandboxPolicy, UiClipboardAccess, UiPolicy, }; use openshell_driver_mxc::{ EmbeddedPolicyMapper, MapCtx, MapError, MxcMappingOptions, PolicyMapper, map_to_mxc, @@ -82,6 +82,12 @@ fn pc_split_opts() -> MxcMappingOptions { } } +fn pc_opts() -> MxcMappingOptions { + MxcMappingOptions { + containment: "processcontainer".to_owned(), + ..Default::default() + } +} /// Build a minimal policy with one network rule whose endpoints carry a single /// endpoint set up by the caller. fn net_policy(key: &str, ep: NetworkEndpoint) -> SandboxPolicy { @@ -930,6 +936,7 @@ fn b_seam_returns_unsupported_on_error_field() { let ctx = MapCtx { sandbox_id: "sb-test".into(), egress: None, // coarse path → isolation_session → network policy errors + containment: "isolation_session".into(), }; let err = mapper.map(Some(&policy), &ctx).unwrap_err(); assert!( @@ -1032,6 +1039,90 @@ fn c_split_empty_allowed_hosts_with_network_rules() { ); } +#[test] +fn a_processcontainer_maps_ui_capabilities_exactly() { + for (clipboard, expected) in [ + (UiClipboardAccess::Unspecified, "none"), + (UiClipboardAccess::None, "none"), + (UiClipboardAccess::Read, "read"), + (UiClipboardAccess::Write, "write"), + (UiClipboardAccess::All, "all"), + ] { + let policy = SandboxPolicy { + ui: Some(UiPolicy { + allow_graphical_ui: true, + clipboard: clipboard as i32, + allow_input_injection: true, + }), + ..Default::default() + }; + let result = map_to_mxc(&policy, &pc_opts()); + assert_eq!(result.config["ui"]["disable"], false); + assert_eq!(result.config["ui"]["clipboard"], expected); + assert_eq!(result.config["ui"]["injection"], true); + assert_eq!(result.config["ui"].as_object().unwrap().len(), 3); + assert!(result.loss.iter().all(|item| item.path != "ui")); + } +} + +#[test] +fn c_processcontainer_absent_or_empty_ui_is_default_deny() { + for policy in [ + SandboxPolicy::default(), + SandboxPolicy { + ui: Some(UiPolicy::default()), + ..Default::default() + }, + ] { + let result = map_to_mxc(&policy, &pc_opts()); + assert_eq!(result.config["ui"]["disable"], true); + assert_eq!(result.config["ui"]["clipboard"], "none"); + assert_eq!(result.config["ui"]["injection"], false); + } +} + +#[test] +fn b_isolation_session_omits_absent_ui_and_rejects_explicit_ui() { + let opts = MxcMappingOptions { + containment: "isolation_session".into(), + ..Default::default() + }; + let absent = map_to_mxc(&SandboxPolicy::default(), &opts); + assert!(absent.config.get("ui").is_none()); + + let explicit = map_to_mxc( + &SandboxPolicy { + ui: Some(UiPolicy::default()), + ..Default::default() + }, + &opts, + ); + assert!(explicit.config.get("ui").is_none()); + assert_single_loss( + &explicit.loss, + "ui", + "error", + "isolation_session explicit UI", + ); +} + +#[test] +fn a_split_maps_ui_to_mxc_and_omits_it_from_proxy_policy() { + let policy = SandboxPolicy { + version: 1, + ui: Some(UiPolicy { + clipboard: UiClipboardAccess::Write as i32, + ..Default::default() + }), + ..Default::default() + }; + let result = split_policy(&policy, &pc_split_opts()).expect("split"); + assert_eq!(result.mxc_config["ui"]["disable"], true); + assert_eq!(result.mxc_config["ui"]["clipboard"], "write"); + assert_eq!(result.mxc_config["ui"]["injection"], false); + assert!(result.proxy_policy.ui.is_none()); +} + // ─── DRIFT GUARD ───────────────────────────────────────────────────────────── // // Serialize policies via openshell_policy::serialize_sandbox_policy, collect @@ -1046,11 +1137,14 @@ fn c_split_empty_allowed_hosts_with_network_rules() { /// "landlock" — loss item emitted in add_static_policy_loss /// "process" — loss items for run_as_user / run_as_group /// "network_policies" — mapped via map_network / delegated in split +/// "network_middlewares" — error loss in coarse map / delegated in split +/// "ui" — exact processContainer map / explicit unsupported loss const HANDLED_TOPLEVEL: &[&str] = &[ "version", "filesystem_policy", "landlock", "process", + "ui", "network_policies", "network_middlewares", ]; @@ -1121,6 +1215,7 @@ fn handled_fields_inventory() { run_as_user: "sandbox".into(), run_as_group: "sandbox".into(), }), + ui: Some(UiPolicy::default()), network_policies: { let mut m = std::collections::HashMap::new(); m.insert( diff --git a/crates/openshell-driver-mxc/tests/wxc_exec_real.rs b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs index a3bbdc7d07..396673768d 100644 --- a/crates/openshell-driver-mxc/tests/wxc_exec_real.rs +++ b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs @@ -36,7 +36,7 @@ use openshell_core::proto::{ FilesystemPolicy, NetworkBinary, NetworkEndpoint, NetworkPolicyRule, SandboxPolicy, }; use openshell_driver_mxc::{MxcComputeBackend, MxcComputeConfig}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::process::Command; use std::time::Duration; @@ -81,10 +81,19 @@ fn temp_fixture() -> (tempfile::TempDir, String) { /// Invoke `wxc-exec --config-base64 --dry-run` synchronously. /// Returns `(exit_code, stdout, stderr)`. fn dry_run(wxc: &PathBuf, config: &serde_json::Value) -> (i32, String, String) { + dry_run_with_args(wxc, config, &[]) +} + +fn dry_run_with_args( + wxc: &PathBuf, + config: &serde_json::Value, + args: &[&str], +) -> (i32, String, String) { let json = serde_json::to_string(config).expect("config serialize"); let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); let out = Command::new(wxc) + .args(args) .arg("--config-base64") .arg(&b64) .arg("--dry-run") @@ -97,6 +106,32 @@ fn dry_run(wxc: &PathBuf, config: &serde_json::Value) -> (i32, String, String) { (code, stdout, stderr) } +fn wxc_version(wxc: &Path) -> Option<(u64, u64, u64, String)> { + // wxc-exec does not expose --version. Release builds carry the Cargo + // version in the standard Windows ProductVersion resource. + let path_literal = wxc.to_string_lossy().replace('\'', "''"); + let output = Command::new("powershell.exe") + .args(["-NoProfile", "-NonInteractive", "-Command"]) + .arg(format!( + "(Get-Item -LiteralPath '{path_literal}').VersionInfo.ProductVersion" + )) + .output() + .ok()?; + let raw = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let version = raw.split_whitespace().find_map(|token| { + let core = token + .trim_matches(|ch: char| !ch.is_ascii_digit() && ch != '.') + .split(['+', '-']) + .next()?; + let mut parts = core.split('.'); + let major = parts.next()?.parse().ok()?; + let minor = parts.next()?.parse().ok()?; + let patch = parts.next()?.parse().ok()?; + Some((major, minor, patch)) + })?; + Some((version.0, version.1, version.2, raw)) +} + // ── (a) Dry-run contract tests ──────────────────────────────────────────────── // // These PASS on any box that has the wxc-exec binary — no enforcement backend @@ -133,6 +168,103 @@ fn dryrun_accepts_minimal_processcontainer_config() { ); } +/// Every `OpenShell` clipboard direction maps to MXC's shared top-level UI +/// contract, with graphical UI and injection carried as independent booleans. +#[test] +#[ignore = "requires real wxc-exec"] +fn dryrun_accepts_processcontainer_ui_policy_matrix() { + let Some(wxc) = wxc_path() else { + eprintln!("SKIP: wxc-exec not found"); + return; + }; + + let tempdir = tempfile::tempdir().expect("tempdir"); + let temp_path = tempdir.path().to_string_lossy().into_owned(); + for clipboard in ["none", "read", "write", "all"] { + let config = serde_json::json!({ + "version": "0.7.0-alpha", + "containerId": format!("test-ui-{clipboard}"), + "containment": "processcontainer", + "process": { + "commandLine": "cmd /c exit 0", + "cwd": temp_path.clone(), + "timeout": 0, + }, + "filesystem": { + "readwritePaths": [temp_path.clone()], + }, + "ui": { + "disable": false, + "clipboard": clipboard, + "injection": true, + }, + }); + + let (code, stdout, stderr) = dry_run(&wxc, &config); + assert_eq!( + code, 0, + "processcontainer UI policy clipboard={clipboard} rejected by --dry-run\nstdout={stdout}\nstderr={stderr}" + ); + } +} + +/// MXC 0.8 and the 0.9 development schema reject the shared top-level +/// UI object on `isolation_session`, while omission remains accepted. Older +/// 0.7 builds accepted and ignored the object, so `OpenShell`'s gateway-level +/// capability check is the stable enforcement boundary across versions. +#[test] +#[ignore = "requires real wxc-exec"] +fn dryrun_current_schema_rejects_isolation_session_ui() { + let Some(wxc) = wxc_path() else { + eprintln!("SKIP: wxc-exec not found"); + return; + }; + let Some((major, minor, _patch, raw_version)) = wxc_version(&wxc) else { + eprintln!("SKIP: could not determine wxc-exec version"); + return; + }; + if (major, minor) < (0, 8) { + eprintln!("SKIP: {raw_version} predates the isolation_session UI rejection contract"); + return; + } + + let base = serde_json::json!({ + "phase": "provision", + "containment": "isolation_session", + "network": { + "defaultPolicy": "allow", + "allowLocalNetwork": true, + }, + }); + let (code, stdout, stderr) = dry_run_with_args(&wxc, &base, &["--experimental"]); + let output = format!("{stdout} {stderr}").to_ascii_lowercase(); + if code != 0 + && output.contains("backend_unavailable") + && output.contains("not available in this build") + { + eprintln!("SKIP: {raw_version} was built without isolation_session support"); + return; + } + assert_eq!( + code, 0, + "current isolation_session schema must accept omission of UI\nversion={raw_version}\nstdout={stdout}\nstderr={stderr}" + ); + + let mut with_ui = base; + with_ui["ui"] = serde_json::json!({ "disable": true }); + let (code, stdout, stderr) = dry_run_with_args(&wxc, &with_ui, &["--experimental"]); + assert_ne!( + code, 0, + "current isolation_session schema unexpectedly accepted UI\nversion={raw_version}\nstdout={stdout}\nstderr={stderr}" + ); + assert!( + format!("{stdout} {stderr}") + .to_ascii_lowercase() + .contains("ui"), + "rejection should identify UI\nversion={raw_version}\nstdout={stdout}\nstderr={stderr}" + ); +} + /// Network block without proxy (defaultPolicy block, empty host lists) accepted. #[test] #[ignore = "requires real wxc-exec"] diff --git a/crates/openshell-driver-podman/src/driver.rs b/crates/openshell-driver-podman/src/driver.rs index 62142a3157..566f77fdf2 100644 --- a/crates/openshell-driver-podman/src/driver.rs +++ b/crates/openshell-driver-podman/src/driver.rs @@ -547,6 +547,7 @@ impl PodmanComputeDriver { }), rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, + supports_ui_policy: false, }) } diff --git a/crates/openshell-driver-vm/src/driver.rs b/crates/openshell-driver-vm/src/driver.rs index 1ad10f654b..9a82e6ef5e 100644 --- a/crates/openshell-driver-vm/src/driver.rs +++ b/crates/openshell-driver-vm/src/driver.rs @@ -1002,6 +1002,7 @@ impl VmDriver { .to_string_lossy() .into_owned(), rootfs_tar_max_bytes: self.config.rootfs_tar_max_bytes(), + supports_ui_policy: false, } } diff --git a/crates/openshell-policy-schema/src/lib.rs b/crates/openshell-policy-schema/src/lib.rs index 62c2b06dfe..46968f23e8 100644 --- a/crates/openshell-policy-schema/src/lib.rs +++ b/crates/openshell-policy-schema/src/lib.rs @@ -155,6 +155,12 @@ pub struct PolicyDocument { skip_serializing_if = "Option::is_none" )] pub process: Option, + #[serde( + default, + deserialize_with = "deserialize_non_null_optional_field", + skip_serializing_if = "Option::is_none" + )] + pub ui: Option, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub network_policies: BTreeMap, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] @@ -196,6 +202,34 @@ pub struct ProcessPolicy { pub run_as_group: String, } +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct UiPolicy { + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub allow_graphical_ui: bool, + #[serde(default, skip_serializing_if = "UiClipboardAccess::is_none")] + pub clipboard: UiClipboardAccess, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub allow_input_injection: bool, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum UiClipboardAccess { + #[default] + None, + Read, + Write, + All, +} + +impl UiClipboardAccess { + #[must_use] + pub const fn is_none(&self) -> bool { + matches!(self, Self::None) + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct NetworkPolicyRule { diff --git a/crates/openshell-policy/src/lib.rs b/crates/openshell-policy/src/lib.rs index a9d2ab0bbf..672cf90280 100644 --- a/crates/openshell-policy/src/lib.rs +++ b/crates/openshell-policy/src/lib.rs @@ -29,9 +29,27 @@ use openshell_core::mcp::{DEFAULT_MCP_PROTOCOL_VERSION, McpProtocolVersion}; use openshell_core::proto::{ FilesystemPolicy, GraphqlOperation, L7Allow, L7DenyRule, L7QueryMatcher, L7Rule, LandlockPolicy, McpOptions, NetworkBinary, NetworkEndpoint, NetworkPolicyRule, ProcessPolicy, - SandboxPolicy, + SandboxPolicy, UiClipboardAccess, UiPolicy, }; +const fn ui_clipboard_to_proto(value: UiClipboardAccessDef) -> UiClipboardAccess { + match value { + UiClipboardAccessDef::None => UiClipboardAccess::None, + UiClipboardAccessDef::Read => UiClipboardAccess::Read, + UiClipboardAccessDef::Write => UiClipboardAccess::Write, + UiClipboardAccessDef::All => UiClipboardAccess::All, + } +} + +fn ui_clipboard_from_proto(value: i32) -> UiClipboardAccessDef { + match UiClipboardAccess::try_from(value).unwrap_or(UiClipboardAccess::Unspecified) { + UiClipboardAccess::Unspecified | UiClipboardAccess::None => UiClipboardAccessDef::None, + UiClipboardAccess::Read => UiClipboardAccessDef::Read, + UiClipboardAccess::Write => UiClipboardAccessDef::Write, + UiClipboardAccess::All => UiClipboardAccessDef::All, + } +} + pub use compose::{ PROVIDER_RULE_NAME_PREFIX, ProviderPolicyLayer, compose_effective_policy, is_provider_rule_name, provider_rule_name, strip_provider_rule_names, @@ -60,6 +78,7 @@ use openshell_policy_schema::{ NetworkCredentialBinding as NetworkCredentialBindingDef, NetworkEndpoint as NetworkEndpointDef, NetworkPolicyRule as NetworkPolicyRuleDef, ParameterMatcher as ParamMatcherDef, PolicyDocument as PolicyFile, ProcessPolicy as ProcessDef, QueryMatcher as QueryMatcherDef, + UiClipboardAccess as UiClipboardAccessDef, UiPolicy as UiDef, }; fn json_rpc_config_from_proto(max_body_bytes: u32) -> Option { @@ -599,6 +618,11 @@ fn to_proto(raw: PolicyFile) -> Result { run_as_user: p.run_as_user, run_as_group: p.run_as_group, }), + ui: raw.ui.map(|ui| UiPolicy { + allow_graphical_ui: ui.allow_graphical_ui, + clipboard: ui_clipboard_to_proto(ui.clipboard) as i32, + allow_input_injection: ui.allow_input_injection, + }), network_policies, network_middlewares, }) @@ -642,6 +666,12 @@ fn from_proto(policy: &SandboxPolicy) -> Result { } }); + let ui = policy.ui.as_ref().map(|ui| UiDef { + allow_graphical_ui: ui.allow_graphical_ui, + clipboard: ui_clipboard_from_proto(ui.clipboard), + allow_input_injection: ui.allow_input_injection, + }); + let network_policies = policy .network_policies .iter() @@ -780,6 +810,7 @@ fn from_proto(policy: &SandboxPolicy) -> Result { filesystem_policy, landlock, process, + ui, network_policies, network_middlewares, }) @@ -955,6 +986,7 @@ pub fn restrictive_default_policy() -> SandboxPolicy { compatibility: "best_effort".into(), }), process: None, + ui: None, network_policies: HashMap::new(), network_middlewares: HashMap::default(), } @@ -990,6 +1022,8 @@ const MAX_PATH_LENGTH: usize = 4096; pub enum PolicyViolation { /// An explicit `run_as_user` or `run_as_group` is unsafe. InvalidProcessIdentity { field: &'static str, value: String }, + /// The protobuf carries a clipboard enum value unknown to this version. + InvalidUiClipboardAccess { value: i32 }, /// A filesystem path contains `..` components. PathTraversal { path: String }, /// A filesystem path is not absolute (does not start with `/`). @@ -1091,6 +1125,12 @@ impl fmt::Display for PolicyViolation { "{field} must be 'sandbox' or a numeric UID/GID in range [{MIN_SANDBOX_UID}, {MAX_SANDBOX_UID}], got '{value}'" ) } + Self::InvalidUiClipboardAccess { value } => { + write!( + f, + "ui clipboard access has unknown enum value {value}; expected unspecified, none, read, write, or all" + ) + } Self::PathTraversal { path } => { write!(f, "path contains '..' traversal component: {path}") } @@ -1297,6 +1337,7 @@ impl fmt::Display for PolicyViolation { /// /// Checks performed: /// - Explicit `run_as_user` / `run_as_group` fields must be safe identities +/// - UI clipboard access must use a recognized enum value /// - Filesystem paths must be absolute (start with `/`) /// - Filesystem paths must not contain `..` components /// - Read-write paths must not be overly broad (just `/`) @@ -1356,6 +1397,14 @@ fn validate_sandbox_policy_with_mcp_presence( }); } + if let Some(ref ui) = policy.ui + && UiClipboardAccess::try_from(ui.clipboard).is_err() + { + violations.push(PolicyViolation::InvalidUiClipboardAccess { + value: ui.clipboard, + }); + } + // Check filesystem paths if let Some(ref fs) = policy.filesystem { let total_paths = fs.read_only.len() + fs.read_write.len(); @@ -1948,6 +1997,75 @@ network_policies: assert!(json.get("network_policies").is_some()); } + #[test] + fn ui_absence_and_explicit_empty_remain_distinct() { + let absent = parse_sandbox_policy("version: 1\n").expect("absent UI parses"); + assert!(absent.ui.is_none()); + let absent_yaml = serialize_sandbox_policy(&absent).expect("absent UI serializes"); + assert!(!absent_yaml.contains("\nui:")); + + let explicit = parse_sandbox_policy("version: 1\nui: {}\n").expect("empty UI parses"); + let ui = explicit.ui.as_ref().expect("UI presence preserved"); + assert!(!ui.allow_graphical_ui); + assert_eq!(ui.clipboard, UiClipboardAccess::None as i32); + assert!(!ui.allow_input_injection); + + let explicit_yaml = serialize_sandbox_policy(&explicit).expect("empty UI serializes"); + assert!(explicit_yaml.contains("ui: {}"), "got:\n{explicit_yaml}"); + let reparsed = parse_sandbox_policy(&explicit_yaml).expect("empty UI reparses"); + assert!(reparsed.ui.is_some()); + } + + #[test] + fn ui_policy_round_trips_all_clipboard_directions() { + for (wire, expected) in [ + ("none", UiClipboardAccess::None), + ("read", UiClipboardAccess::Read), + ("write", UiClipboardAccess::Write), + ("all", UiClipboardAccess::All), + ] { + let yaml = format!( + "version: 1\nui:\n allow_graphical_ui: true\n clipboard: {wire}\n allow_input_injection: true\n" + ); + let policy = parse_sandbox_policy(&yaml).expect("UI policy parses"); + let ui = policy.ui.as_ref().expect("UI policy present"); + assert!(ui.allow_graphical_ui); + assert_eq!(ui.clipboard, expected as i32); + assert!(ui.allow_input_injection); + + let serialized = serialize_sandbox_policy(&policy).expect("UI policy serializes"); + let reparsed = parse_sandbox_policy(&serialized).expect("UI policy reparses"); + assert_eq!(reparsed, policy); + } + } + + #[test] + fn ui_policy_rejects_unknown_yaml_clipboard_value() { + let error = parse_sandbox_policy("version: 1\nui:\n clipboard: execute\n") + .expect_err("unknown clipboard value must fail"); + assert!( + error + .to_string() + .contains("failed to parse sandbox policy YAML") + ); + } + + #[test] + fn ui_policy_validation_rejects_unknown_proto_clipboard_value() { + let policy = SandboxPolicy { + ui: Some(UiPolicy { + clipboard: 99, + ..Default::default() + }), + ..Default::default() + }; + let violations = validate_sandbox_policy(&policy).expect_err("unknown enum must fail"); + assert_eq!( + violations, + vec![PolicyViolation::InvalidUiClipboardAccess { value: 99 }] + ); + } + /// Verify that `allowed_ips` survives the round-trip. #[test] fn round_trip_preserves_allowed_ips() { @@ -3574,6 +3692,7 @@ network_policies: process: None, filesystem: None, landlock: None, + ui: None, network_policies: HashMap::new(), network_middlewares: HashMap::default(), }; @@ -4032,6 +4151,7 @@ network_policies: }), filesystem: None, landlock: None, + ui: None, network_policies: HashMap::new(), network_middlewares: HashMap::default(), }; @@ -4048,6 +4168,7 @@ network_policies: }), filesystem: None, landlock: None, + ui: None, network_policies: HashMap::new(), network_middlewares: HashMap::default(), }; @@ -4120,6 +4241,7 @@ network_policies: }), filesystem: None, landlock: None, + ui: None, network_policies: HashMap::new(), network_middlewares: HashMap::default(), }; diff --git a/crates/openshell-server/src/compute/mod.rs b/crates/openshell-server/src/compute/mod.rs index 4cca02d6ac..065c078d61 100644 --- a/crates/openshell-server/src/compute/mod.rs +++ b/crates/openshell-server/src/compute/mod.rs @@ -307,6 +307,7 @@ enum BeginDelete { } #[derive(Debug, Clone)] +#[allow(clippy::struct_excessive_bools)] pub struct ComputeDriverInfoSnapshot { /// Gateway-selected driver name used for routing and `driver_config` keys. pub name: String, @@ -326,6 +327,9 @@ pub struct ComputeDriverInfoSnapshot { pub rootfs_tar_staging_dir: String, /// Maximum rootfs tar file size in bytes. pub rootfs_tar_max_bytes: u64, + /// Whether this configured driver instance completely enforces the portable + /// UI policy contract. + pub supports_ui_policy: bool, } /// Interval between store-vs-backend reconciliation sweeps. @@ -681,6 +685,7 @@ impl ComputeRuntime { resource_capabilities: capabilities.resource_capabilities, rootfs_tar_staging_dir: capabilities.rootfs_tar_staging_dir, rootfs_tar_max_bytes: capabilities.rootfs_tar_max_bytes, + supports_ui_policy: capabilities.supports_ui_policy, }; let default_image = capabilities.default_image; let gateway_listener_requirements = match driver @@ -918,6 +923,7 @@ impl ComputeRuntime { } pub async fn validate_sandbox_create(&self, sandbox: &Sandbox) -> Result<(), Status> { + self.validate_policy_capabilities(sandbox)?; let mut driver_sandbox = driver_sandbox_from_public(sandbox, &self.driver_info.name) .map_err(|status| *status)?; // Peek, never consume: create runs the same path immediately after and @@ -942,6 +948,22 @@ impl ComputeRuntime { .map(|_| ()) } + fn validate_policy_capabilities(&self, sandbox: &Sandbox) -> Result<(), Status> { + let has_explicit_ui = sandbox + .spec + .as_ref() + .and_then(|spec| spec.policy.as_ref()) + .and_then(|policy| policy.ui.as_ref()) + .is_some(); + if has_explicit_ui && !self.driver_info.supports_ui_policy { + return Err(Status::invalid_argument(format!( + "compute driver '{}' does not support the complete UI policy contract; remove the explicit ui section or select a supporting driver/backend", + self.driver_info.name + ))); + } + Ok(()) + } + pub async fn create_sandbox( &self, sandbox: Sandbox, @@ -964,6 +986,9 @@ impl ComputeRuntime { launch_authentication: Option>, await_main_process_attachment: bool, ) -> Result { + // Defense in depth for internal callers that bypass the public create + // handler's ValidateSandboxCreate step. This check has no side effects. + self.validate_policy_capabilities(&sandbox)?; let sandbox_id = sandbox.object_id().to_string(); let mut sandbox = sandbox; @@ -5062,6 +5087,7 @@ impl ComputeDriver for NoopTestDriver { resource_capabilities: None, rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, + supports_ui_policy: false, }, )) } @@ -5209,6 +5235,7 @@ pub fn new_test_runtime_with_driver( resource_capabilities: None, rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, + supports_ui_policy: false, }, telemetry_compute_driver: TelemetryComputeDriver::custom(), driver_process: None, @@ -5235,6 +5262,7 @@ mod tests { GetSandboxResponse, StartSandboxResponse, StopSandboxRequest, StopSandboxResponse, ValidateSandboxCreateResponse, WatchSandboxesDeletedEvent, WatchSandboxesSandboxEvent, }; + use openshell_core::proto::{SandboxPolicy as PublicSandboxPolicy, UiPolicy}; use std::collections::HashMap; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex as TestMutex}; @@ -5514,6 +5542,8 @@ mod tests { listed_sandboxes: Vec, current_sandboxes: Vec, workspace_rpcs_unimplemented: bool, + validate_create_calls: AtomicUsize, + create_calls: AtomicUsize, } #[tonic::async_trait] @@ -5546,6 +5576,7 @@ mod tests { resource_capabilities: None, rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, + supports_ui_policy: false, })) } @@ -5562,6 +5593,7 @@ mod tests { &self, _request: Request, ) -> Result, Status> { + self.validate_create_calls.fetch_add(1, Ordering::Relaxed); Ok(tonic::Response::new(ValidateSandboxCreateResponse {})) } @@ -5613,6 +5645,7 @@ mod tests { &self, _request: Request, ) -> Result, Status> { + self.create_calls.fetch_add(1, Ordering::Relaxed); Ok(tonic::Response::new(CreateSandboxResponse {})) } @@ -5899,6 +5932,7 @@ mod tests { resource_capabilities: None, rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, + supports_ui_policy: false, })) } @@ -6116,6 +6150,7 @@ mod tests { resource_capabilities: None, rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, + supports_ui_policy: false, }, telemetry_compute_driver: TelemetryComputeDriver::custom(), driver_process: None, @@ -6133,6 +6168,83 @@ mod tests { } } + fn sandbox_with_explicit_ui(id: &str) -> Sandbox { + let mut sandbox = sandbox_record(id, "ui-policy", SandboxPhase::Provisioning); + sandbox.spec = Some(SandboxSpec { + policy: Some(PublicSandboxPolicy { + ui: Some(UiPolicy::default()), + ..Default::default() + }), + ..Default::default() + }); + sandbox + } + + #[tokio::test] + async fn explicit_ui_policy_rejects_before_unsupported_driver_validation() { + let driver = Arc::new(TestDriver::default()); + let runtime = test_runtime(driver.clone()).await; + + let error = runtime + .validate_sandbox_create(&sandbox_with_explicit_ui("sb-ui-validate")) + .await + .expect_err("an unsupported driver must reject explicit UI policy"); + + assert_eq!(error.code(), Code::InvalidArgument); + assert!(error.message().contains("complete UI policy contract")); + assert_eq!( + driver.validate_create_calls.load(Ordering::Relaxed), + 0, + "gateway capability validation must run before the driver RPC" + ); + } + + #[tokio::test] + async fn explicit_ui_policy_rejects_before_unsupported_driver_create() { + let driver = Arc::new(TestDriver::default()); + let runtime = test_runtime(driver.clone()).await; + + let error = runtime + .create_sandbox(sandbox_with_explicit_ui("sb-ui-create"), None, false) + .await + .expect_err("an internal caller must not bypass UI capability validation"); + + assert_eq!(error.code(), Code::InvalidArgument); + assert_eq!( + driver.create_calls.load(Ordering::Relaxed), + 0, + "unsupported UI policy must fail before provisioning" + ); + } + + #[tokio::test] + async fn explicit_ui_policy_reaches_driver_when_capability_is_complete() { + let driver = Arc::new(TestDriver::default()); + let mut runtime = test_runtime(driver.clone()).await; + runtime.driver_info.supports_ui_policy = true; + + runtime + .validate_sandbox_create(&sandbox_with_explicit_ui("sb-ui-supported")) + .await + .expect("a driver advertising complete UI support accepts validation"); + + assert_eq!(driver.validate_create_calls.load(Ordering::Relaxed), 1); + } + + #[tokio::test] + async fn absent_ui_policy_preserves_unsupported_driver_behavior() { + let driver = Arc::new(TestDriver::default()); + let runtime = test_runtime(driver.clone()).await; + let sandbox = sandbox_record("sb-no-ui", "no-ui-policy", SandboxPhase::Provisioning); + + runtime + .validate_sandbox_create(&sandbox) + .await + .expect("an absent UI section must preserve existing behavior"); + + assert_eq!(driver.validate_create_calls.load(Ordering::Relaxed), 1); + } + async fn test_runtime_with_gateway_managed_lifecycle( driver: SharedComputeDriver, driver_name: &str, @@ -10524,6 +10636,7 @@ mod tests { }), workspace: "default".to_string(), }], + ..Default::default() })) .await; @@ -10695,6 +10808,7 @@ mod tests { })), workspace: "default".to_string(), }], + ..Default::default() })) .await; diff --git a/crates/openshell-server/src/grpc/mod.rs b/crates/openshell-server/src/grpc/mod.rs index f66a0a011a..751fd1d995 100644 --- a/crates/openshell-server/src/grpc/mod.rs +++ b/crates/openshell-server/src/grpc/mod.rs @@ -249,6 +249,7 @@ impl OpenShell for OpenShellService { .resource_capabilities .as_ref() .map(|resources| public_resource_capabilities(*resources)), + supports_ui_policy: driver.supports_ui_policy, }), }) .collect(); diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index d986c5cba9..feabde75d4 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -18851,6 +18851,35 @@ mod tests { ); } + #[test] + fn policy_hash_distinguishes_ui_absence_presence_and_values() { + use openshell_core::proto::{UiClipboardAccess, UiPolicy}; + + let absent = ProtoSandboxPolicy::default(); + let explicit_deny = ProtoSandboxPolicy { + ui: Some(UiPolicy::default()), + ..Default::default() + }; + let clipboard_read = ProtoSandboxPolicy { + ui: Some(UiPolicy { + clipboard: UiClipboardAccess::Read as i32, + ..Default::default() + }), + ..Default::default() + }; + + assert_ne!( + deterministic_policy_hash(&absent), + deterministic_policy_hash(&explicit_deny), + "an explicitly present deny-only UI block remains hash-significant" + ); + assert_ne!( + deterministic_policy_hash(&explicit_deny), + deterministic_policy_hash(&clipboard_read), + "UI capability changes must produce a new policy hash" + ); + } + #[test] fn policy_hash_is_stable_across_middleware_config_field_insertion_order() { use prost_types::{Struct, Value, value::Kind}; diff --git a/crates/openshell-server/src/grpc/validation.rs b/crates/openshell-server/src/grpc/validation.rs index 8a81e2648c..53ea3dbc19 100644 --- a/crates/openshell-server/src/grpc/validation.rs +++ b/crates/openshell-server/src/grpc/validation.rs @@ -1015,7 +1015,7 @@ pub(super) fn validate_no_reserved_provider_policy_keys( Ok(()) } -/// Validate that static policy fields (filesystem, landlock, process) haven't changed +/// Validate that static policy fields (filesystem, landlock, process, UI) haven't changed /// from the baseline (version 1) policy. pub(super) fn validate_static_fields_unchanged( baseline: &ProtoSandboxPolicy, @@ -1039,6 +1039,11 @@ pub(super) fn validate_static_fields_unchanged( "process policy cannot be changed on a live sandbox (applied at startup)", )); } + if baseline.ui != new.ui { + return Err(Status::invalid_argument( + "UI policy cannot be changed on a live sandbox (applied at startup)", + )); + } Ok(()) } @@ -2344,6 +2349,33 @@ mod tests { assert!(result.unwrap_err().message().contains("include_workdir")); } + #[test] + fn validate_static_fields_rejects_ui_presence_or_value_change() { + use openshell_core::proto::{UiClipboardAccess, UiPolicy}; + + let absent = ProtoSandboxPolicy::default(); + let deny = ProtoSandboxPolicy { + ui: Some(UiPolicy::default()), + ..Default::default() + }; + let allow_clipboard = ProtoSandboxPolicy { + ui: Some(UiPolicy { + clipboard: UiClipboardAccess::Read as i32, + ..Default::default() + }), + ..Default::default() + }; + + let presence_error = validate_static_fields_unchanged(&absent, &deny) + .expect_err("adding explicit UI policy must be static"); + assert!(presence_error.message().contains("UI policy")); + + let value_error = validate_static_fields_unchanged(&deny, &allow_clipboard) + .expect_err("changing UI policy must be static"); + assert!(value_error.message().contains("UI policy")); + assert!(validate_static_fields_unchanged(&deny, &deny).is_ok()); + } + // ---- Exec validation ---- #[test] diff --git a/crates/openshell-server/src/test_support.rs b/crates/openshell-server/src/test_support.rs index aa5d86b798..581efc2c85 100644 --- a/crates/openshell-server/src/test_support.rs +++ b/crates/openshell-server/src/test_support.rs @@ -159,6 +159,7 @@ impl FakeComputeDriver { resource_capabilities: None, rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, + supports_ui_policy: false, }, gateway_listener_requirements: Vec::new(), gateway_listener_requirements_supported: true, diff --git a/crates/openshell-supervisor-network/src/opa.rs b/crates/openshell-supervisor-network/src/opa.rs index 34fc81a6e2..e1fd1caa39 100644 --- a/crates/openshell-supervisor-network/src/opa.rs +++ b/crates/openshell-supervisor-network/src/opa.rs @@ -2742,6 +2742,7 @@ mod tests { run_as_user: "sandbox".to_string(), run_as_group: "sandbox".to_string(), }), + ui: None, network_policies, network_middlewares: std::collections::HashMap::default(), } @@ -3847,6 +3848,7 @@ process: }), network_policies, network_middlewares: std::collections::HashMap::default(), + ui: None, }; let engine = OpaEngine::from_proto_with_pid_and_binary_identity_required(&proto, 0, false) .expect("engine from relaxed proto"); @@ -4385,6 +4387,7 @@ network_policies: }), network_policies, network_middlewares: std::collections::HashMap::default(), + ui: None, }; let engine = OpaEngine::from_proto(&proto).expect("engine from proto"); @@ -4456,6 +4459,7 @@ network_policies: }), network_policies, network_middlewares: std::collections::HashMap::default(), + ui: None, }; let engine = OpaEngine::from_proto(&proto).expect("engine from proto"); @@ -4532,6 +4536,7 @@ network_policies: }), network_policies, network_middlewares: std::collections::HashMap::default(), + ui: None, }; let engine = OpaEngine::from_proto(&proto).expect("engine from proto"); @@ -6017,6 +6022,7 @@ network_policies: }), network_policies, network_middlewares: std::collections::HashMap::default(), + ui: None, }; let engine = OpaEngine::from_proto(&proto).expect("engine from proto"); @@ -6074,6 +6080,7 @@ network_policies: }), network_policies, network_middlewares: std::collections::HashMap::default(), + ui: None, }; let engine = OpaEngine::from_proto(&proto).expect("engine from proto"); @@ -6132,6 +6139,7 @@ network_policies: }), network_policies, network_middlewares: std::collections::HashMap::default(), + ui: None, }; let engine = OpaEngine::from_proto(&proto).expect("engine from proto"); @@ -6192,6 +6200,7 @@ network_policies: }), network_policies, network_middlewares: std::collections::HashMap::default(), + ui: None, }; let engine = OpaEngine::from_proto(&proto).expect("engine from proto"); @@ -6251,6 +6260,7 @@ network_policies: }), network_policies, network_middlewares: std::collections::HashMap::default(), + ui: None, }; let engine = OpaEngine::from_proto(&proto).expect("engine from proto"); @@ -7643,6 +7653,7 @@ network_policies: }), network_policies, network_middlewares: std::collections::HashMap::default(), + ui: None, }; let engine = OpaEngine::from_proto(&proto).expect("engine from proto"); let input = NetworkInput { @@ -7713,6 +7724,7 @@ network_policies: }), network_policies, network_middlewares: std::collections::HashMap::default(), + ui: None, }; let engine = OpaEngine::from_proto(&proto).expect("Failed to create engine from proto"); @@ -7943,6 +7955,7 @@ network_policies: }), network_policies, network_middlewares: std::collections::HashMap::default(), + ui: None, }; let engine = OpaEngine::from_proto(&proto).unwrap(); // Port 443 @@ -8911,6 +8924,7 @@ network_policies: process: None, network_policies, network_middlewares: std::collections::HashMap::default(), + ui: None, }; let pid = std::process::id(); // accessible root, leaf paths absent @@ -9547,6 +9561,7 @@ network_policies: }), network_policies, network_middlewares: std::collections::HashMap::default(), + ui: None, }; // Build engine with our PID (symlink resolution will work via /proc/self/root/) @@ -9622,6 +9637,7 @@ network_policies: }), network_policies, network_middlewares: std::collections::HashMap::default(), + ui: None, }; // Initial load at pid=0 — no symlink expansion diff --git a/docs/reference/policy-schema.mdx b/docs/reference/policy-schema.mdx index a721718e2e..a3ef3bf7a3 100644 --- a/docs/reference/policy-schema.mdx +++ b/docs/reference/policy-schema.mdx @@ -19,6 +19,7 @@ version: 1 filesystem_policy: { ... } landlock: { ... } process: { ... } +ui: { ... } network_policies: { ... } network_middlewares: { ... } ``` @@ -29,6 +30,7 @@ network_middlewares: { ... } | `filesystem_policy` | object | No | Static | Controls which directories the agent can read and write. | | `landlock` | object | No | Static | Configures Landlock LSM enforcement behavior. | | `process` | object | No | Static | Sets the user and group the agent process runs as. | +| `ui` | object | No | Static | Controls graphical UI, directional clipboard access, and synthetic input on compute drivers that advertise complete enforcement. | | `network_policies` | map | No | Dynamic | Declares which binaries can reach which network endpoints. | | `network_middlewares` | map | No | Dynamic | Attaches ordered middleware by destination host; each implementation's manifest selects its supported HTTP and WebSocket operations. | @@ -168,6 +170,37 @@ process: run_as_group: "1500" ``` +## UI + +**Category:** Static + +Declares platform-neutral UI capabilities. Within an explicit section, every +field is deny by default, so `ui: {}` grants nothing on a supporting backend. +Omit the entire section when the workload does not need a UI surface. + +| Field | Type | Required | Values | Description | +|---|---|---|---|---| +| `allow_graphical_ui` | bool | No | `true`, `false` | Allows the workload to display graphical windows. Defaults to `false`. | +| `clipboard` | string | No | `none`, `read`, `write`, `all` | Controls host clipboard direction from the sandbox's perspective. Defaults to `none`. | +| `allow_input_injection` | bool | No | `true`, `false` | Allows synthetic keyboard or pointer input. Defaults to `false`. | + +MXC `process_container` enforces this section on Windows and advertises complete +support through the compute-driver capability contract. OpenShell rejects any +explicit UI section, including `{}`, with MXC `isolation_session` because +current MXC rejects the top-level `ui` object there. Docker, Podman, Kubernetes, +VM, and older or partial extension drivers also advertise no support, so the +gateway rejects explicit UI policy before validation or provisioning. Omitting +the section preserves each runtime's existing behavior. + +Example: + +```yaml showLineNumbers={false} +ui: + allow_graphical_ui: true + clipboard: read + allow_input_injection: false +``` + ## Network Policies **Category:** Dynamic diff --git a/docs/sandboxes/policies.mdx b/docs/sandboxes/policies.mdx index f4202319ae..a4cb2a1830 100644 --- a/docs/sandboxes/policies.mdx +++ b/docs/sandboxes/policies.mdx @@ -12,7 +12,7 @@ Use this page to apply and iterate policy changes on running sandboxes. For a fu ## Policy Structure -A policy has static sections `filesystem_policy`, `landlock`, and `process` that are locked at sandbox creation, and dynamic `network_policies` and `network_middlewares` sections that are hot-reloadable on a running sandbox. +A policy has static sections `filesystem_policy`, `landlock`, `process`, and `ui` that are locked at sandbox creation, and dynamic `network_policies` and `network_middlewares` sections that are hot-reloadable on a running sandbox. ```yaml wordWrap showLineNumbers={false} version: 1 @@ -32,6 +32,13 @@ landlock: # run_as_user: "1500" # run_as_group: "1500" +# Static, optional: portable UI capabilities. Within this explicit section, +# omitted values deny access. Only drivers advertising complete support accept it. +# ui: +# allow_graphical_ui: true +# clipboard: read +# allow_input_injection: false + # Dynamic: hot-reloadable. Named blocks of endpoints + binaries allowed to reach them. network_policies: my_api: @@ -69,6 +76,7 @@ When a hot reload changes rules, the supervisor publishes a new policy generatio | `filesystem_policy` | Static | Controls which directories the agent can access on disk. Paths are split into `read_only` and `read_write` lists. Any path not listed in either list is inaccessible. Set `include_workdir: true` to automatically add the agent's working directory to `read_write`. [Landlock LSM](https://docs.kernel.org/security/landlock.html) enforces these restrictions at the kernel level. | | `landlock` | Static | Configures Landlock LSM enforcement behavior. Set `compatibility` to `best_effort` (skip individual inaccessible paths while applying remaining rules) or `hard_requirement` (fail if any path is inaccessible or the required kernel ABI is unavailable). Refer to the [Policy Schema Reference](/reference/policy-schema#landlock) for the full behavior table. | | `process` | Static | Optionally overrides the OS-level identity for the agent process. Explicit values must be `sandbox` or numeric UID/GID values from `1` through `4294967294`; root and the invalid identity sentinel are rejected. Docker and Podman may use named identities through per-field OCI `USER` fallback; Kubernetes uses its platform-selected numeric identity. The agent also runs with seccomp filters that block dangerous system calls. | +| `ui` | Static | Controls portable graphical UI, directional clipboard, and synthetic-input capabilities. Within an explicit section, every omitted value denies. MXC `process_container` advertises complete enforcement. MXC `isolation_session`, Docker, Podman, Kubernetes, VM, and partial extension drivers reject any explicit section before provisioning. Omission preserves existing runtime behavior. | | `network_policies` | Dynamic | Controls outbound traffic from the sandbox, including native model-provider endpoints. Each block has a name, a list of endpoints (host, port, protocol, and optional rules), and a list of binaries allowed to use those endpoints.
Every outbound connection passes through the network supervisor, which queries the [policy engine](/about/how-it-works#core-components) with the destination and calling binary. A connection is allowed only when both match an entry in the same policy block. Attached provider profiles can contribute endpoint and binary entries to the effective policy.
For endpoints with `protocol: rest`, the proxy auto-detects TLS and terminates it so each HTTP request can be checked against that endpoint's `rules` (method and path). For endpoints with `protocol: websocket`, the proxy validates the RFC 6455 upgrade and evaluates `GET` rules for the handshake plus either `WEBSOCKET_TEXT` rules for raw client text messages or GraphQL operation rules for GraphQL-over-WebSocket messages. Set `websocket_credential_rewrite: true` only when a WebSocket or REST compatibility endpoint must keep placeholder credentials in sandbox-owned text frames and resolve them at the OpenShell relay boundary.
Endpoints with `protocol: tcp` allow ordinary DNS resolution and native TCP connections without inspecting payloads. Endpoints without `protocol` retain L4 passthrough through an explicit proxy.
If no endpoint matches, the connection is denied. | | `network_middlewares` | Dynamic | Declares keyed HTTP and WebSocket middleware configs. After network and L7 policy admit a request or upgrade, OpenShell matches each config's host selectors independently and runs matching entries by their unique ascending `order` before credential injection. WebSocket-capable entries continue on complete client text messages. | diff --git a/proto/compute_driver.proto b/proto/compute_driver.proto index 7147c7a6f6..17fb91412d 100644 --- a/proto/compute_driver.proto +++ b/proto/compute_driver.proto @@ -101,6 +101,10 @@ message GetCapabilitiesResponse { // Maximum rootfs tar file size in bytes accepted by the driver. Zero means // the driver does not support rootfs tar sources. uint64 rootfs_tar_max_bytes = 11; + // Whether this configured driver instance completely enforces the current + // portable SandboxPolicy.ui contract. Partial support must report false so + // the gateway rejects every explicit UI section before provisioning. + bool supports_ui_policy = 12; } message AuthenticateSandboxRequest { diff --git a/proto/openshell.proto b/proto/openshell.proto index a349e1db8a..3492f70c38 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -875,6 +875,10 @@ message ComputeDriverCapabilities { // Static portable resource request forms reported by the driver. ResourceCapabilities resource_capabilities = 3; + + // Whether the configured driver instance completely enforces the portable + // SandboxPolicy.ui contract. + bool supports_ui_policy = 4; } // Static portable resource request forms reported by a compute driver. diff --git a/proto/sandbox.proto b/proto/sandbox.proto index 600f4a750b..6e3cc4dab0 100644 --- a/proto/sandbox.proto +++ b/proto/sandbox.proto @@ -32,6 +32,10 @@ message SandboxPolicy { // policy-local names. At most 10 configs are accepted, and at most 10 stages // can be selected per request. map network_middlewares = 6; + // Static, platform-neutral user-interface access policy. Within an explicit + // section, omitted capabilities deny. Omitting the section preserves the + // compute platform's existing behavior. + UiPolicy ui = 7; } // Filesystem access policy. @@ -58,6 +62,32 @@ message ProcessPolicy { string run_as_group = 2; } +// Directional clipboard access for a sandboxed workload. +enum UiClipboardAccess { + // Unspecified resolves to no clipboard access. + UI_CLIPBOARD_ACCESS_UNSPECIFIED = 0; + // No clipboard reads or writes. + UI_CLIPBOARD_ACCESS_NONE = 1; + // The sandbox may read host clipboard contents. + UI_CLIPBOARD_ACCESS_READ = 2; + // The sandbox may write host clipboard contents. + UI_CLIPBOARD_ACCESS_WRITE = 3; + // The sandbox may read and write host clipboard contents. + UI_CLIPBOARD_ACCESS_ALL = 4; +} + +// Platform-neutral user-interface capabilities. Every omitted field in an +// explicit policy defaults to deny. Compute platforms without complete support +// reject the entire explicit policy before provisioning. +message UiPolicy { + // Allow the sandbox to display graphical windows. + bool allow_graphical_ui = 1; + // Directional host clipboard access. + UiClipboardAccess clipboard = 2; + // Allow the sandbox to synthesize keyboard or pointer input. + bool allow_input_injection = 3; +} + // A named network access policy rule. message NetworkPolicyRule { // Human-readable name for this policy rule. diff --git a/sdk/go/openshell/v1/internal/converter/coverage_test.go b/sdk/go/openshell/v1/internal/converter/coverage_test.go index 77d8f72ea6..a59f20ffd4 100644 --- a/sdk/go/openshell/v1/internal/converter/coverage_test.go +++ b/sdk/go/openshell/v1/internal/converter/coverage_test.go @@ -162,6 +162,7 @@ func TestConverterCoversAllProtoFields_SandboxPolicy(t *testing.T) { "network_policies": true, "process": true, "landlock": true, + "ui": true, "network_middlewares": true, } diff --git a/sdk/go/openshell/v1/internal/converter/health.go b/sdk/go/openshell/v1/internal/converter/health.go index 63ab8c3298..f2f873ae8e 100644 --- a/sdk/go/openshell/v1/internal/converter/health.go +++ b/sdk/go/openshell/v1/internal/converter/health.go @@ -48,6 +48,7 @@ func ComputeDriverInfoFromProto(d *pb.ComputeDriverInfo) types.ComputeDriverInfo if caps := d.GetCapabilities(); caps != nil { result.DriverName = caps.GetDriverName() result.DriverVersion = caps.GetDriverVersion() + result.SupportsUIPolicy = caps.GetSupportsUiPolicy() } return result } diff --git a/sdk/go/openshell/v1/internal/converter/health_test.go b/sdk/go/openshell/v1/internal/converter/health_test.go index d0360a9b66..0a9cfb6a3f 100644 --- a/sdk/go/openshell/v1/internal/converter/health_test.go +++ b/sdk/go/openshell/v1/internal/converter/health_test.go @@ -20,8 +20,9 @@ func TestGatewayInfoFromProto(t *testing.T) { { Name: "k8s", Capabilities: &pb.ComputeDriverCapabilities{ - DriverName: "kubernetes", - DriverVersion: "2.1.0", + DriverName: "kubernetes", + DriverVersion: "2.1.0", + SupportsUiPolicy: true, }, }, { @@ -43,8 +44,10 @@ func TestGatewayInfoFromProto(t *testing.T) { assert.Equal(t, "k8s", info.ComputeDrivers[0].Name) assert.Equal(t, "kubernetes", info.ComputeDrivers[0].DriverName) assert.Equal(t, "2.1.0", info.ComputeDrivers[0].DriverVersion) + assert.True(t, info.ComputeDrivers[0].SupportsUIPolicy) assert.Equal(t, "docker", info.ComputeDrivers[1].Name) assert.Equal(t, "docker-engine", info.ComputeDrivers[1].DriverName) + assert.False(t, info.ComputeDrivers[1].SupportsUIPolicy) } func TestGatewayInfoFromProto_NoDrivers(t *testing.T) { @@ -107,6 +110,7 @@ func TestComputeDriverInfoFromProto_NilCapabilities(t *testing.T) { assert.Equal(t, "bare-metal", info.Name) assert.Empty(t, info.DriverName) assert.Empty(t, info.DriverVersion) + assert.False(t, info.SupportsUIPolicy) } func TestCurrentUserFromProto(t *testing.T) { diff --git a/sdk/go/openshell/v1/internal/converter/policy.go b/sdk/go/openshell/v1/internal/converter/policy.go index 1a473654e9..7be4ecbb86 100644 --- a/sdk/go/openshell/v1/internal/converter/policy.go +++ b/sdk/go/openshell/v1/internal/converter/policy.go @@ -117,6 +117,7 @@ func SandboxPolicyFromProto(p *sbv1.SandboxPolicy) *types.SandboxPolicy { Filesystem: filesystemPolicyFromProto(p.GetFilesystem()), Landlock: landlockPolicyFromProto(p.GetLandlock()), Process: processPolicyFromProto(p.GetProcess()), + UI: uiPolicyFromProto(p.GetUi()), } if np := p.GetNetworkPolicies(); np != nil { result.NetworkPolicies = make(map[string]types.NetworkPolicyRule, len(np)) @@ -148,6 +149,7 @@ func SandboxPolicyToProto(p *types.SandboxPolicy) *sbv1.SandboxPolicy { Filesystem: filesystemPolicyToProto(p.Filesystem), Landlock: landlockPolicyToProto(p.Landlock), Process: processPolicyToProto(p.Process), + Ui: uiPolicyToProto(p.UI), } if p.NetworkPolicies != nil { result.NetworkPolicies = make(map[string]*sbv1.NetworkPolicyRule, len(p.NetworkPolicies)) @@ -184,6 +186,58 @@ func SandboxPolicyToProtoChecked(p *types.SandboxPolicy) (*sbv1.SandboxPolicy, e return result, nil } +func uiPolicyFromProto(p *sbv1.UiPolicy) *types.UIPolicy { + if p == nil { + return nil + } + return &types.UIPolicy{ + AllowGraphicalUI: p.GetAllowGraphicalUi(), + Clipboard: uiClipboardAccessFromProto(p.GetClipboard()), + AllowInputInjection: p.GetAllowInputInjection(), + } +} + +func uiPolicyToProto(p *types.UIPolicy) *sbv1.UiPolicy { + if p == nil { + return nil + } + return &sbv1.UiPolicy{ + AllowGraphicalUi: p.AllowGraphicalUI, + Clipboard: uiClipboardAccessToProto(p.Clipboard), + AllowInputInjection: p.AllowInputInjection, + } +} + +func uiClipboardAccessFromProto(v sbv1.UiClipboardAccess) types.UIClipboardAccess { + switch v { + case sbv1.UiClipboardAccess_UI_CLIPBOARD_ACCESS_NONE: + return types.UIClipboardAccessNone + case sbv1.UiClipboardAccess_UI_CLIPBOARD_ACCESS_READ: + return types.UIClipboardAccessRead + case sbv1.UiClipboardAccess_UI_CLIPBOARD_ACCESS_WRITE: + return types.UIClipboardAccessWrite + case sbv1.UiClipboardAccess_UI_CLIPBOARD_ACCESS_ALL: + return types.UIClipboardAccessAll + default: + return types.UIClipboardAccessUnspecified + } +} + +func uiClipboardAccessToProto(v types.UIClipboardAccess) sbv1.UiClipboardAccess { + switch v { + case types.UIClipboardAccessNone: + return sbv1.UiClipboardAccess_UI_CLIPBOARD_ACCESS_NONE + case types.UIClipboardAccessRead: + return sbv1.UiClipboardAccess_UI_CLIPBOARD_ACCESS_READ + case types.UIClipboardAccessWrite: + return sbv1.UiClipboardAccess_UI_CLIPBOARD_ACCESS_WRITE + case types.UIClipboardAccessAll: + return sbv1.UiClipboardAccess_UI_CLIPBOARD_ACCESS_ALL + default: + return sbv1.UiClipboardAccess_UI_CLIPBOARD_ACCESS_UNSPECIFIED + } +} + func middlewareConfigFromProto(m *sbv1.NetworkMiddlewareConfig) types.NetworkMiddlewareConfig { result := types.NetworkMiddlewareConfig{ Name: m.GetName(), diff --git a/sdk/go/openshell/v1/internal/converter/policy_test.go b/sdk/go/openshell/v1/internal/converter/policy_test.go index dde2205773..c6cf740d7d 100644 --- a/sdk/go/openshell/v1/internal/converter/policy_test.go +++ b/sdk/go/openshell/v1/internal/converter/policy_test.go @@ -200,6 +200,11 @@ func TestSandboxPolicyRoundTrip(t *testing.T) { RunAsUser: "sandbox-user", RunAsGroup: "sandbox-group", }, + UI: &v1.UIPolicy{ + AllowGraphicalUI: true, + Clipboard: v1.UIClipboardAccessRead, + AllowInputInjection: true, + }, NetworkPolicies: map[string]v1.NetworkPolicyRule{ "web-api": { Name: "web-api", @@ -239,6 +244,10 @@ func TestSandboxPolicyRoundTrip(t *testing.T) { assert.Equal(t, original.Process.RunAsUser, roundTrip.Process.RunAsUser) assert.Equal(t, original.Process.RunAsGroup, roundTrip.Process.RunAsGroup) + // UI + require.NotNil(t, roundTrip.UI) + assert.Equal(t, original.UI, roundTrip.UI) + // NetworkPolicies require.Len(t, roundTrip.NetworkPolicies, 2) webAPI, ok := roundTrip.NetworkPolicies["web-api"] @@ -315,6 +324,39 @@ func TestSandboxPolicyPartialSubPolicies(t *testing.T) { assert.Nil(t, roundTrip.NetworkPolicies) }) + t.Run("only UI", func(t *testing.T) { + for _, clipboard := range []v1.UIClipboardAccess{ + v1.UIClipboardAccessUnspecified, + v1.UIClipboardAccessNone, + v1.UIClipboardAccessRead, + v1.UIClipboardAccessWrite, + v1.UIClipboardAccessAll, + } { + original := &v1.SandboxPolicy{ + UI: &v1.UIPolicy{ + AllowGraphicalUI: true, + Clipboard: clipboard, + AllowInputInjection: true, + }, + } + roundTrip := SandboxPolicyFromProto(SandboxPolicyToProto(original)) + require.NotNil(t, roundTrip) + assert.Equal(t, original.UI, roundTrip.UI) + } + }) + + t.Run("explicit empty UI remains present", func(t *testing.T) { + original := &v1.SandboxPolicy{UI: &v1.UIPolicy{}} + protoPolicy := SandboxPolicyToProto(original) + require.NotNil(t, protoPolicy) + require.NotNil(t, protoPolicy.Ui) + + roundTrip := SandboxPolicyFromProto(protoPolicy) + require.NotNil(t, roundTrip) + require.NotNil(t, roundTrip.UI) + assert.Equal(t, &v1.UIPolicy{}, roundTrip.UI) + }) + t.Run("only landlock", func(t *testing.T) { original := &v1.SandboxPolicy{ Version: 2, diff --git a/sdk/go/openshell/v1/types/health.go b/sdk/go/openshell/v1/types/health.go index 1db3ec3872..14a4e2544a 100644 --- a/sdk/go/openshell/v1/types/health.go +++ b/sdk/go/openshell/v1/types/health.go @@ -29,9 +29,10 @@ type GatewayInfo struct { // ComputeDriverInfo describes a compute backend available on the gateway. type ComputeDriverInfo struct { - Name string - DriverName string - DriverVersion string + Name string + DriverName string + DriverVersion string + SupportsUIPolicy bool } // CurrentUser holds the authenticated caller's identity. diff --git a/sdk/go/openshell/v1/types/policy.go b/sdk/go/openshell/v1/types/policy.go index 8fb0712b60..ab01014522 100644 --- a/sdk/go/openshell/v1/types/policy.go +++ b/sdk/go/openshell/v1/types/policy.go @@ -112,7 +112,7 @@ type DraftPolicy struct { // SandboxPolicy is the top-level security policy configuration for a sandbox. // It contains filesystem access rules, Landlock LSM configuration, process -// identity rules, and named network access policies. +// identity rules, portable UI capabilities, and named network access policies. type SandboxPolicy struct { // Version is the policy version number. The server may override this on write. Version uint32 @@ -125,6 +125,9 @@ type SandboxPolicy struct { // Process controls the user and group identity for sandboxed processes. // Nil means no process policy is specified. Process *ProcessPolicy + // UI controls portable graphical UI, clipboard, and input-injection capabilities. + // Nil means no UI policy is specified. + UI *UIPolicy // NetworkPolicies contains named network access rules. // Nil means no network policies are specified; an empty map is distinct from nil. NetworkPolicies map[string]NetworkPolicyRule @@ -178,6 +181,33 @@ type ProcessPolicy struct { RunAsGroup string } +// UIClipboardAccess controls host clipboard direction from the sandbox's perspective. +type UIClipboardAccess int + +const ( + // UIClipboardAccessUnspecified resolves to no clipboard access. + UIClipboardAccessUnspecified UIClipboardAccess = iota + // UIClipboardAccessNone denies clipboard reads and writes. + UIClipboardAccessNone + // UIClipboardAccessRead permits reading host clipboard contents. + UIClipboardAccessRead + // UIClipboardAccessWrite permits writing host clipboard contents. + UIClipboardAccessWrite + // UIClipboardAccessAll permits reading and writing host clipboard contents. + UIClipboardAccessAll +) + +// UIPolicy declares platform-neutral user-interface capabilities. +// Every zero value denies access. +type UIPolicy struct { + // AllowGraphicalUI permits the workload to display graphical windows. + AllowGraphicalUI bool + // Clipboard controls host clipboard direction. + Clipboard UIClipboardAccess + // AllowInputInjection permits synthetic keyboard or pointer input. + AllowInputInjection bool +} + // SandboxPolicyRevision represents a versioned policy revision for a sandbox. type SandboxPolicyRevision struct { // Version is the policy version (monotonically increasing per sandbox). diff --git a/sdk/go/proto/openshellv1/openshell.pb.go b/sdk/go/proto/openshellv1/openshell.pb.go index 935ceaac47..5c0a00dbdb 100644 --- a/sdk/go/proto/openshellv1/openshell.pb.go +++ b/sdk/go/proto/openshellv1/openshell.pb.go @@ -1269,8 +1269,11 @@ type ComputeDriverCapabilities struct { DriverVersion string `protobuf:"bytes,2,opt,name=driver_version,json=driverVersion,proto3" json:"driver_version,omitempty"` // Static portable resource request forms reported by the driver. ResourceCapabilities *ResourceCapabilities `protobuf:"bytes,3,opt,name=resource_capabilities,json=resourceCapabilities,proto3" json:"resource_capabilities,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Whether the configured driver instance completely enforces the portable + // SandboxPolicy.ui contract. + SupportsUiPolicy bool `protobuf:"varint,4,opt,name=supports_ui_policy,json=supportsUiPolicy,proto3" json:"supports_ui_policy,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ComputeDriverCapabilities) Reset() { @@ -1324,6 +1327,13 @@ func (x *ComputeDriverCapabilities) GetResourceCapabilities() *ResourceCapabilit return nil } +func (x *ComputeDriverCapabilities) GetSupportsUiPolicy() bool { + if x != nil { + return x.SupportsUiPolicy + } + return false +} + // Static portable resource request forms reported by a compute driver. // An omitted domain means the driver does not report that domain. type ResourceCapabilities struct { @@ -14916,12 +14926,13 @@ const file_openshell_proto_rawDesc = "" + "\x0fcompute_drivers\x18\x03 \x03(\v2\x1f.openshell.v1.ComputeDriverInfoR\x0ecomputeDrivers\"t\n" + "\x11ComputeDriverInfo\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12K\n" + - "\fcapabilities\x18\x02 \x01(\v2'.openshell.v1.ComputeDriverCapabilitiesR\fcapabilities\"\xbc\x01\n" + + "\fcapabilities\x18\x02 \x01(\v2'.openshell.v1.ComputeDriverCapabilitiesR\fcapabilities\"\xea\x01\n" + "\x19ComputeDriverCapabilities\x12\x1f\n" + "\vdriver_name\x18\x01 \x01(\tR\n" + "driverName\x12%\n" + "\x0edriver_version\x18\x02 \x01(\tR\rdriverVersion\x12W\n" + - "\x15resource_capabilities\x18\x03 \x01(\v2\".openshell.v1.ResourceCapabilitiesR\x14resourceCapabilities\"\xca\x01\n" + + "\x15resource_capabilities\x18\x03 \x01(\v2\".openshell.v1.ResourceCapabilitiesR\x14resourceCapabilities\x12,\n" + + "\x12supports_ui_policy\x18\x04 \x01(\bR\x10supportsUiPolicy\"\xca\x01\n" + "\x14ResourceCapabilities\x127\n" + "\x03cpu\x18\x01 \x01(\v2%.openshell.v1.CpuResourceCapabilitiesR\x03cpu\x12@\n" + "\x06memory\x18\x02 \x01(\v2(.openshell.v1.MemoryResourceCapabilitiesR\x06memory\x127\n" + diff --git a/skills/generate-sandbox-policy/SKILL.md b/skills/generate-sandbox-policy/SKILL.md index 73c0863df7..2bc77b446e 100644 --- a/skills/generate-sandbox-policy/SKILL.md +++ b/skills/generate-sandbox-policy/SKILL.md @@ -11,7 +11,7 @@ Generate YAML sandbox network policies and network middleware configuration from This skill translates a user's plain-language policy intent into a valid sandbox policy. The amount of detail the user provides determines the granularity of the generated policy — from broad L4 or preset-based policies (just a host:port) up to fine-grained per-endpoint L7 rules (full API docs). -The output is a `network_policies` YAML block, an optional `network_middlewares` block, and optionally a full policy file that conforms to the sandbox policy schema. +The output is a `network_policies` YAML block, an optional `network_middlewares` block, an optional static `ui` block when explicitly requested, and optionally a full policy file that conforms to the sandbox policy schema. ## Step 1: Gather Inputs @@ -168,6 +168,7 @@ Key sections to reference: - **Private IP Access via `allowed_ips`** — CIDR allowlist for private IP space - **Network Middleware** - top-level middleware configs, ordering, host selection, and failure behavior - **Validation Rules** — what combinations are valid/invalid +- **UI** — static, portable capabilities and runtime support boundaries When middleware is requested, also read the published [supervisor middleware guide](https://docs.nvidia.com/openshell/latest/extensibility/supervisor-middleware.md). @@ -257,6 +258,28 @@ Use the most specific pattern that covers the intent. Prefer narrow globs over ` ## Step 5: Generate the Policy +### UI Policy + +Emit `ui` only when the user explicitly requests a graphical surface, +clipboard access, or synthetic input. Choose the narrowest capability and keep +unrequested fields omitted so they remain deny by default: + +```yaml +ui: + allow_graphical_ui: true + clipboard: read # none | read | write | all + allow_input_injection: false +``` + +Treat clipboard direction from the sandbox's perspective. Warn that UI is a +static sandbox-creation control. It is currently enforceable only by the MXC +driver's OpenShell `process_container` backend, which emits MXC's +`processcontainer` containment value and advertises complete support. MXC +`isolation_session` and non-Windows drivers advertise no support, so the gateway +rejects any explicit UI section, including `{}`, before provisioning. Omit the +section rather than emitting deny-only UI for those drivers; omission preserves +their existing behavior. + ### Output Format Generate a complete `network_policies` entry. Use this template: @@ -381,6 +404,7 @@ Before presenting the policy to the user, verify correctness **and** flag breadt - [ ] No fail-closed middleware selector can cover a `tls: skip` endpoint - [ ] Any required WebSocket control advertises `WEBSOCKET_MESSAGE/PRE_CREDENTIALS`, and the user understands that V1 does not inspect binary messages - [ ] Endpoints contributed by a credentialed provider are not L4-only or `tls: skip` unless `allow_uninspected_credentials: true` explicitly records the exception +- [ ] An explicit `ui` section targets a configured driver/backend that advertises complete UI-policy support ### Schema Warnings (log-only, but should be fixed) @@ -443,7 +467,7 @@ The policy needs to go somewhere. Determine which mode applies: 1. **Read the existing file** to understand current state: - What policies already exist under `network_policies` - - What the `filesystem_policy`, `landlock`, and `process` sections look like + - What the `filesystem_policy`, `landlock`, `process`, and `ui` sections look like - Whether the file uses compact (`{ host: ..., port: ... }`) or expanded YAML style 2. **Check for conflicts**: @@ -462,7 +486,7 @@ The policy needs to go somewhere. Determine which mode applies: - **Modifying an existing policy**: Edit the specific policy in place — add/remove endpoints, change access presets, update rules, add binaries, etc. A rule authorizes every binary it lists to reach every endpoint and port it lists, so adding one binary grants it all of that rule's endpoints, and adding one endpoint grants it to all of that rule's binaries. State the resulting pairs to the user before writing them. When the user wants a binary to reach only part of a rule's endpoints, put that binary and those endpoints in a separate rule instead of extending the existing one. An empty `binaries` list means any binary, so leaving it off widens the rule to every process. - **Removing a policy**: Delete the policy block if the user asks. -4. **Preserve everything else**: Do not modify `filesystem_policy`, `landlock`, `process`, or other policies unless the user explicitly asks. +4. **Preserve everything else**: Do not modify `filesystem_policy`, `landlock`, `process`, `ui`, or other policies unless the user explicitly asks. ### Mode B: Create a New Policy File diff --git a/skills/generate-sandbox-policy/examples.md b/skills/generate-sandbox-policy/examples.md index 2cbc21b6b6..a547ea5398 100644 --- a/skills/generate-sandbox-policy/examples.md +++ b/skills/generate-sandbox-policy/examples.md @@ -748,7 +748,7 @@ An exact IP is treated as `/32` — only that specific address is permitted. - { path: /usr/bin/curl } ``` -The agent inserts the new entry after the last existing policy in the `network_policies` block. All other sections (`filesystem_policy`, `landlock`, `process`) are untouched. +The agent inserts the new entry after the last existing policy in the `network_policies` block. All other sections (`filesystem_policy`, `landlock`, `process`, `ui`) are untouched. --- diff --git a/skills/openshell-cli/SKILL.md b/skills/openshell-cli/SKILL.md index 2e4c9c3bdf..f1ce300beb 100644 --- a/skills/openshell-cli/SKILL.md +++ b/skills/openshell-cli/SKILL.md @@ -439,7 +439,7 @@ the operation that removes retained state. This is the most important multi-step workflow. It enables a tight feedback cycle where sandbox policy is refined based on observed activity. -**Key concept**: Policies have static fields (immutable after creation: `filesystem_policy`, `landlock`, `process`) and two dynamic fields: `network_policies` and `network_middlewares`. Both dynamic fields can be updated without recreating the sandbox when the selected compute driver supports live policy updates. Drivers without the standard supervisor fetch revisions through the sandbox configuration API and report whether they loaded them. +**Key concept**: Policies have static fields (immutable after creation: `filesystem_policy`, `landlock`, `process`, `ui`) and two dynamic fields: `network_policies` and `network_middlewares`. Both dynamic fields can be updated without recreating the sandbox when the selected compute driver supports live policy updates. MXC rejects live policy replacement and merge updates; delete and recreate an MXC sandbox instead. UI capabilities are enforced only by a configured driver/backend advertising complete UI-policy support. Today that is the MXC driver's OpenShell `process_container` backend, which emits MXC's `processcontainer` containment value. `isolation_session` and non-Windows drivers reject any explicit UI section before provisioning; omit it to preserve their existing behavior. An endpoint with omitted `protocol` retains explicit-proxy behavior. Explicit `protocol: tcp` requests policy DNS and transparent TCP and currently requires @@ -509,7 +509,7 @@ Edit `current-policy.yaml` to allow the blocked actions. **For policy content au - Binary matching patterns - Ordered `network_middlewares`, host selection, HTTP and WebSocket bindings, and `fail_open` or `fail_closed` behavior -`network_policies` and `network_middlewares` can be modified at runtime when the selected compute driver supports live policy updates. Use `--wait` to verify that the active runtime loaded the revision; do not infer enforcement from the gateway accepting the update. If `filesystem_policy`, `landlock`, or `process` need changes, the sandbox must be recreated. Built-in middleware such as `openshell/regex` needs no gateway registration. An operator-run middleware must already be registered under `[[openshell.supervisor.middleware]]`; changing that static registration requires a gateway restart. +`network_policies` and `network_middlewares` can be modified at runtime when the selected compute driver supports live policy updates. Use `--wait` to verify that the active runtime loaded the revision; do not infer enforcement from the gateway accepting the update. MXC rejects live policy replacement and merge updates; delete and recreate an MXC sandbox instead. If `filesystem_policy`, `landlock`, `process`, or `ui` need changes, the sandbox must be recreated. Built-in middleware such as `openshell/regex` needs no gateway registration. An operator-run middleware must already be registered under `[[openshell.supervisor.middleware]]`; changing that static registration requires a gateway restart. Middleware can inspect parsed HTTP request bodies and complete client-to-upstream WebSocket text messages over both `ws://` and `wss://` when the implementation advertises the matching binding. The built-in `openshell/regex` advertises both bindings and applies its fixed patterns to UTF-8 text. A host-matched HTTP-only attachment can inspect the upgrade GET but does not join the WebSocket chain; look for `binding_not_selected` coverage. Binary messages pass under both `on_error` modes and active stages emit `unsupported_message_type` coverage; upstream-to-client messages remain uninspected. A broken fail-open WebSocket stage is disabled for the rest of that connection; inspect sandbox OCSF logs for `openshell.middleware.websocket_stage_disabled`. diff --git a/tasks/scripts/windows-msvc.ps1 b/tasks/scripts/windows-msvc.ps1 index 51179fb1e0..0ef556fe00 100644 --- a/tasks/scripts/windows-msvc.ps1 +++ b/tasks/scripts/windows-msvc.ps1 @@ -6,7 +6,7 @@ [CmdletBinding()] param( [Parameter(Mandatory = $true, Position = 0)] - [ValidateSet("check", "lint", "build", "test", "test-precommit", "test-unsupported", "artifacts", "ci")] + [ValidateSet("check", "lint", "build", "test", "test-precommit", "test-unsupported", "test-mxc-real", "artifacts", "ci")] [string] $Action, [Parameter(Position = 1)] @@ -542,7 +542,7 @@ function Invoke-UnsupportedContractTests([string] $RustTarget) { foreach ($test in $tests) { Invoke-VsCargo ` -RustTarget $RustTarget ` - -CargoArgs "cargo test -p openshell-gateway --target $RustTarget $test $Z3ServerFeatures" ` + -CargoArgs "cargo test -p openshell-gateway --target $RustTarget $test $Z3GatewayFeatures" ` -LogName "test-$RustTarget-unsupported-$test.log" } @@ -556,6 +556,14 @@ function Invoke-UnsupportedContractTests([string] $RustTarget) { } } +function Invoke-MxcRealTests([string] $RustTarget) { + Assert-NativeTestTarget $RustTarget + Invoke-VsCargo ` + -RustTarget $RustTarget ` + -CargoArgs "cargo test -p openshell-driver-mxc --test wxc_exec_real --target $RustTarget -- --ignored --test-threads=1 --nocapture" ` + -LogName "test-$RustTarget-mxc-real.log" +} + function Get-Sha256([string] $Path) { $stream = [System.IO.File]::OpenRead($Path) try { @@ -600,13 +608,13 @@ if ($Action -eq "ci" -and (Get-HostArch) -ne "amd64") { } $targets = Get-SelectedTargets $Target -if ($Action -in @("test", "test-precommit", "test-unsupported")) { +if ($Action -in @("test", "test-precommit", "test-unsupported", "test-mxc-real")) { foreach ($rustTarget in $targets) { Assert-NativeTestTarget $rustTarget } } -if ($Action -in @("check", "lint", "build", "test", "test-precommit", "test-unsupported", "ci")) { +if ($Action -in @("check", "lint", "build", "test", "test-precommit", "test-unsupported", "test-mxc-real", "ci")) { $z3Features = Configure-Z3 $Z3WorkspaceFeatures = $z3Features.WorkspaceFeatures $Z3ServerFeatures = $z3Features.ServerFeatures @@ -648,6 +656,11 @@ switch ($Action) { Invoke-UnsupportedContractTests $rustTarget } } + "test-mxc-real" { + foreach ($rustTarget in $targets) { + Invoke-MxcRealTests $rustTarget + } + } "artifacts" { Show-Artifacts $targets } diff --git a/tasks/windows.toml b/tasks/windows.toml index 708ba2c248..7509a934c6 100644 --- a/tasks/windows.toml +++ b/tasks/windows.toml @@ -76,9 +76,14 @@ run = "echo 'windows:* tasks require a Windows MSVC host' && exit 1" run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 ci all" ["windows:test:mxc-real:x64"] -description = "Run real-wxc-exec Tier-2 integration tests (skip-safe: tests print SKIP when binary/backend absent)" +description = "Run native x64 real-wxc-exec Tier-2 integration tests (skip-safe: tests print SKIP when binary/backend absent)" run = "echo 'windows:* tasks require a Windows MSVC host' && exit 1" -run_windows = "cargo test -p openshell-driver-mxc --test wxc_exec_real --target x86_64-pc-windows-msvc -- --ignored --test-threads=1" +run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 test-mxc-real x86_64-pc-windows-msvc" + +["windows:test:mxc-real:arm64"] +description = "Run native ARM64 real-wxc-exec Tier-2 integration tests (skip-safe: tests print SKIP when binary/backend absent)" +run = "echo 'windows:* tasks require a Windows MSVC host' && exit 1" +run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 test-mxc-real aarch64-pc-windows-msvc" ["windows:e2e:mxc"] description = "Run MXC Tier-3 e2e scenario runner against real wxc-exec (probe-gated; skip-safe on hosts without the binary)" From 4cf0e4124e4284e7c6be08764e0e8f48c0888a0c Mon Sep 17 00:00:00 2001 From: Shailendra Singh Date: Thu, 10 Sep 2026 18:31:07 -0700 Subject: [PATCH 02/26] fix(policy): address UI policy review feedback Signed-off-by: Shailendra Singh --- architecture/compute-runtimes.md | 5 +- architecture/gateway.md | 8 +- architecture/security-policy.md | 11 +- crates/openshell-driver-mxc/src/policy.rs | 26 +++ .../src/policy_map/map.rs | 27 ++- .../tests/policy_mapper_matrix.rs | 43 ++++- crates/openshell-policy/src/lib.rs | 39 ++++ crates/openshell-server/src/grpc/policy.rs | 182 +++++++++++++++++- .../openshell-server/src/grpc/validation.rs | 11 +- docs/reference/policy-schema.mdx | 19 +- docs/sandboxes/policies.mdx | 4 +- sdk/go/openshell/v1/fake/sandbox.go | 4 + sdk/go/openshell/v1/fake/sandbox_test.go | 13 ++ 13 files changed, 366 insertions(+), 26 deletions(-) diff --git a/architecture/compute-runtimes.md b/architecture/compute-runtimes.md index 8da70004c6..74c4fe94d6 100644 --- a/architecture/compute-runtimes.md +++ b/architecture/compute-runtimes.md @@ -125,7 +125,10 @@ report false. When `ui` is explicitly present, including as `{}`, the gateway rejects create before the driver validation RPC or provisioning unless this capability is true. An absent section bypasses this gate and preserves the runtime's existing behavior. The startup snapshot is also exposed through -gateway info so clients can discover the selected runtime's support. +gateway info so clients can discover the selected runtime's support. UI cannot +be supplied by a gateway-global policy because it is applied at startup. When a +global dynamic policy is active, effective-policy reads retain the UI block from +the sandbox's creation policy. The gateway records driver identity and version from the startup capability response. Elevated gateway info reports that initialized driver snapshot instead diff --git a/architecture/gateway.md b/architecture/gateway.md index 5e04f377fa..54d47a131d 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -660,9 +660,11 @@ leave an ambiguous final dynamic-token state or a deleted custom profile that is still referenced by a sandbox. Policy and runtime settings are delivered together through the effective sandbox -config path. A gateway-global policy can override sandbox-scoped policy. The -sandbox supervisor polls for config revisions and hot-reloads dynamic policy -when the policy engine accepts the update. +config path. A gateway-global policy can override sandbox-scoped dynamic policy. +Startup-only UI remains anchored to each sandbox's creation policy, and global +policy writes containing UI are rejected. The sandbox supervisor polls for +config revisions and hot-reloads dynamic policy when the policy engine accepts +the update. External supervisor middleware registration is operator-owned configuration under `[[openshell.supervisor.middleware]]`. At startup the gateway connects to diff --git a/architecture/security-policy.md b/architecture/security-policy.md index 43714a0778..cc1580b6f6 100644 --- a/architecture/security-policy.md +++ b/architecture/security-policy.md @@ -14,7 +14,9 @@ On native Windows, the MXC driver cannot rely on Linux Landlock or setuid. It maps portable static filesystem and UI controls from `SandboxPolicy` into MXC configuration. UI controls are available only with the MXC `process_container` backend; all omitted UI fields retain deny-by-default -values, and other compute runtimes reject an explicit UI policy. +values, and other compute runtimes reject an explicit UI policy. MXC ignores +clipboard and input-injection fields when graphical UI is disabled, so the +mapper rejects those otherwise-unenforceable combinations. ## Policy Areas @@ -266,8 +268,11 @@ generation, and whether the previous policy is active. Static controls, such as filesystem allowlists and process identity, require a new sandbox because they are applied before the child process starts. -Gateway-global policy can override sandbox-scoped policy. Use it sparingly -because it changes the effective access model for every sandbox on the gateway. +Gateway-global policy can override sandbox-scoped dynamic policy. Use it +sparingly because it changes the effective access model for every sandbox on +the gateway. A global policy cannot contain `ui`; effective-policy reads retain +the per-sandbox UI contract recorded at creation so a global update cannot +misrepresent startup enforcement. ## Policy Advisor diff --git a/crates/openshell-driver-mxc/src/policy.rs b/crates/openshell-driver-mxc/src/policy.rs index 0e387442c6..2a5c9883aa 100644 --- a/crates/openshell-driver-mxc/src/policy.rs +++ b/crates/openshell-driver-mxc/src/policy.rs @@ -352,6 +352,31 @@ mod tests { ); } + #[test] + fn embedded_rejects_ui_grants_suppressed_by_disable() { + use openshell_core::proto::{UiClipboardAccess, UiPolicy}; + + for ui in [ + UiPolicy { + clipboard: UiClipboardAccess::Read as i32, + ..Default::default() + }, + UiPolicy { + allow_input_injection: true, + ..Default::default() + }, + ] { + let policy = SandboxPolicy { + ui: Some(ui), + ..Default::default() + }; + let error = EmbeddedPolicyMapper + .map(Some(&policy), &processcontainer_ctx()) + .expect_err("MXC cannot enforce UI grants while UI is disabled"); + assert!(matches!(error, MapError::Unsupported(_))); + } + } + #[test] fn embedded_split_normalizes_paths_and_returns_proxy_handoff() { use openshell_core::proto::{NetworkBinary, NetworkEndpoint, NetworkPolicyRule}; @@ -411,6 +436,7 @@ mod tests { let ctx = MapCtx { sandbox_id: "sb-egress-middleware".into(), egress: Some("127.0.0.1:18080".parse().unwrap()), + containment: "processcontainer".into(), }; let error = mapper.map(Some(&policy), &ctx).unwrap_err(); diff --git a/crates/openshell-driver-mxc/src/policy_map/map.rs b/crates/openshell-driver-mxc/src/policy_map/map.rs index 1d660cc5c5..08cd3859c7 100644 --- a/crates/openshell-driver-mxc/src/policy_map/map.rs +++ b/crates/openshell-driver-mxc/src/policy_map/map.rs @@ -321,10 +321,31 @@ fn map_ui(ui: Option<&UiPolicy>, containment: &str, items: &mut Vec) - "none" } }; + let graphical_ui_disabled = !ui.allow_graphical_ui; + if graphical_ui_disabled && clipboard != "none" { + add_loss( + items, + "ui.clipboard", + "error", + "MXC ignores clipboard grants when ui.disable is true.", + "directional clipboard access without graphical UI", + "MXC receives clipboard=none and sandbox creation is rejected; set allow_graphical_ui=true to request clipboard access.", + ); + } + if graphical_ui_disabled && ui.allow_input_injection { + add_loss( + items, + "ui.allow_input_injection", + "error", + "MXC ignores input-injection grants when ui.disable is true.", + "input injection without graphical UI", + "MXC receives injection=false and sandbox creation is rejected; set allow_graphical_ui=true to request input injection.", + ); + } Some(json!({ - "disable": !ui.allow_graphical_ui, - "clipboard": clipboard, - "injection": ui.allow_input_injection, + "disable": graphical_ui_disabled, + "clipboard": if graphical_ui_disabled { "none" } else { clipboard }, + "injection": !graphical_ui_disabled && ui.allow_input_injection, })) } "isolation_session" => { diff --git a/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs b/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs index d5322a9ecf..82bfe96428 100644 --- a/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs +++ b/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs @@ -1081,6 +1081,46 @@ fn c_processcontainer_absent_or_empty_ui_is_default_deny() { } } +#[test] +fn b_processcontainer_rejects_clipboard_without_graphical_ui() { + let policy = SandboxPolicy { + ui: Some(UiPolicy { + clipboard: UiClipboardAccess::Read as i32, + ..Default::default() + }), + ..Default::default() + }; + let result = map_to_mxc(&policy, &pc_opts()); + assert_eq!(result.config["ui"]["disable"], true); + assert_eq!(result.config["ui"]["clipboard"], "none"); + assert_single_loss( + &result.loss, + "ui.clipboard", + "error", + "clipboard grant suppressed by ui.disable", + ); +} + +#[test] +fn b_processcontainer_rejects_input_injection_without_graphical_ui() { + let policy = SandboxPolicy { + ui: Some(UiPolicy { + allow_input_injection: true, + ..Default::default() + }), + ..Default::default() + }; + let result = map_to_mxc(&policy, &pc_opts()); + assert_eq!(result.config["ui"]["disable"], true); + assert_eq!(result.config["ui"]["injection"], false); + assert_single_loss( + &result.loss, + "ui.allow_input_injection", + "error", + "input-injection grant suppressed by ui.disable", + ); +} + #[test] fn b_isolation_session_omits_absent_ui_and_rejects_explicit_ui() { let opts = MxcMappingOptions { @@ -1111,13 +1151,14 @@ fn a_split_maps_ui_to_mxc_and_omits_it_from_proxy_policy() { let policy = SandboxPolicy { version: 1, ui: Some(UiPolicy { + allow_graphical_ui: true, clipboard: UiClipboardAccess::Write as i32, ..Default::default() }), ..Default::default() }; let result = split_policy(&policy, &pc_split_opts()).expect("split"); - assert_eq!(result.mxc_config["ui"]["disable"], true); + assert_eq!(result.mxc_config["ui"]["disable"], false); assert_eq!(result.mxc_config["ui"]["clipboard"], "write"); assert_eq!(result.mxc_config["ui"]["injection"], false); assert!(result.proxy_policy.ui.is_none()); diff --git a/crates/openshell-policy/src/lib.rs b/crates/openshell-policy/src/lib.rs index 672cf90280..ed5498925f 100644 --- a/crates/openshell-policy/src/lib.rs +++ b/crates/openshell-policy/src/lib.rs @@ -1849,6 +1849,7 @@ fn validate_and_canonicalize_mcp_policy_schema( .map_err(|violations| PolicyValidationError { violations })?; materialize_default_mcp_versions(&mut policy); canonicalize_mcp_version_allowlists(&mut policy); + canonicalize_ui_defaults(&mut policy); validate_mcp_policy_schema(&policy, McpVersionPresence::RequireMaterialized) .map_err(|violations| PolicyValidationError { violations })?; Ok(policy) @@ -1871,6 +1872,7 @@ pub fn validate_and_canonicalize_sandbox_policy( .map_err(|violations| PolicyValidationError { violations })?; materialize_default_mcp_versions(&mut policy); canonicalize_mcp_version_allowlists(&mut policy); + canonicalize_ui_defaults(&mut policy); debug_assert!( validate_sandbox_policy(&policy).is_ok(), "validated MCP canonicalization must preserve every policy invariant" @@ -1878,6 +1880,20 @@ pub fn validate_and_canonicalize_sandbox_policy( Ok(policy) } +/// Materialize protobuf UI defaults that have a distinct canonical enum value. +/// +/// Proto3 clients commonly leave `clipboard` at `Unspecified(0)`. `OpenShell` +/// defines that value as deny, so canonical policy state stores the equivalent +/// explicit `None(1)`. This keeps protobuf, YAML, hashes, and static-field +/// comparisons stable across a serialize/parse round trip. +fn canonicalize_ui_defaults(policy: &mut SandboxPolicy) { + if let Some(ui) = policy.ui.as_mut() + && ui.clipboard == UiClipboardAccess::Unspecified as i32 + { + ui.clipboard = UiClipboardAccess::None as i32; + } +} + /// Replace absent protobuf MCP options and empty revision lists with the /// single pinned policy default while preserving every explicit MCP option. pub(crate) fn materialize_default_mcp_versions(policy: &mut SandboxPolicy) { @@ -2039,6 +2055,29 @@ network_policies: } } + #[test] + fn ui_unspecified_clipboard_canonicalizes_to_none_across_yaml_round_trip() { + let raw = SandboxPolicy { + ui: Some(UiPolicy { + allow_graphical_ui: true, + clipboard: UiClipboardAccess::Unspecified as i32, + ..Default::default() + }), + ..Default::default() + }; + + let canonical = validate_and_canonicalize_sandbox_policy(raw) + .expect("unspecified clipboard must be a valid deny default"); + assert_eq!( + canonical.ui.as_ref().expect("UI remains present").clipboard, + UiClipboardAccess::None as i32 + ); + + let yaml = serialize_sandbox_policy(&canonical).expect("canonical UI serializes"); + let reparsed = parse_sandbox_policy(&yaml).expect("canonical UI reparses"); + assert_eq!(reparsed, canonical); + } + #[test] fn ui_policy_rejects_unknown_yaml_clipboard_value() { let error = parse_sandbox_policy("version: 1\nui:\n clipboard: execute\n") diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index feabde75d4..2707eef005 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -1716,6 +1716,20 @@ async fn auto_approve_chunk( Ok(()) } +/// Preserve UI as the sandbox's startup contract when applying a global policy. +/// +/// UI controls are enforced by the compute runtime before the workload starts, +/// so a later global override cannot safely add, remove, or change them. Global +/// policy writes reject their own UI section; this overlay also prevents a +/// global dynamic policy from hiding the UI state that the runtime enforced. +fn preserve_sandbox_startup_ui(policy: &mut ProtoSandboxPolicy, sandbox: &Sandbox) { + policy.ui = sandbox + .spec + .as_ref() + .and_then(|spec| spec.policy.as_ref()) + .and_then(|policy| policy.ui); +} + // TODO: share effective-policy lookup with `load_sandbox_policy` / // `GetSandboxConfig`. They re-implement very similar global-settings and // profile-composition logic; consolidating them is out of scope for the @@ -1733,10 +1747,10 @@ async fn current_effective_policy_for_sandbox( .map(|spec| spec.providers.clone()) .unwrap_or_default(); let global_settings = load_global_settings(state.store.as_ref()).await?; - if let Some(global_policy) = decode_policy_from_global_settings(&global_settings)? { - // A global policy is the complete effective policy. Dormant sandbox - // history and specs may predate the current schema, but they must not - // prevent the valid global policy from being served. + if let Some(mut global_policy) = decode_policy_from_global_settings(&global_settings)? { + // A global policy replaces dynamic policy, but startup-only UI remains + // anchored to the sandbox spec so reads cannot misrepresent enforcement. + preserve_sandbox_startup_ui(&mut global_policy, sandbox); return apply_effective_policy_context( state, catalog, @@ -2523,9 +2537,10 @@ pub(super) async fn handle_get_sandbox_config( .await .map_err(|e| Status::internal(format!("fetch policy history failed: {e}")))?; - let (mut policy, version, mut policy_hash, policy_source) = if let Some(global_policy) = + let (mut policy, version, mut policy_hash, policy_source) = if let Some(mut global_policy) = global_policy { + preserve_sandbox_startup_ui(&mut global_policy, &sandbox); let version = latest .as_ref() .map(|record| u32::try_from(record.version).unwrap_or(0)) @@ -3477,6 +3492,11 @@ async fn handle_update_config_inner( clear_provider_credentialed_markers(&mut new_policy); validate_no_reserved_provider_policy_keys(&new_policy)?; new_policy = validate_and_canonicalize_policy(new_policy)?; + if new_policy.ui.is_some() { + return Err(Status::invalid_argument( + "UI policy cannot be set globally because it is applied at sandbox startup; configure ui in each sandbox policy", + )); + } validate_policy_safety(&new_policy)?; crate::middleware::validate_policy(state.middleware_registry.as_ref(), &new_policy) .await?; @@ -3881,7 +3901,10 @@ async fn handle_update_config_inner( } let should_backfill_policy = if let Some(baseline_policy) = spec.policy.as_ref() { - let comparable_baseline = baseline_policy.clone(); + let comparable_baseline = validate_and_canonicalize_stored_policy( + baseline_policy.clone(), + STORED_POLICY_SOURCE_SPEC, + )?; validate_static_fields_unchanged(&comparable_baseline, &new_policy)?; false } else { @@ -8125,6 +8148,66 @@ mod tests { ); } + #[tokio::test] + async fn global_policy_preserves_each_sandbox_startup_ui_contract() { + use openshell_core::proto::{UiClipboardAccess, UiPolicy}; + + let state = test_server_state().await; + let global_policy = install_test_global_policy(&state).await; + assert!(global_policy.ui.is_none()); + + let sandbox_id = "global-preserves-startup-ui"; + let startup_ui = UiPolicy { + allow_graphical_ui: true, + clipboard: UiClipboardAccess::Read as i32, + allow_input_injection: false, + }; + let mut sandbox_policy = openshell_policy::restrictive_default_policy(); + sandbox_policy.ui = Some(startup_ui); + let sandbox = test_sandbox(sandbox_id, sandbox_id, sandbox_policy, Vec::new()); + state + .store + .put_message(&sandbox) + .await + .expect("store sandbox"); + + let response = handle_get_sandbox_config( + &state, + with_sandbox( + Request::new(GetSandboxConfigRequest { + sandbox_id: sandbox_id.to_string(), + }), + sandbox_id, + ), + ) + .await + .expect("global policy read must preserve startup UI") + .into_inner(); + assert_eq!( + response + .policy + .as_ref() + .and_then(|policy| policy.ui.as_ref()), + Some(&startup_ui) + ); + + let catalog = state + .provider_profile_sources + .snapshot_catalog(state.store.as_ref(), "default") + .await + .expect("provider profile catalog"); + let effective = current_effective_policy_for_sandbox( + state.as_ref(), + &catalog, + "default", + &sandbox, + sandbox_id, + ) + .await + .expect("effective policy lookup must preserve startup UI"); + assert_eq!(effective.ui.as_ref(), Some(&startup_ui)); + } + #[tokio::test] async fn canonical_mcp_version_order_produces_identical_policy_bytes_and_hashes() { let state = test_server_state().await; @@ -8222,6 +8305,93 @@ mod tests { } } + #[tokio::test] + async fn global_policy_ingress_rejects_startup_only_ui_before_persistence() { + use openshell_core::proto::UiPolicy; + + let state = test_server_state().await; + let mut policy = openshell_policy::restrictive_default_policy(); + policy.ui = Some(UiPolicy::default()); + + let error = handle_update_config( + &state, + with_user(Request::new(UpdateConfigRequest { + global: true, + policy: Some(policy), + ..Default::default() + })), + ) + .await + .expect_err("global UI must be rejected before persistence"); + + assert_eq!(error.code(), Code::InvalidArgument); + assert!(error.message().contains("sandbox startup")); + assert!( + state + .store + .get_latest_policy(GLOBAL_POLICY_SANDBOX_ID) + .await + .expect("global policy lookup") + .is_none() + ); + let settings = load_global_settings(state.store.as_ref()) + .await + .expect("global settings lookup"); + assert!(!settings.settings.contains_key(POLICY_SETTING_KEY)); + } + + #[tokio::test] + async fn sandbox_policy_update_accepts_semantically_unchanged_ui_default() { + use openshell_core::proto::{UiClipboardAccess, UiPolicy}; + + let state = test_server_state().await; + let sandbox_id = "ui-default-roundtrip"; + let mut canonical = openshell_policy::restrictive_default_policy(); + canonical.ui = Some(UiPolicy { + allow_graphical_ui: true, + clipboard: UiClipboardAccess::None as i32, + ..Default::default() + }); + state + .store + .put_message(&test_sandbox( + sandbox_id, + sandbox_id, + canonical.clone(), + Vec::new(), + )) + .await + .expect("store sandbox"); + + let mut protobuf_default = canonical; + protobuf_default.ui.as_mut().expect("UI policy").clipboard = + UiClipboardAccess::Unspecified as i32; + handle_update_config( + &state, + with_user(Request::new(UpdateConfigRequest { + name: sandbox_id.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), + policy: Some(protobuf_default), + ..Default::default() + })), + ) + .await + .expect("unspecified and none are the same static UI policy"); + + let stored = state + .store + .get_latest_policy(sandbox_id) + .await + .expect("policy history lookup") + .expect("policy revision"); + let persisted = ProtoSandboxPolicy::decode(stored.policy_payload.as_slice()) + .expect("decode persisted policy"); + assert_eq!( + persisted.ui.expect("persisted UI").clipboard, + UiClipboardAccess::None as i32 + ); + } + #[tokio::test] async fn policy_record_identity_global_deduplicates_defaulted_mcp_history() { for (case, legacy_policy) in defaulted_mcp_policy_cases() { diff --git a/crates/openshell-server/src/grpc/validation.rs b/crates/openshell-server/src/grpc/validation.rs index 53ea3dbc19..76e3be2af2 100644 --- a/crates/openshell-server/src/grpc/validation.rs +++ b/crates/openshell-server/src/grpc/validation.rs @@ -10,7 +10,7 @@ use openshell_core::proto::{ CredentialHandle, ExecSandboxRequest, Provider, SandboxPolicy as ProtoSandboxPolicy, - SandboxSpec, SandboxTemplate, + SandboxSpec, SandboxTemplate, UiClipboardAccess, }; use openshell_core::rpc_error::invalid_argument; use prost::Message; @@ -1039,7 +1039,14 @@ pub(super) fn validate_static_fields_unchanged( "process policy cannot be changed on a live sandbox (applied at startup)", )); } - if baseline.ui != new.ui { + let mut baseline_ui = baseline.ui; + let mut new_ui = new.ui; + for ui in [&mut baseline_ui, &mut new_ui].into_iter().flatten() { + if ui.clipboard == UiClipboardAccess::Unspecified as i32 { + ui.clipboard = UiClipboardAccess::None as i32; + } + } + if baseline_ui != new_ui { return Err(Status::invalid_argument( "UI policy cannot be changed on a live sandbox (applied at startup)", )); diff --git a/docs/reference/policy-schema.mdx b/docs/reference/policy-schema.mdx index a3ef3bf7a3..badb0160b5 100644 --- a/docs/reference/policy-schema.mdx +++ b/docs/reference/policy-schema.mdx @@ -185,12 +185,19 @@ Omit the entire section when the workload does not need a UI surface. | `allow_input_injection` | bool | No | `true`, `false` | Allows synthetic keyboard or pointer input. Defaults to `false`. | MXC `process_container` enforces this section on Windows and advertises complete -support through the compute-driver capability contract. OpenShell rejects any -explicit UI section, including `{}`, with MXC `isolation_session` because -current MXC rejects the top-level `ui` object there. Docker, Podman, Kubernetes, -VM, and older or partial extension drivers also advertise no support, so the -gateway rejects explicit UI policy before validation or provisioning. Omitting -the section preserves each runtime's existing behavior. +support through the compute-driver capability contract. MXC's `disable` switch +suppresses the other UI fields, so a clipboard or input-injection grant on this +backend also requires `allow_graphical_ui: true`; OpenShell rejects a grant that +MXC would ignore. OpenShell rejects any explicit UI section, including `{}`, with +MXC `isolation_session` because current MXC rejects the top-level `ui` object +there. Docker, Podman, Kubernetes, VM, and older or partial extension drivers +also advertise no support, so the gateway rejects explicit UI policy before +validation or provisioning. Omitting the section preserves each runtime's +existing behavior. + +UI is a per-sandbox startup contract and cannot be set in a gateway-global +policy. A global policy changes dynamic policy fields while each sandbox keeps +the UI controls applied from its own policy at creation. Example: diff --git a/docs/sandboxes/policies.mdx b/docs/sandboxes/policies.mdx index a4cb2a1830..4ab6a54c52 100644 --- a/docs/sandboxes/policies.mdx +++ b/docs/sandboxes/policies.mdx @@ -615,7 +615,9 @@ openshell policy set --global --policy ./global-policy.yaml When a global policy is configured: -- The global payload is applied in full for all sandboxes. +- The global payload supplies the dynamic policy fields for all sandboxes. +- Each sandbox keeps the startup-only `ui` section from its own creation policy; + a global policy containing `ui` is rejected. - Sandbox-level policy updates are rejected until the global policy is removed. To restore sandbox-level policy control, delete the global policy setting: diff --git a/sdk/go/openshell/v1/fake/sandbox.go b/sdk/go/openshell/v1/fake/sandbox.go index 0fa43a59d5..fdf8a0861f 100644 --- a/sdk/go/openshell/v1/fake/sandbox.go +++ b/sdk/go/openshell/v1/fake/sandbox.go @@ -79,6 +79,10 @@ func copySandboxPolicy(p *types.SandboxPolicy) *types.SandboxPolicy { pr := *p.Process cp.Process = &pr } + if p.UI != nil { + ui := *p.UI + cp.UI = &ui + } if p.NetworkPolicies != nil { np := make(map[string]types.NetworkPolicyRule, len(p.NetworkPolicies)) for k, rule := range p.NetworkPolicies { diff --git a/sdk/go/openshell/v1/fake/sandbox_test.go b/sdk/go/openshell/v1/fake/sandbox_test.go index c9dcc8829b..14ab91b3c7 100644 --- a/sdk/go/openshell/v1/fake/sandbox_test.go +++ b/sdk/go/openshell/v1/fake/sandbox_test.go @@ -837,6 +837,11 @@ func TestFakeSandboxCreateWithPolicy(t *testing.T) { RunAsUser: "sandbox", RunAsGroup: "sandbox-group", }, + UI: &types.UIPolicy{ + AllowGraphicalUI: true, + Clipboard: types.UIClipboardAccessRead, + AllowInputInjection: true, + }, NetworkPolicies: map[string]types.NetworkPolicyRule{ "web": { Name: "web", @@ -874,6 +879,10 @@ func TestFakeSandboxCreateWithPolicy(t *testing.T) { require.NotNil(t, p.Process) assert.Equal(t, "sandbox", p.Process.RunAsUser) assert.Equal(t, "sandbox-group", p.Process.RunAsGroup) + require.NotNil(t, p.UI) + assert.True(t, p.UI.AllowGraphicalUI) + assert.Equal(t, types.UIClipboardAccessRead, p.UI.Clipboard) + assert.True(t, p.UI.AllowInputInjection) require.Len(t, p.NetworkPolicies, 1) webRule, ok := p.NetworkPolicies["web"] @@ -886,19 +895,23 @@ func TestFakeSandboxCreateWithPolicy(t *testing.T) { // Deep-copy isolation: mutate input spec, verify stored copy unchanged spec.Policy.Version = 99 spec.Policy.Filesystem.ReadOnly[0] = "mutated" + spec.Policy.UI.Clipboard = types.UIClipboardAccessAll spec.Policy.NetworkPolicies["web"] = types.NetworkPolicyRule{Name: "mutated"} got2, err := sc.Get(ctx, "default", "policy-sb") require.NoError(t, err) assert.Equal(t, uint32(3), got2.Spec.Policy.Version) assert.Equal(t, "/etc", got2.Spec.Policy.Filesystem.ReadOnly[0]) + assert.Equal(t, types.UIClipboardAccessRead, got2.Spec.Policy.UI.Clipboard) assert.Equal(t, "web", got2.Spec.Policy.NetworkPolicies["web"].Name) // Deep-copy isolation: mutate returned object, verify store unchanged got.Spec.Policy.Filesystem.ReadWrite[0] = "mutated" + got.Spec.Policy.UI.AllowInputInjection = false got3, err := sc.Get(ctx, "default", "policy-sb") require.NoError(t, err) assert.Equal(t, "/tmp", got3.Spec.Policy.Filesystem.ReadWrite[0]) + assert.True(t, got3.Spec.Policy.UI.AllowInputInjection) } func TestFakeSandboxCreateWithNilPolicy(t *testing.T) { From 975de90c29518c39ceef5c9b5e31f18c090358cb Mon Sep 17 00:00:00 2001 From: Akber Raza Date: Thu, 13 Aug 2026 18:13:54 -0500 Subject: [PATCH 03/26] feat: enhance MXC backend with provider credential management - Introduced `pending_provider_credentials` in `MxcComputeBackend` to manage out-of-band provider credential state. - Added `append_provider_child_env` function to merge provider credentials into the agent environment, ensuring sensitive values are not exposed. - Updated `create_sandbox` method to validate egress requirements for provider credentials. - Implemented `resolve_sandbox_create_runtime_inputs` to prepare driver-specific sandbox creation state, including effective policy and provider credentials. - Modified gRPC handlers to utilize the new provider credential management features. - Updated documentation to clarify the use of provider credentials and their requirements for governed egress. Signed-off-by: Akber Raza --- docs/reference/gateway-config.mdx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index 9940f2ee2e..b61ba7bdf3 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -888,6 +888,8 @@ OpenShell sends no override and Podman applies its runtime-selected profile. The setting applies to the workload container; the supervisor retains Podman's runtime-selected profile. +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 `agent_env` entries case-insensitively. Sandbox creation fails when attached provider material exists but `egress_proxy` is disabled. MXC takes its provider snapshot at creation; recreate the sandbox after attaching, detaching, or rotating a provider. + ### MicroVM Each sandbox runs inside its own libkrun microVM managed by the standalone `openshell-driver-vm` subprocess. Use this driver when you want stronger isolation than container namespaces alone. From ba196e9f73f318957ee39b5016ac3ef45d336d24 Mon Sep 17 00:00:00 2001 From: Akber Raza Date: Wed, 19 Aug 2026 21:32:54 -0500 Subject: [PATCH 04/26] feat(mxc): validate provider credentials through host proxy Add an end-to-end Windows MXC scenario for verifying provider credential handling without exposing the raw credential to the sandbox. The scenario confirms that the sandbox receives a revision-scoped GITHUB_TOKEN placeholder, the host CONNECT proxy substitutes the credential only for api.github.com, and use of the same placeholder against github.com is rejected with credential_endpoint_mismatch. Use inbox Windows PowerShell and curl for the probe so the standard Windows build requires no additional test executable. Configure SystemRoot and PATHEXT explicitly through agent_env, which provides the minimum environment needed to locate and execute curl while avoiding implicit inheritance of the gateway environment. Add pc_disable_ui for process-container workloads that require Win32k during startup. Preserve the hardened default and continue denying clipboard access and input injection when UI compatibility is enabled. Collect redacted diagnostic artifacts, check for raw-token leakage, and document the MXC configuration and credential-validation workflow. Tests: - cargo test -p openshell-driver-mxc - Windows PowerShell curl launch with only SystemRoot and PATHEXT Signed-off-by: Akber Raza --- .../examples/mxc-github-provider-profile.yml | 29 ++ .../mxc-provider-credential-policy.yaml | 36 ++ .../mxc-provider-credential-probe.ps1 | 175 +++++++++ .../examples/mxc-provider-credential.toml | 18 + .../examples/run-provider-credential-test.ps1 | 352 ++++++++++++++++++ 5 files changed, 610 insertions(+) create mode 100644 crates/openshell-driver-mxc/examples/mxc-github-provider-profile.yml create mode 100644 crates/openshell-driver-mxc/examples/mxc-provider-credential-policy.yaml create mode 100644 crates/openshell-driver-mxc/examples/mxc-provider-credential-probe.ps1 create mode 100644 crates/openshell-driver-mxc/examples/mxc-provider-credential.toml create mode 100644 crates/openshell-driver-mxc/examples/run-provider-credential-test.ps1 diff --git a/crates/openshell-driver-mxc/examples/mxc-github-provider-profile.yml b/crates/openshell-driver-mxc/examples/mxc-github-provider-profile.yml new file mode 100644 index 0000000000..49f8ee64e1 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/mxc-github-provider-profile.yml @@ -0,0 +1,29 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Test-only Windows variant of the built-in GitHub profile. MXC's host proxy +# uses the first per-sandbox command element as its static process identity, so +# this profile names inbox Windows PowerShell rather than the Linux gh/git paths +# in the production GitHub profile. + +id: mxc-github-e2e +display_name: MXC GitHub credential e2e +description: Test-only GitHub profile for the MXC provider credential example +category: source_control +credentials: + - name: api_token + description: GitHub token + env_vars: [GITHUB_TOKEN] + required: true + auth_style: bearer + header_name: authorization +discovery: + credentials: [api_token] +endpoints: + - host: api.github.com + port: 443 + protocol: rest + access: read-only + enforcement: enforce +binaries: + - "C:/Windows/System32/WindowsPowerShell/v1.0/powershell.exe" diff --git a/crates/openshell-driver-mxc/examples/mxc-provider-credential-policy.yaml b/crates/openshell-driver-mxc/examples/mxc-provider-credential-policy.yaml new file mode 100644 index 0000000000..cc6809fe70 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/mxc-provider-credential-policy.yaml @@ -0,0 +1,36 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Base policy for the MXC provider-credential example. The attached provider +# profile contributes the api.github.com rule. This base rule separately allows +# github.com so the probe can prove credential endpoint binding independently of +# network admission: traffic is allowed, but use of GITHUB_TOKEN is not. Keeping +# the negative probe within GitHub avoids risking a token leak to an unrelated +# service if the behavior under test regresses. + +version: 1 + +ui: + # Windows PowerShell loads USER32 during startup and therefore requires the + # graphical UI subsystem. Clipboard and input injection remain denied. + allow_graphical_ui: true + clipboard: none + allow_input_injection: false + +filesystem_policy: + include_workdir: false + read_only: [] + read_write: + - "C:/work/openshell-mxc-provider" + +network_policies: + credential_endpoint_mismatch_probe: + name: credential-endpoint-mismatch-probe + endpoints: + - host: github.com + port: 443 + protocol: rest + access: read-only + enforcement: enforce + binaries: + - path: "C:/Windows/System32/WindowsPowerShell/v1.0/powershell.exe" diff --git a/crates/openshell-driver-mxc/examples/mxc-provider-credential-probe.ps1 b/crates/openshell-driver-mxc/examples/mxc-provider-credential-probe.ps1 new file mode 100644 index 0000000000..56477c2342 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/mxc-provider-credential-probe.ps1 @@ -0,0 +1,175 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# In-sandbox probe for the MXC provider-credential example. The probe verifies +# that GITHUB_TOKEN is a revision-scoped placeholder before making any network +# request, then exercises authorized substitution and endpoint mismatch. + +[CmdletBinding()] +param( + [string] $OutputDir = "C:\work\openshell-mxc-provider" +) + +$ErrorActionPreference = "Stop" +$PSNativeCommandUseErrorActionPreference = $false +$utf8NoBom = New-Object System.Text.UTF8Encoding($false) +$resultPath = Join-Path $OutputDir "mxc-provider-credential-result.txt" + +function Complete-Probe([string[]] $Checks) { + $passed = @($Checks | Where-Object { $_.StartsWith("[FAIL]") }).Count -eq 0 + $lines = @($Checks) + "OVERALL: $(if ($passed) { 'PASS' } else { 'FAIL' })" + $summary = ($lines -join "`n") + "`n" + [System.IO.File]::WriteAllText($resultPath, $summary, $utf8NoBom) + Write-Output $summary.TrimEnd() + if ($passed) { exit 0 } else { exit 1 } +} + +function Get-SafeProbeText([string] $Text, [string] $Token) { + if ([string]::IsNullOrEmpty($Text)) { return $Text } + + $safe = $Text + if (-not [string]::IsNullOrEmpty($Token)) { + $safe = $safe.Replace($Token, "") + } + $safe = [regex]::Replace( + $safe, + '(?i)(authorization:\s*bearer\s+)\S+', + '$1' + ) + $safe = ($safe -replace '\r?\n', ' | ').Trim() + if ($safe.Length -gt 512) { + $safe = $safe.Substring(0, 512) + "..." + } + return $safe +} + +function Invoke-CurlProbe( + [string] $CurlPath, + [string] $Url, + [string] $BodyPath, + [string] $CaBundle, + [string] $Token +) { + Remove-Item $BodyPath -Force -ErrorAction SilentlyContinue + + # Windows inbox curl uses Schannel, which ignores CURL_CA_BUNDLE as an + # environment variable. Pass the host proxy's generated bundle explicitly. + # Windows PowerShell also turns native stderr into ErrorRecord objects; use + # Continue locally and merge stderr into memory so curl failures become + # structured probe results. Avoid redirecting stderr to the shared folder: + # a file created by an earlier sandbox can carry a different AppContainer + # SID and cause PowerShell to throw UnauthorizedAccessException before curl + # starts. + $nativeOutput = @() + $exitCode = -1 + $previous = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { + $nativeOutput = @(& $CurlPath ` + --silent ` + --show-error ` + --connect-timeout 15 ` + --max-time 60 ` + --cacert $CaBundle ` + --ssl-revoke-best-effort ` + --output $BodyPath ` + --write-out "%{http_code}" ` + --header "Authorization: Bearer $Token" ` + --header "Accept: application/vnd.github+json" ` + --header "User-Agent: openshell-mxc-provider-credential-example" ` + $Url 2>&1) + $exitCode = $LASTEXITCODE + } finally { + $ErrorActionPreference = $previous + } + $stdout = @($nativeOutput | Where-Object { + $_ -isnot [System.Management.Automation.ErrorRecord] + } | ForEach-Object { $_.ToString() }) + $nativeErrors = @($nativeOutput | Where-Object { + $_ -is [System.Management.Automation.ErrorRecord] + } | ForEach-Object { $_.Exception.Message }) + $httpCode = ($stdout -join "").Trim() + $stderr = Get-SafeProbeText ` + -Text ($nativeErrors -join [Environment]::NewLine) ` + -Token $Token + $body = if (Test-Path $BodyPath) { + [System.IO.File]::ReadAllText($BodyPath, [System.Text.Encoding]::UTF8) + } else { + "" + } + $errorText = if ($exitCode -eq 0) { + $null + } elseif ([string]::IsNullOrWhiteSpace($stderr)) { + "curl exited $exitCode" + } else { + $stderr + } + + return [pscustomobject]@{ + HttpCode = $httpCode + Body = $body + Error = $errorText + } +} + +$checks = @() +$stage = "environment validation" +try { + $token = $env:GITHUB_TOKEN + if ([string]::IsNullOrWhiteSpace($token)) { + $checks += "[FAIL] GITHUB_TOKEN is unavailable" + Complete-Probe $checks + } + if ($token -notmatch '^openshell:resolve:env:v[0-9]+_GITHUB_TOKEN$') { + $checks += "[FAIL] MXC did not receive a revision-scoped GITHUB_TOKEN placeholder" + $checks += "[INFO] no network request was attempted" + Complete-Probe $checks + } + + $checks += "[PASS] MXC received only a revision-scoped GITHUB_TOKEN placeholder" + $curlPath = Join-Path $env:SystemRoot "System32\curl.exe" + if (-not (Test-Path $curlPath)) { + $checks += "[FAIL] inbox curl.exe is unavailable" + Complete-Probe $checks + } + $caBundle = $env:CURL_CA_BUNDLE + if ([string]::IsNullOrWhiteSpace($caBundle) -or -not (Test-Path $caBundle)) { + $checks += "[FAIL] host proxy CA bundle is unavailable" + Complete-Probe $checks + } + $checks += "[PASS] host proxy CA bundle is available to inbox curl" + + $stage = "api.github.com request" + $github = Invoke-CurlProbe ` + -CurlPath $curlPath ` + -Url "https://api.github.com/user" ` + -BodyPath (Join-Path $OutputDir "github-user-response.json") ` + -CaBundle $caBundle ` + -Token $token + if ($null -eq $github.Error -and $github.HttpCode -eq "200" -and $github.Body.Contains('"login"')) { + $checks += "[PASS] GitHub accepted the credential rewritten by the host CONNECT proxy (HTTP 200)" + } else { + $errorText = if ($null -eq $github.Error) { "none" } else { $github.Error } + $checks += "[FAIL] authenticated GitHub request failed (http=$($github.HttpCode), error=$errorText)" + } + + $stage = "github.com endpoint-mismatch request" + $mismatch = Invoke-CurlProbe ` + -CurlPath $curlPath ` + -Url "https://github.com/" ` + -BodyPath (Join-Path $OutputDir "credential-mismatch-response.json") ` + -CaBundle $caBundle ` + -Token $token + if ($null -eq $mismatch.Error -and $mismatch.HttpCode -eq "403" -and $mismatch.Body.Contains("credential_endpoint_mismatch")) { + $checks += "[PASS] proxy rejected placeholder use outside the GitHub binding (HTTP 403 credential_endpoint_mismatch)" + } else { + $errorText = if ($null -eq $mismatch.Error) { "none" } else { $mismatch.Error } + $checks += "[FAIL] endpoint-mismatch request was not rejected as expected (http=$($mismatch.HttpCode), error=$errorText)" + } +} catch { + # Report only the stage and exception type. Exception messages can echo + # native command arguments, and this probe must never persist credentials. + $checks += "[FAIL] probe encountered an unexpected error during $stage ($($_.Exception.GetType().Name))" +} + +Complete-Probe $checks diff --git a/crates/openshell-driver-mxc/examples/mxc-provider-credential.toml b/crates/openshell-driver-mxc/examples/mxc-provider-credential.toml new file mode 100644 index 0000000000..65cd4a21c1 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/mxc-provider-credential.toml @@ -0,0 +1,18 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Gateway configuration for run-provider-credential-test.ps1. The runner writes +# a disposable copy with the requested wxc-exec path. + +[openshell.drivers.mxc] +wxc_exec_path = "C:\\mxc-kit\\bin\\wxc-exec.exe" +backend = "process_container" +default_configuration_id = "composable" + +# The split mapper gives MXC a loopback redirect and the driver starts the +# per-sandbox host CONNECT proxy that enforces L4/L7 policy and rewrites +# provider credential placeholders. +egress_proxy = true +egress_proxy_addr = "127.0.0.1:18080" + +debug = false diff --git a/crates/openshell-driver-mxc/examples/run-provider-credential-test.ps1 b/crates/openshell-driver-mxc/examples/run-provider-credential-test.ps1 new file mode 100644 index 0000000000..250b9f3415 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/run-provider-credential-test.ps1 @@ -0,0 +1,352 @@ +# 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 demo-package folder containing openshell-gateway.exe, openshell.exe, +# the PowerShell probe, and the three configuration fixtures beside this script, +# or pass explicit gateway and CLI 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) { 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 Invoke-Cli([string[]]$CommandArgs, [switch]$AllowFailure) { + $previous = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { + $lines = & $cli @CommandArgs 2>&1 + $exitCode = $LASTEXITCODE + } finally { + $ErrorActionPreference = $previous + } + # Windows PowerShell wraps native stderr lines in ErrorRecord objects and + # Out-String adds a misleading "At ... NativeCommandError" block even when + # the command succeeds. Preserve the message without that decoration. + $text = (($lines | ForEach-Object { + if ($_ -is [System.Management.Automation.ErrorRecord]) { + $_.Exception.Message + } else { + $_.ToString() + } + }) -join [Environment]::NewLine).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 +$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" + } + 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 { + $gw = Start-Process -FilePath $gateway ` + -ArgumentList @("--disable-tls", "--db-url", "sqlite::memory:", "--log-level", "info") ` + -WorkingDirectory $here -PassThru -NoNewWindow ` + -RedirectStandardOutput $gwLog -RedirectStandardError $gwErrLog + } 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 = "" + Invoke-Cli @("gateway", "add", "http://127.0.0.1:$Port", "--local", "--name", $GatewayName) | Out-Null + Invoke-Cli @("gateway", "select", $GatewayName) | Out-Null + Invoke-Cli @("settings", "set", "--global", "--yes", "--key", "providers_v2_enabled", "--value", "true") | 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) + 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 { + Bad $_.Exception.Message +} +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 { + try { + $contents = [System.IO.File]::ReadAllText($_.FullName, [System.Text.Encoding]::UTF8) + if ($contents.Contains($githubToken)) { + $rawTokenLeak = $true + [System.IO.File]::WriteAllText($_.FullName, $contents.Replace($githubToken, "***REDACTED***"), $utf8NoBom) + } + } catch {} + } + } + 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 + +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" })) + + 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 } From f44e95d4abdf46f13e3a7b5747e5feb63969ab95 Mon Sep 17 00:00:00 2001 From: Akber Raza Date: Thu, 10 Sep 2026 17:10:29 -0500 Subject: [PATCH 05/26] feat(mxc): route provider credentials through host proxy - pass effective provider policy and credential state into MXC sandbox creation - inject revision-scoped placeholders into the MXC child environment - resolve provider credentials only through governed host CONNECT proxy requests - add gateway and driver coverage for secret isolation and policy propagation - update MXC documentation and remove obsolete provider-v2 test setup Signed-off-by: Akber Raza --- .../src/provider_credentials.rs | 12 + crates/openshell-driver-mxc/README.md | 33 +- .../examples/run-provider-credential-test.ps1 | 1 - crates/openshell-driver-mxc/src/driver.rs | 253 ++++++++++++++- crates/openshell-gateway/src/lib.rs | 10 +- crates/openshell-server/src/compute/mod.rs | 297 +++++++++++++++++- crates/openshell-server/src/grpc/policy.rs | 204 ++++++++++-- crates/openshell-server/src/grpc/sandbox.rs | 10 +- crates/openshell-server/src/lib.rs | 27 +- 9 files changed, 791 insertions(+), 56 deletions(-) diff --git a/crates/openshell-core/src/provider_credentials.rs b/crates/openshell-core/src/provider_credentials.rs index 4d893fa362..03bfd0ec7d 100644 --- a/crates/openshell-core/src/provider_credentials.rs +++ b/crates/openshell-core/src/provider_credentials.rs @@ -373,6 +373,18 @@ impl ProviderCredentialState { .revision } + /// Whether this snapshot contains endpoint-bound material that must be + /// resolved by a network proxy rather than exposed to the child process. + #[must_use] + pub fn requires_proxy_resolution(&self) -> bool { + let inner = self + .inner + .read() + .expect("provider credential state poisoned"); + !inner.static_credential_bindings.is_empty() + || !inner.current.dynamic_credentials.is_empty() + } + /// Remove a key from the credential snapshot's child env. /// /// Used when a sandbox-side service (e.g., metadata server) fails to start diff --git a/crates/openshell-driver-mxc/README.md b/crates/openshell-driver-mxc/README.md index 46b8839a70..d3a7a78c52 100644 --- a/crates/openshell-driver-mxc/README.md +++ b/crates/openshell-driver-mxc/README.md @@ -7,9 +7,10 @@ OpenShell compute driver backed by **Microsoft MXC** (`wxc-exec`) on Windows. This driver implements the gateway's ordinary in-process `ComputeDriver` contract and is linked into `openshell-gateway`. It sets `driver_reports_runtime_readiness`, so the gateway accepts driver-reported -readiness without a supervisor session. The canonical create-time -`SandboxPolicy` is carried by `DriverSandboxSpec.policy`. `process_container` launches a one-shot -AppContainer and is the default. The opt-in `isolation_session` backend uses the +readiness without a supervisor session. The gateway composes the create-time +effective `SandboxPolicy` and carries it on the driver-only copy of +`DriverSandboxSpec.policy`. `process_container` launches a one-shot AppContainer +and is the default. The opt-in `isolation_session` backend uses the state-aware `provision` → `start` → `exec` → `stop` → `deprovision` lifecycle. The driver launches and monitors the configured workload itself and self-reports readiness; there is no in-sandbox supervisor or `ConnectSupervisor` relay. @@ -22,6 +23,7 @@ readiness; there is no in-sandbox supervisor or `ConnectSupervisor` relay. | 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. | MXC support for persistent sessions | | Governed egress (CONNECT proxy + OPA + L7) | Available behind `egress_proxy` on `process_container`; the driver starts a per-sandbox host CONNECT proxy, generates HTTPS MITM trust material, and injects the CA bundle into the sandbox process env | Gateway event-bus wiring follow-on | | Network policy | Split into MXC `network.proxy` + trimmed OpenShell policy on `process_container`; `isolation_session` still rejects network config | MXC feedback item M1 for persistent sessions | +| 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. | — | | Network middleware | ❌ rejected before launch because the MXC host proxy does not receive the gateway middleware registry | Gateway middleware-registry injection | | Process policy (seccomp, uid/gid) | ❌ host-side governance design; OS isolation only | not pursued | | Interactive exec/connect/forward | ❌ exec runs in-driver, no client attach | gateway interactive-exec surgery (follow-on) | @@ -66,7 +68,15 @@ openshell sandbox create --name mxc-demo --policy demo.yaml ` --driver-config-json $config --env MODE=demo --no-tty ``` -The `command` array is required and preserves Windows argument boundaries. `cwd` is optional. Environment variables come from the standard sandbox and template environment maps; the driver never copies values from the gateway host environment. +The `command` array is required and preserves Windows argument boundaries. `cwd` +is optional. Environment variables come from the standard sandbox and template +environment maps; the driver never copies values from the gateway host +environment. Provider-owned keys override matching entries case-insensitively. +MXC receives revision-scoped placeholders and explicitly classified non-secret +provider configuration, while credential values remain in the host proxy. When +governed egress is enabled, the driver replaces common TLS trust environment +variables with paths to the proxy-generated CA material and grants that +sandbox-unique CA directory read-write so the AppContainer can read it. The host CONNECT proxy enforces network policy when governed egress is enabled. Live policy replacement or merge updates remain unsupported; delete and recreate @@ -117,7 +127,9 @@ The production driver maps the typed `SandboxPolicy` carried by the standard driver request to MXC configuration before it inserts a registry entry or invokes `wxc-exec`. Mapping failure therefore returns from `CreateSandbox` without leaving a partial sandbox. There is no in-process policy side channel -or MXC-specific gateway composition variant. +or MXC-specific gateway composition variant. Provider resolver state uses a +separate, create-scoped in-process handoff because it intentionally cannot be +represented in the public compute-driver protobuf. When `egress_proxy` is enabled, `EmbeddedPolicyMapper` uses `split_policy` instead: MXC receives filesystem grants plus a loopback `network.proxy` @@ -156,6 +168,17 @@ If governed egress is disabled, any network rule fails closed rather than launch 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. + ## Packaging the demo for the demo box Use [`examples/package-demo.ps1`](examples/package-demo.ps1) to assemble diff --git a/crates/openshell-driver-mxc/examples/run-provider-credential-test.ps1 b/crates/openshell-driver-mxc/examples/run-provider-credential-test.ps1 index 250b9f3415..56aff38dd6 100644 --- a/crates/openshell-driver-mxc/examples/run-provider-credential-test.ps1 +++ b/crates/openshell-driver-mxc/examples/run-provider-credential-test.ps1 @@ -243,7 +243,6 @@ try { $env:OPENSHELL_GATEWAY = "" Invoke-Cli @("gateway", "add", "http://127.0.0.1:$Port", "--local", "--name", $GatewayName) | Out-Null Invoke-Cli @("gateway", "select", $GatewayName) | Out-Null - Invoke-Cli @("settings", "set", "--global", "--yes", "--key", "providers_v2_enabled", "--value", "true") | 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 diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index 52518a43d3..188761da43 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -15,12 +15,13 @@ use openshell_core::proto::compute::v1::{ WatchSandboxesPlatformEvent, WatchSandboxesSandboxEvent, watch_sandboxes_event, }; use openshell_core::proto_struct::struct_to_json_value; +use openshell_core::provider_credentials::ProviderCredentialState; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::net::SocketAddr; use std::path::PathBuf; use std::pin::Pin; -use std::sync::Arc; +use std::sync::{Arc, Mutex as StdMutex}; use tokio::sync::{Mutex, broadcast, mpsc, watch}; use tokio::task::JoinHandle; use tokio_stream::wrappers::ReceiverStream; @@ -209,6 +210,10 @@ pub struct MxcComputeBackend { registry: Arc>>, watch_tx: Arc>, policy_mapper: Arc, + /// Provider resolver snapshots staged by the gateway immediately before + /// create. The driver consumes each entry exactly once; only placeholder + /// child environment values cross into MXC. + pending_provider_credentials: Arc>>, /// In-process ETW → OCSF audit consumer (Plane A). `Some` only when /// `config.etw_audit` is set and the session started; kept alive here so it /// stops when the backend is dropped (held purely for its `Drop`, hence @@ -285,6 +290,37 @@ fn sandbox_environment(sandbox: &DriverSandbox) -> Vec { environment } +/// Merge provider-owned child environment values into MXC `process.env`. +/// +/// Provider entries win case-insensitively, matching Windows environment +/// semantics. Secret values have already been replaced by revision-scoped +/// placeholders; explicitly classified GCP configuration is resolved by the +/// shared credential state because SDKs consume it before making a request. +fn append_provider_child_env( + env: &mut Vec, + provider_credentials: Option<&ProviderCredentialState>, +) { + let Some(provider_credentials) = provider_credentials else { + return; + }; + let mut provider_env = provider_credentials + .child_env_with_gcp_resolved() + .into_iter() + .collect::>(); + provider_env.sort_by(|(left, _), (right, _)| left.cmp(right)); + env.retain(|entry| { + let key = entry.split_once('=').map_or(entry.as_str(), |(key, _)| key); + !provider_env + .iter() + .any(|(provider_key, _)| key.eq_ignore_ascii_case(provider_key)) + }); + env.extend( + provider_env + .into_iter() + .map(|(key, value)| format!("{key}={value}")), + ); +} + fn configured_egress_addr(config: &MxcComputeConfig) -> Result, tonic::Status> { if !config.egress_proxy { return Ok(None); @@ -447,11 +483,19 @@ impl MxcComputeBackend { // Production policy translation is always handled by the embedded // mapper before any MXC lifecycle side effects begin. policy_mapper: Arc::new(EmbeddedPolicyMapper), + pending_provider_credentials: Arc::new(StdMutex::new(HashMap::new())), etw_session, attribution, } } + /// Return the in-process create-time provider credential side channel. + pub fn provider_credentials_sink( + &self, + ) -> Arc>> { + self.pending_provider_credentials.clone() + } + /// Test-only constructor wiring the in-process mock `wxc-exec` shim. #[cfg(test)] pub(crate) fn new_mocked(config: MxcComputeConfig) -> Self { @@ -540,6 +584,14 @@ impl MxcComputeBackend { pub async fn create_sandbox(&self, sandbox: &DriverSandbox) -> Result<(), tonic::Status> { let sandbox_id = sandbox.id.clone(); + // Consume before any fallible validation so rejected creates cannot + // retain real provider material in the staging map. + let provider_credentials = self + .pending_provider_credentials + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(&sandbox_id); + Self::validate_sandbox_fields(sandbox)?; let sandbox_config = sandbox_config(sandbox)?; let (egress_addr, reserved_proxy_listener) = match configured_egress_addr(&self.config)? { @@ -561,6 +613,15 @@ impl MxcComputeBackend { // synchronously at the CreateSandbox boundary. let policy = sandbox.spec.as_ref().and_then(|spec| spec.policy.as_ref()); let mapped = self.map_sandbox_policy(&sandbox_id, policy, egress_addr)?; + if provider_credentials + .as_ref() + .is_some_and(ProviderCredentialState::requires_proxy_resolution) + && egress_addr.is_none() + { + return Err(tonic::Status::failed_precondition( + "mxc provider credentials require governed egress; enable egress_proxy so placeholders can be resolved by the host proxy", + )); + } if sandbox .spec @@ -631,6 +692,7 @@ impl MxcComputeBackend { sandbox, sandbox_config, mapped, + provider_credentials, reserved_proxy_listener, startup_guard, ) @@ -829,6 +891,7 @@ async fn run_lifecycle( sandbox: DriverSandbox, sandbox_config: MxcSandboxConfig, mapped: MappedConfig, + provider_credentials: Option, mut reserved_proxy_listener: Option, _startup_guard: tokio::sync::OwnedMutexGuard<()>, ) { @@ -848,7 +911,7 @@ async fn run_lifecycle( sandbox_id: Some(sandbox_id.clone()), sandbox_name: Some(sandbox_name.clone()), openshell_endpoint: None, - provider_credentials: None, + provider_credentials: provider_credentials.clone(), agent_proposals: openshell_core::proposals::AgentProposals::default(), denial_tx: None, activity_tx: None, @@ -911,6 +974,7 @@ async fn run_lifecycle( }; let command_line = encode_windows_command_line(&sandbox_config.command); let mut environment = sandbox_environment(&sandbox); + append_provider_child_env(&mut environment, provider_credentials.as_ref()); append_tls_env_vars(&mut environment, host_proxy_ca_paths.as_ref()); info!(sandbox = %sandbox_name, count = environment.len(), "MXC process env vars"); let process = MxcProcess { @@ -1241,8 +1305,9 @@ mod lifecycle_tests { use futures::StreamExt; use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; use openshell_core::proto::{ - FilesystemPolicy, MiddlewareEndpointSelector, NetworkMiddlewareConfig, SandboxPolicy, - UiClipboardAccess, UiPolicy, + FilesystemPolicy, MiddlewareEndpointSelector, NetworkBinary, NetworkEndpoint, + NetworkMiddlewareConfig, NetworkPolicyRule, SandboxPolicy, StaticCredentialBinding, + StaticCredentialEndpointBinding, UiClipboardAccess, UiPolicy, }; use std::time::Duration; @@ -1298,6 +1363,32 @@ mod lifecycle_tests { } } + fn github_provider_credentials() -> ProviderCredentialState { + ProviderCredentialState::from_bound_environment( + 42, + HashMap::from([( + "GITHUB_TOKEN".to_string(), + "raw-test-token-must-not-enter-mxc".to_string(), + )]), + HashMap::new(), + HashMap::new(), + HashMap::from([( + "GITHUB_TOKEN".to_string(), + StaticCredentialBinding { + endpoints: vec![StaticCredentialEndpointBinding { + host: "api.github.com".to_string(), + port: 443, + path: "/**".to_string(), + }], + credential_identity: "provider-github:GITHUB_TOKEN".to_string(), + workload_credential_handle: String::new(), + }, + )]), + Vec::new(), + ) + .expect("valid GitHub provider credential state") + } + fn with_policy(mut sandbox: DriverSandbox, policy: SandboxPolicy) -> DriverSandbox { sandbox.spec.as_mut().unwrap().policy = Some(policy); sandbox @@ -1348,6 +1439,160 @@ mod lifecycle_tests { assert_ne!(addr.port(), 0); } + #[test] + fn governed_egress_rejects_non_loopback_and_isolation_session() { + let mut config = MxcComputeConfig { + egress_proxy: true, + egress_proxy_addr: "10.0.0.1:18080".into(), + ..Default::default() + }; + assert!( + configured_egress_addr(&config) + .unwrap_err() + .message() + .contains("127.0.0.1") + ); + + config.egress_proxy_addr = "127.0.0.1:18080".into(); + config.backend = MxcBackend::IsolationSession; + assert!( + configured_egress_addr(&config) + .unwrap_err() + .message() + .contains("MXC M1") + ); + } + + #[test] + fn provider_child_environment_overrides_agent_values_with_placeholders() { + let credentials = github_provider_credentials(); + let placeholder = credentials + .snapshot() + .child_env + .get("GITHUB_TOKEN") + .cloned() + .expect("GitHub placeholder"); + let mut env = vec![ + "github_token=agent-value".to_string(), + "UNCHANGED=value".to_string(), + ]; + + append_provider_child_env(&mut env, Some(&credentials)); + + assert!(env.contains(&"UNCHANGED=value".to_string())); + assert!(!env.iter().any(|entry| entry == "github_token=agent-value")); + assert!(env.contains(&format!("GITHUB_TOKEN={placeholder}"))); + assert!(!env.iter().any(|entry| entry.contains("raw-test-token"))); + } + + #[tokio::test] + async fn provider_state_requiring_resolution_fails_closed_without_governed_egress() { + let backend = MxcComputeBackend::new_mocked(MxcComputeConfig::default()); + let sink = backend.provider_credentials_sink(); + sink.lock() + .expect("provider credential staging lock poisoned") + .insert( + "sb-provider-no-proxy".to_string(), + github_provider_credentials(), + ); + + let error = backend + .create_sandbox(&with_policy( + driver_sandbox("sb-provider-no-proxy"), + fs_policy(&[]), + )) + .await + .expect_err("provider credentials must require governed egress"); + + assert_eq!(error.code(), tonic::Code::FailedPrecondition); + assert!(error.message().contains("enable egress_proxy")); + assert!( + sink.lock() + .expect("provider credential staging lock poisoned") + .is_empty(), + "the driver must consume staged credential material on every create attempt" + ); + } + + #[tokio::test] + async fn governed_egress_puts_only_provider_placeholder_in_mxc_process_env() { + let mut config = MxcComputeConfig { + egress_proxy: true, + egress_proxy_addr: "127.0.0.1:18080".to_string(), + ..Default::default() + }; + config.backend = MxcBackend::ProcessContainer; + let backend = MxcComputeBackend::new_mocked(config); + let credentials = github_provider_credentials(); + let placeholder = credentials + .snapshot() + .child_env + .get("GITHUB_TOKEN") + .cloned() + .expect("GitHub placeholder"); + backend + .provider_credentials_sink() + .lock() + .expect("provider credential staging lock poisoned") + .insert("sb-provider-env".to_string(), credentials); + + let mut policy = fs_policy(&[]); + policy.network_policies.insert( + "github".to_string(), + NetworkPolicyRule { + name: "github".to_string(), + endpoints: vec![NetworkEndpoint { + host: "api.github.com".to_string(), + port: 443, + protocol: "rest".to_string(), + provider_credentialed: true, + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: "cmd".to_string(), + ..Default::default() + }], + }, + ); + let mut sandbox = with_policy(driver_sandbox("sb-provider-env"), policy); + sandbox + .spec + .as_mut() + .expect("sandbox spec") + .environment + .extend([ + ( + "GITHUB_TOKEN".to_string(), + "raw-agent-env-token".to_string(), + ), + ("UNCHANGED".to_string(), "value".to_string()), + ]); + + backend + .create_sandbox(&sandbox) + .await + .expect("create accepted"); + wait_for(&backend, "sb-provider-env", |_| { + crate::mxc::mock_recorded_config("sb-provider-env").is_some() + }) + .await + .expect("MXC config should be recorded"); + + let recorded = + crate::mxc::mock_recorded_config("sb-provider-env").expect("mock recorded config"); + let env = recorded["process"]["env"] + .as_array() + .expect("MXC process env") + .iter() + .filter_map(serde_json::Value::as_str) + .collect::>(); + assert!(env.contains(&"UNCHANGED=value")); + assert!(env.contains(&format!("GITHUB_TOKEN={placeholder}").as_str())); + let encoded = recorded.to_string(); + assert!(!encoded.contains("raw-agent-env-token")); + assert!(!encoded.contains("raw-test-token-must-not-enter-mxc")); + } + #[test] fn sandbox_environment_inherits_host_with_spec_precedence() { let mut sandbox = driver_sandbox("sb-env"); diff --git a/crates/openshell-gateway/src/lib.rs b/crates/openshell-gateway/src/lib.rs index b67e93c102..eec328c537 100644 --- a/crates/openshell-gateway/src/lib.rs +++ b/crates/openshell-gateway/src/lib.rs @@ -159,10 +159,14 @@ impl openshell_server::ComputeDriverFactory for MxcFactory { ) -> openshell_core::Result { let config: openshell_driver_mxc::MxcComputeConfig = context.driver_config()?; let backend = openshell_driver_mxc::MxcComputeBackend::new(config); + let provider_credentials_sink = backend.provider_credentials_sink(); let driver = openshell_driver_mxc::ComputeDriverService::new(backend); - Ok(openshell_server::ComputeDriverInstance::InProcess( - std::sync::Arc::new(driver), - )) + Ok( + openshell_server::ComputeDriverInstance::InProcessWithProviderCredentials { + driver: std::sync::Arc::new(driver), + provider_credentials_sink, + }, + ) } } diff --git a/crates/openshell-server/src/compute/mod.rs b/crates/openshell-server/src/compute/mod.rs index 065c078d61..10323edc82 100644 --- a/crates/openshell-server/src/compute/mod.rs +++ b/crates/openshell-server/src/compute/mod.rs @@ -36,9 +36,11 @@ use openshell_core::proto::compute::v1::{ watch_sandboxes_event, }; use openshell_core::proto::{ - PlatformEvent, Sandbox, SandboxCondition, SandboxPhase, SandboxSpec, SandboxStatus, - SandboxTemplate, SandboxWorkloadTemplate, ServiceEndpoint, SshSession, + PlatformEvent, Sandbox, SandboxCondition, SandboxPhase, SandboxPolicy as ProtoSandboxPolicy, + SandboxSpec, SandboxStatus, SandboxTemplate, SandboxWorkloadTemplate, ServiceEndpoint, + SshSession, }; +use openshell_core::provider_credentials::ProviderCredentialState; use openshell_core::telemetry::TelemetryComputeDriver; use openshell_core::{ObjectLabels, ObjectWorkspace}; use prost::Message; @@ -65,6 +67,62 @@ pub type DriverWatchStream = Pin> + Send>>; pub type SharedComputeDriver = Arc + Send + Sync>; +pub type SandboxProviderCredentialsSink = Arc>>; + +/// Driver-specific values that must be delivered atomically with sandbox +/// creation without expanding the public compute-driver protobuf contract. +#[derive(Clone, Default)] +pub struct SandboxCreateRuntimeInputs { + pub effective_policy: Option, + pub provider_credentials: Option, + pub launch_authentication: Option>, +} + +impl SandboxCreateRuntimeInputs { + #[must_use] + pub(crate) fn new( + effective_policy: ProtoSandboxPolicy, + provider_credentials: Option, + ) -> Self { + Self { + effective_policy: Some(effective_policy), + provider_credentials, + launch_authentication: None, + } + } +} + +struct StagedProviderCredentials { + sink: SandboxProviderCredentialsSink, + sandbox_id: String, +} + +impl StagedProviderCredentials { + fn stage( + sink: SandboxProviderCredentialsSink, + sandbox_id: &str, + credentials: ProviderCredentialState, + ) -> Self { + let mut pending = sink + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + pending.insert(sandbox_id.to_string(), credentials); + drop(pending); + Self { + sink, + sandbox_id: sandbox_id.to_string(), + } + } +} + +impl Drop for StagedProviderCredentials { + fn drop(&mut self) { + self.sink + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(&self.sandbox_id); + } +} use traced_driver::TracedDriver; @@ -620,6 +678,7 @@ pub struct ComputeRuntime { telemetry_compute_driver: TelemetryComputeDriver, driver_process: Option>, default_image: String, + provider_credentials_sink: Option, store: Arc, sandbox_index: SandboxIndex, sandbox_watch_bus: SandboxWatchBus, @@ -656,6 +715,7 @@ impl ComputeRuntime { driver_name: String, driver: SharedComputeDriver, driver_process: Option>, + provider_credentials_sink: Option, store: Arc, sandbox_index: SandboxIndex, sandbox_watch_bus: SandboxWatchBus, @@ -753,6 +813,7 @@ impl ComputeRuntime { telemetry_compute_driver: TelemetryComputeDriver::custom(), driver_process, default_image, + provider_credentials_sink, store, sandbox_index, sandbox_watch_bus, @@ -804,6 +865,7 @@ impl ComputeRuntime { endpoint.name, driver, endpoint.driver_process, + None, store, sandbox_index, sandbox_watch_bus, @@ -818,6 +880,12 @@ impl ComputeRuntime { &self.default_image } + /// Whether this in-process driver accepts create-time provider state. + #[must_use] + pub(crate) fn accepts_create_time_provider_credentials(&self) -> bool { + self.provider_credentials_sink.is_some() + } + #[must_use] pub fn driver_info_snapshots(&self) -> &[ComputeDriverInfoSnapshot] { std::slice::from_ref(&self.driver_info) @@ -923,9 +991,26 @@ impl ComputeRuntime { } pub async fn validate_sandbox_create(&self, sandbox: &Sandbox) -> Result<(), Status> { - self.validate_policy_capabilities(sandbox)?; + self.validate_sandbox_create_with_runtime_inputs( + sandbox, + &SandboxCreateRuntimeInputs::default(), + ) + .await + } + + pub(crate) async fn validate_sandbox_create_with_runtime_inputs( + &self, + sandbox: &Sandbox, + runtime_inputs: &SandboxCreateRuntimeInputs, + ) -> Result<(), Status> { + self.validate_policy_capabilities(sandbox, runtime_inputs.effective_policy.as_ref())?; let mut driver_sandbox = driver_sandbox_from_public(sandbox, &self.driver_info.name) .map_err(|status| *status)?; + if let Some(effective_policy) = runtime_inputs.effective_policy.as_ref() + && let Some(spec) = driver_sandbox.spec.as_mut() + { + spec.policy = Some(effective_policy.clone()); + } // Peek, never consume: create runs the same path immediately after and // must still find the token. if let Some(token) = take_staging_token(&mut driver_sandbox) { @@ -948,11 +1033,13 @@ impl ComputeRuntime { .map(|_| ()) } - fn validate_policy_capabilities(&self, sandbox: &Sandbox) -> Result<(), Status> { - let has_explicit_ui = sandbox - .spec - .as_ref() - .and_then(|spec| spec.policy.as_ref()) + fn validate_policy_capabilities( + &self, + sandbox: &Sandbox, + effective_policy: Option<&ProtoSandboxPolicy>, + ) -> Result<(), Status> { + let has_explicit_ui = effective_policy + .or_else(|| sandbox.spec.as_ref().and_then(|spec| spec.policy.as_ref())) .and_then(|policy| policy.ui.as_ref()) .is_some(); if has_explicit_ui && !self.driver_info.supports_ui_policy { @@ -970,11 +1057,11 @@ impl ComputeRuntime { sandbox_token: Option, await_main_process_attachment: bool, ) -> Result { - self.create_sandbox_authenticated( + self.create_sandbox_with_runtime_inputs( sandbox, sandbox_token, - None, await_main_process_attachment, + SandboxCreateRuntimeInputs::default(), ) .await } @@ -985,10 +1072,35 @@ impl ComputeRuntime { sandbox_token: Option, launch_authentication: Option>, await_main_process_attachment: bool, + ) -> Result { + self.create_sandbox_with_runtime_inputs( + sandbox, + sandbox_token, + await_main_process_attachment, + SandboxCreateRuntimeInputs { + launch_authentication, + ..Default::default() + }, + ) + .await + } + + pub(crate) async fn create_sandbox_with_runtime_inputs( + &self, + sandbox: Sandbox, + sandbox_token: Option, + await_main_process_attachment: bool, + runtime_inputs: SandboxCreateRuntimeInputs, ) -> Result { // Defense in depth for internal callers that bypass the public create // handler's ValidateSandboxCreate step. This check has no side effects. - self.validate_policy_capabilities(&sandbox)?; + self.validate_policy_capabilities(&sandbox, runtime_inputs.effective_policy.as_ref())?; + if runtime_inputs.provider_credentials.is_some() && self.provider_credentials_sink.is_none() + { + return Err(Status::internal( + "provider credentials supplied to a compute driver without a create-time sink", + )); + } let sandbox_id = sandbox.object_id().to_string(); let mut sandbox = sandbox; @@ -1003,6 +1115,11 @@ impl ComputeRuntime { let mut driver_sandbox = driver_sandbox_from_public(&sandbox, &self.driver_info.name) .map_err(|status| *status)?; + if let Some(effective_policy) = runtime_inputs.effective_policy + && let Some(spec) = driver_sandbox.spec.as_mut() + { + spec.policy = Some(effective_policy); + } if let Some(staged) = staged.as_ref() { set_rootfs_tar_path(&mut driver_sandbox, staged.path()); } @@ -1051,8 +1168,20 @@ impl ComputeRuntime { } if let Some(spec) = driver_sandbox.spec.as_mut() { spec.await_main_process_attachment = await_main_process_attachment; - spec.launch_authentication = launch_authentication.unwrap_or_default(); + spec.launch_authentication = runtime_inputs.launch_authentication.unwrap_or_default(); } + let _staged_provider_credentials = match ( + self.provider_credentials_sink.clone(), + runtime_inputs.provider_credentials, + ) { + (Some(sink), Some(credentials)) => Some(StagedProviderCredentials::stage( + sink, + &sandbox_id, + credentials, + )), + (None, Some(_)) => unreachable!("provider credential sink checked before persistence"), + (_, None) => None, + }; match self .driver .call( @@ -5240,6 +5369,7 @@ pub fn new_test_runtime_with_driver( telemetry_compute_driver: TelemetryComputeDriver::custom(), driver_process: None, default_image: "openshell/sandbox:test".to_string(), + provider_credentials_sink: None, store, sandbox_index: SandboxIndex::new(), sandbox_watch_bus: SandboxWatchBus::new(), @@ -5544,6 +5674,9 @@ mod tests { workspace_rpcs_unimplemented: bool, validate_create_calls: AtomicUsize, create_calls: AtomicUsize, + provider_credentials_sink: Option, + provider_env_at_create: TestMutex>>, + created_sandboxes: TestMutex>, } #[tonic::async_trait] @@ -5643,9 +5776,28 @@ mod tests { async fn create_sandbox( &self, - _request: Request, + request: Request, ) -> Result, Status> { self.create_calls.fetch_add(1, Ordering::Relaxed); + if let Some(sandbox) = request.into_inner().sandbox { + if let Some(sink) = &self.provider_credentials_sink { + let credentials = sink + .lock() + .expect("provider credential staging lock poisoned") + .get(&sandbox.id) + .cloned() + .expect("provider credentials must be staged before driver create"); + *self + .provider_env_at_create + .lock() + .expect("provider env observation lock poisoned") = + Some(credentials.child_env_with_gcp_resolved()); + } + self.created_sandboxes + .lock() + .expect("created sandbox observation lock poisoned") + .push(sandbox); + } Ok(tonic::Response::new(CreateSandboxResponse {})) } @@ -6155,6 +6307,7 @@ mod tests { telemetry_compute_driver: TelemetryComputeDriver::custom(), driver_process: None, default_image: "openshell/sandbox:test".to_string(), + provider_credentials_sink: None, store, sandbox_index: SandboxIndex::new(), sandbox_watch_bus: SandboxWatchBus::new(), @@ -6217,6 +6370,32 @@ mod tests { ); } + #[tokio::test] + async fn effective_ui_policy_rejects_before_unsupported_driver_validation() { + let driver = Arc::new(TestDriver::default()); + let runtime = test_runtime(driver.clone()).await; + let sandbox = sandbox_record( + "sb-effective-ui", + "effective-ui-policy", + SandboxPhase::Provisioning, + ); + let runtime_inputs = SandboxCreateRuntimeInputs::new( + PublicSandboxPolicy { + ui: Some(UiPolicy::default()), + ..Default::default() + }, + None, + ); + + let error = runtime + .validate_sandbox_create_with_runtime_inputs(&sandbox, &runtime_inputs) + .await + .expect_err("an unsupported driver must reject the effective UI policy"); + + assert_eq!(error.code(), Code::InvalidArgument); + assert_eq!(driver.validate_create_calls.load(Ordering::Relaxed), 0); + } + #[tokio::test] async fn explicit_ui_policy_reaches_driver_when_capability_is_complete() { let driver = Arc::new(TestDriver::default()); @@ -6245,6 +6424,97 @@ mod tests { assert_eq!(driver.validate_create_calls.load(Ordering::Relaxed), 1); } + #[tokio::test] + async fn create_runtime_inputs_reach_driver_without_persisting_effective_policy_or_secrets() { + let sink: SandboxProviderCredentialsSink = Arc::new(TestMutex::new(HashMap::new())); + let driver = Arc::new(TestDriver { + provider_credentials_sink: Some(sink.clone()), + ..Default::default() + }); + let mut runtime = test_runtime(driver.clone()).await; + runtime.provider_credentials_sink = Some(sink.clone()); + + let mut sandbox = sandbox_record( + "sb-provider-inputs", + "provider-inputs", + SandboxPhase::Provisioning, + ); + sandbox.spec = Some(SandboxSpec { + policy: Some(PublicSandboxPolicy { + version: 1, + ..Default::default() + }), + ..Default::default() + }); + let provider_credentials = ProviderCredentialState::from_environment( + 17, + HashMap::from([("GITHUB_TOKEN".to_string(), "raw-test-token".to_string())]), + HashMap::new(), + HashMap::new(), + ); + let runtime_inputs = SandboxCreateRuntimeInputs::new( + PublicSandboxPolicy { + version: 2, + ..Default::default() + }, + Some(provider_credentials), + ); + + runtime + .create_sandbox_with_runtime_inputs(sandbox, None, false, runtime_inputs) + .await + .expect("create should succeed"); + + let observed_env = driver + .provider_env_at_create + .lock() + .expect("provider env observation lock poisoned") + .clone() + .expect("driver should observe staged provider credentials"); + let observed_token = &observed_env["GITHUB_TOKEN"]; + assert_ne!(observed_token, "raw-test-token"); + assert!(observed_token.starts_with(openshell_core::secrets::PLACEHOLDER_PREFIX_PUBLIC)); + assert!( + sink.lock() + .expect("provider credential staging lock poisoned") + .is_empty(), + "create-time credentials must be removed after the driver call" + ); + + let observed_policy_version = { + let created = driver + .created_sandboxes + .lock() + .expect("created sandbox observation lock poisoned"); + created[0] + .spec + .as_ref() + .and_then(|spec| spec.policy.as_ref()) + .map(|policy| policy.version) + }; + assert_eq!( + observed_policy_version, + Some(2), + "the driver must receive the effective policy" + ); + + let persisted = runtime + .store + .get_message::("sb-provider-inputs") + .await + .expect("persisted sandbox lookup should succeed") + .expect("sandbox should be persisted"); + assert_eq!( + persisted + .spec + .as_ref() + .and_then(|spec| spec.policy.as_ref()) + .map(|policy| policy.version), + Some(1), + "the public sandbox must retain its base policy" + ); + } + async fn test_runtime_with_gateway_managed_lifecycle( driver: SharedComputeDriver, driver_name: &str, @@ -11634,6 +11904,7 @@ mod tests { "test-driver".to_string(), Arc::new(TestDriver::default()), None, + None, store, SandboxIndex::new(), SandboxWatchBus::new(), diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index 2707eef005..8a79f6712d 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -1746,20 +1746,40 @@ async fn current_effective_policy_for_sandbox( .as_ref() .map(|spec| spec.providers.clone()) .unwrap_or_default(); + let provider_records = super::provider::load_provider_environment_records( + state.store.as_ref(), + workspace, + &provider_names, + ) + .await?; + current_effective_policy_for_sandbox_with_records( + state, + catalog, + sandbox, + sandbox_id, + &provider_records, + ) + .await +} + +async fn current_effective_policy_for_sandbox_with_records( + state: &ServerState, + catalog: &EffectiveProviderProfileCatalog, + sandbox: &Sandbox, + sandbox_id: &str, + provider_records: &[super::provider::ProviderEnvironmentRecord], +) -> Result { let global_settings = load_global_settings(state.store.as_ref()).await?; if let Some(mut global_policy) = decode_policy_from_global_settings(&global_settings)? { // A global policy replaces dynamic policy, but startup-only UI remains // anchored to the sandbox spec so reads cannot misrepresent enforcement. preserve_sandbox_startup_ui(&mut global_policy, sandbox); - return apply_effective_policy_context( - state, + return apply_effective_policy_context_from_records( catalog, - workspace, - &provider_names, + provider_records, global_policy, PolicySource::Global, - ) - .await; + ); } let policy = if let Some(record) = state @@ -1778,15 +1798,12 @@ async fn current_effective_policy_for_sandbox( } }; - apply_effective_policy_context( - state, + apply_effective_policy_context_from_records( catalog, - workspace, - &provider_names, + provider_records, policy, PolicySource::Sandbox, ) - .await } async fn effective_policy_for_source( @@ -1821,17 +1838,26 @@ async fn apply_effective_policy_context( catalog: &EffectiveProviderProfileCatalog, workspace: &str, provider_names: &[String], - mut policy: ProtoSandboxPolicy, + policy: ProtoSandboxPolicy, policy_source: PolicySource, ) -> Result { - clear_provider_credentialed_markers(&mut policy); - let mut provider_context = provider_policy_context_with_catalog( + let provider_records = super::provider::load_provider_environment_records( state.store.as_ref(), - catalog, workspace, provider_names, ) .await?; + apply_effective_policy_context_from_records(catalog, &provider_records, policy, policy_source) +} + +fn apply_effective_policy_context_from_records( + catalog: &EffectiveProviderProfileCatalog, + provider_records: &[super::provider::ProviderEnvironmentRecord], + mut policy: ProtoSandboxPolicy, + policy_source: PolicySource, +) -> Result { + clear_provider_credentialed_markers(&mut policy); + let mut provider_context = provider_policy_context_from_records(catalog, provider_records); if !matches!(policy_source, PolicySource::Global) && !provider_context.layers.is_empty() { policy = compose_effective_policy(&policy, &provider_context.layers); } @@ -3006,16 +3032,23 @@ async fn provider_policy_context_with_catalog( workspace: &str, provider_names: &[String], ) -> Result { + let records = + super::provider::load_provider_environment_records(store, workspace, provider_names) + .await?; + Ok(provider_policy_context_from_records(catalog, &records)) +} + +fn provider_policy_context_from_records( + catalog: &EffectiveProviderProfileCatalog, + records: &[super::provider::ProviderEnvironmentRecord], +) -> ProviderPolicyContext { let mut layers = Vec::new(); let mut credentialed_scopes = Vec::new(); let mut endpointless_provider_names = HashSet::new(); - for name in provider_names { - let provider = store - .get_message_by_name::(workspace, name) - .await - .map_err(|e| Status::internal(format!("failed to fetch provider '{name}': {e}")))? - .ok_or_else(|| Status::failed_precondition(format!("provider '{name}' not found")))?; + for record in records { + let name = &record.name; + let provider = &record.provider; let provider_type = provider.r#type.trim(); let Some(profile) = super::provider::get_provider_type_profile_for_scope( @@ -3031,7 +3064,7 @@ async fn provider_policy_context_with_catalog( continue; }; - if !super::provider::provider_profile_endpoints_are_active(&profile, &provider) { + if !super::provider::provider_profile_endpoints_are_active(&profile, provider) { endpointless_provider_names.insert(name.clone()); continue; } @@ -3060,11 +3093,11 @@ async fn provider_policy_context_with_catalog( }); } - Ok(ProviderPolicyContext { + ProviderPolicyContext { layers, credentialed_scopes, endpointless_provider_names, - }) + } } fn endpoint_ports(endpoint: &NetworkEndpoint) -> Vec { @@ -3245,6 +3278,125 @@ pub(super) async fn handle_get_gateway_config( })) } +/// Resolve the effective policy and provider credential snapshot required by +/// an in-process compute driver at sandbox creation time. +/// +/// The policy, revision, endpoint bindings, and environment are all derived +/// from one immutable provider-record snapshot. Raw static credentials remain +/// in the returned resolver state; only revision-scoped placeholders are +/// exposed through its child environment. +pub(super) async fn resolve_sandbox_create_runtime_inputs( + state: &ServerState, + sandbox: &Sandbox, +) -> Result { + if !state.compute.accepts_create_time_provider_credentials() { + return Ok(crate::compute::SandboxCreateRuntimeInputs::default()); + } + + let sandbox_id = sandbox.object_id(); + let workspace = sandbox.object_workspace(); + let provider_names = sandbox + .spec + .as_ref() + .map(|spec| spec.providers.as_slice()) + .unwrap_or_default(); + let provider_profile_catalog = state + .provider_profile_sources + .snapshot_catalog(state.store.as_ref(), workspace) + .await?; + let provider_records = super::provider::load_provider_environment_records( + state.store.as_ref(), + workspace, + provider_names, + ) + .await?; + let effective_policy = current_effective_policy_for_sandbox_with_records( + state, + &provider_profile_catalog, + sandbox, + sandbox_id, + &provider_records, + ) + .await?; + let policy_credential_bindings = + policy_static_credential_endpoint_bindings(Some(&effective_policy))?; + validate_policy_credential_binding_context( + &provider_profile_catalog, + &provider_records, + &effective_policy, + &policy_credential_bindings, + )?; + let provider_env_revision = compute_provider_env_revision_from_records_and_policy_bindings( + &provider_profile_catalog, + &provider_records, + &policy_credential_bindings, + )?; + let mut provider_environment = + super::provider::resolve_provider_environment_from_records_with_policy_bindings_and_credentials( + state.store.as_ref(), + &provider_profile_catalog, + &provider_records, + &policy_credential_bindings, + &state.credentials, + Some(sandbox_id), + ) + .await?; + + // MXC uses the binding-capable host proxy. Withhold any static value that + // has no endpoint binding instead of exposing it directly to the process. + let unbound_static_keys = provider_environment + .static_credential_keys + .iter() + .filter(|key| { + !provider_environment + .static_credential_bindings + .contains_key(*key) + }) + .cloned() + .collect::>(); + for key in unbound_static_keys { + warn!( + sandbox_id, + key = %key, + "withholding unbound static provider credential from MXC sandbox" + ); + provider_environment.environment.remove(&key); + provider_environment.credential_expires_at_ms.remove(&key); + provider_environment.static_credential_keys.remove(&key); + } + + let provider_credentials = if provider_records.is_empty() { + None + } else { + let non_secret_environment_keys = provider_environment + .environment + .keys() + .filter(|key| !provider_environment.static_credential_keys.contains(*key)) + .cloned() + .collect(); + Some( + openshell_core::provider_credentials::ProviderCredentialState::from_bound_environment( + provider_env_revision, + provider_environment.environment, + provider_environment.credential_expires_at_ms, + provider_environment.dynamic_credentials, + provider_environment.static_credential_bindings, + non_secret_environment_keys, + ) + .map_err(|error| { + Status::failed_precondition(format!( + "invalid provider credential binding for sandbox '{sandbox_id}': {error}" + )) + })?, + ) + }; + + Ok(crate::compute::SandboxCreateRuntimeInputs::new( + effective_policy, + provider_credentials, + )) +} + pub(super) async fn handle_get_sandbox_provider_environment( state: &Arc, request: Request, @@ -3278,12 +3430,12 @@ pub(super) async fn handle_get_sandbox_provider_environment( &provider_names, ) .await?; - let effective_policy = current_effective_policy_for_sandbox( + let effective_policy = current_effective_policy_for_sandbox_with_records( state.as_ref(), &provider_profile_catalog, - &workspace, &sandbox, &sandbox_id, + &provider_records, ) .await?; let policy_credential_bindings = diff --git a/crates/openshell-server/src/grpc/sandbox.rs b/crates/openshell-server/src/grpc/sandbox.rs index 429a756c7c..16eeb58de8 100644 --- a/crates/openshell-server/src/grpc/sandbox.rs +++ b/crates/openshell-server/src/grpc/sandbox.rs @@ -492,9 +492,12 @@ async fn handle_create_sandbox_inner( ) .await?; + let mut runtime_inputs = + super::policy::resolve_sandbox_create_runtime_inputs(state.as_ref(), &sandbox).await?; + state .compute - .validate_sandbox_create(&sandbox) + .validate_sandbox_create_with_runtime_inputs(&sandbox, &runtime_inputs) .await .map_err(|status| { warn!(error = %status, "Rejecting sandbox create request"); @@ -530,14 +533,15 @@ async fn handle_create_sandbox_inner( .map_err(|error| Status::internal(format!("encode launch authentication: {error}"))) }) .transpose()?; + runtime_inputs.launch_authentication = launch_authentication; let sandbox = state .compute - .create_sandbox_authenticated( + .create_sandbox_with_runtime_inputs( sandbox, sandbox_token, - launch_authentication, await_main_process_attachment, + runtime_inputs, ) .await?; diff --git a/crates/openshell-server/src/lib.rs b/crates/openshell-server/src/lib.rs index d68c93c6b6..6a7cb1daf9 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -1098,13 +1098,19 @@ async fn terminate_signal() { } pub use compute::{ - AcquiredRemoteDriverEndpoint, DriverWatchStream, ManagedDriverProcess, SharedComputeDriver, + AcquiredRemoteDriverEndpoint, DriverWatchStream, ManagedDriverProcess, + SandboxProviderCredentialsSink, SharedComputeDriver, }; /// Driver instance returned by a compiled compute-driver factory. pub enum ComputeDriverInstance { /// A driver hosted in the gateway process. InProcess(SharedComputeDriver), + /// An in-process driver with a create-time provider credential side channel. + InProcessWithProviderCredentials { + driver: SharedComputeDriver, + provider_credentials_sink: SandboxProviderCredentialsSink, + }, /// A driver process launched and owned by the gateway. ManagedRemote(AcquiredRemoteDriverEndpoint), } @@ -1549,6 +1555,25 @@ async fn build_compute_runtime( 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, + } => ComputeRuntime::from_driver( + registration.name, + driver, + None, + Some(provider_credentials_sink), store, sandbox_index, sandbox_watch_bus, From c9beb07f19ad60ef839d4b7e6d482a9688ade33d Mon Sep 17 00:00:00 2001 From: Akber Raza Date: Thu, 10 Sep 2026 17:24:35 -0500 Subject: [PATCH 06/26] docs(mxc): scope isolation session prerequisites Clarify that the provider credential example uses process_container and does not require the isolation_session DLL or build feature. Signed-off-by: Akber Raza --- crates/openshell-driver-mxc/README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/openshell-driver-mxc/README.md b/crates/openshell-driver-mxc/README.md index d3a7a78c52..9605d68439 100644 --- a/crates/openshell-driver-mxc/README.md +++ b/crates/openshell-driver-mxc/README.md @@ -179,6 +179,10 @@ 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. + ## Packaging the demo for the demo box Use [`examples/package-demo.ps1`](examples/package-demo.ps1) to assemble From 621443bd1cd0eb196c2b70c5cee8ae703497c058 Mon Sep 17 00:00:00 2001 From: Akber Raza Date: Thu, 10 Sep 2026 17:25:21 -0500 Subject: [PATCH 07/26] test(mxc): fail closed on artifact scan errors Mark the credential scenario failed and suppress its ZIP bundle when any result artifact cannot be inspected for raw-token leakage. Signed-off-by: Akber Raza --- .../examples/run-provider-credential-test.ps1 | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/crates/openshell-driver-mxc/examples/run-provider-credential-test.ps1 b/crates/openshell-driver-mxc/examples/run-provider-credential-test.ps1 index 56aff38dd6..63be61b9f3 100644 --- a/crates/openshell-driver-mxc/examples/run-provider-credential-test.ps1 +++ b/crates/openshell-driver-mxc/examples/run-provider-credential-test.ps1 @@ -132,6 +132,7 @@ $sandboxName = "mxc-gh-$(Get-Date -Format 'MMddHHmmss')" $providerName = "mxc-github-e2e" $passed = $false $rawTokenLeak = $false +$artifactScanFailed = $false $githubToken = $env:GITHUB_TOKEN try { @@ -308,13 +309,18 @@ finally { # 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($_.FullName, [System.Text.Encoding]::UTF8) + $contents = [System.IO.File]::ReadAllText($artifact.FullName, [System.Text.Encoding]::UTF8) if ($contents.Contains($githubToken)) { $rawTokenLeak = $true - [System.IO.File]::WriteAllText($_.FullName, $contents.Replace($githubToken, "***REDACTED***"), $utf8NoBom) + [System.IO.File]::WriteAllText($artifact.FullName, $contents.Replace($githubToken, "***REDACTED***"), $utf8NoBom) } - } catch {} + } catch { + $artifactScanFailed = $true + $passed = $false + Bad "could not inspect result artifact $($artifact.FullName): $($_.Exception.GetType().Name)" + } } } if ($rawTokenLeak) { @@ -341,11 +347,15 @@ PASS proves: [System.IO.File]::WriteAllText((Join-Path $resultDir "summary.txt"), $summary, $utf8NoBom) Write-Host "`n$summary" -ForegroundColor ($(if ($passed) { "Green" } else { "Red" })) - 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 ($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 } From 0815ec87d77f0cdfd92d1f96e6d28ef3fbd72ffe Mon Sep 17 00:00:00 2001 From: Akber Raza Date: Thu, 10 Sep 2026 17:25:55 -0500 Subject: [PATCH 08/26] test(mxc): redact probe output before logging Detect and redact a raw provider token before the credential probe result can reach console or CI logs. Signed-off-by: Akber Raza --- .../examples/run-provider-credential-test.ps1 | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/openshell-driver-mxc/examples/run-provider-credential-test.ps1 b/crates/openshell-driver-mxc/examples/run-provider-credential-test.ps1 index 63be61b9f3..1201b4cab5 100644 --- a/crates/openshell-driver-mxc/examples/run-provider-credential-test.ps1 +++ b/crates/openshell-driver-mxc/examples/run-provider-credential-test.ps1 @@ -275,6 +275,11 @@ try { 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" From f6f6ed58368a1d25e5472127fd322a74649255e5 Mon Sep 17 00:00:00 2001 From: Akber Raza Date: Thu, 10 Sep 2026 17:27:32 -0500 Subject: [PATCH 09/26] fix(policy): reuse provider snapshot for merge validation Derive provider layers, credential scopes, and binding records from the same loaded provider snapshot to eliminate cross-read inconsistency. Signed-off-by: Akber Raza --- crates/openshell-server/src/grpc/policy.rs | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index 8a79f6712d..9bc66ad965 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -6746,17 +6746,17 @@ async fn sandbox_policy_merge_validation_data_with_catalog( ) -> Result { let global_settings = load_global_settings(state.store.as_ref()).await?; let composition_enabled = provider_policy_composition_enabled_in(&global_settings)?; - let ProviderPolicyContext { - layers, - credentialed_scopes, - endpointless_provider_names, - } = provider_policy_context_with_catalog( + let records = super::provider::load_provider_environment_records( state.store.as_ref(), - catalog, workspace, provider_names, ) .await?; + let ProviderPolicyContext { + layers, + credentialed_scopes, + endpointless_provider_names, + } = provider_policy_context_from_records(catalog, &records); let provider_layers = if composition_enabled { layers } else { @@ -6767,12 +6767,6 @@ async fn sandbox_policy_merge_validation_data_with_catalog( provider_layer_count = provider_layers.len(), "Composed provider policy and credential context for merge validation" ); - let records = super::provider::load_provider_environment_records( - state.store.as_ref(), - workspace, - provider_names, - ) - .await?; Ok(SandboxPolicyMergeValidationData { provider_layers, catalog: catalog.clone(), From f39b31407dd577ddc9bca98f23e1f906dbc72227 Mon Sep 17 00:00:00 2001 From: Akber Raza Date: Thu, 10 Sep 2026 17:29:52 -0500 Subject: [PATCH 10/26] fix(auth): authorize provider environment access Require workspace authorization before returning sandbox provider environment or credential binding data, while preserving sandbox-principal scope checks and not-found concealment. Signed-off-by: Akber Raza --- crates/openshell-server/src/grpc/policy.rs | 26 ++++++++++++++++------ 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index 9bc66ad965..f5df87003e 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -3403,15 +3403,11 @@ pub(super) async fn handle_get_sandbox_provider_environment( ) -> Result, Status> { let sandbox_id = request.get_ref().sandbox_id.clone(); let supports_static_credential_bindings = request.get_ref().supports_static_credential_bindings; - crate::auth::guard::enforce_sandbox_scope(&request, &sandbox_id)?; + let principal = crate::auth::guard::enforce_sandbox_scope(&request, &sandbox_id)?; drop(request); - let sandbox = state - .store - .get_message::(&sandbox_id) - .await - .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? - .ok_or_else(|| Status::not_found("sandbox not found"))?; + let sandbox = + super::sandbox::fetch_and_authorize_sandbox(state, &principal, &sandbox_id).await?; let workspace = sandbox.object_workspace().to_string(); let spec = sandbox @@ -21593,6 +21589,22 @@ mod tests { "handle_get_sandbox_config must return NotFound, not PermissionDenied" ); + // --- handle_get_sandbox_provider_environment --- + let err = handle_get_sandbox_provider_environment( + &state, + non_member_request(GetSandboxProviderEnvironmentRequest { + sandbox_id: "sandbox-other".into(), + supports_static_credential_bindings: true, + }), + ) + .await + .unwrap_err(); + assert_eq!( + err.code(), + Code::NotFound, + "handle_get_sandbox_provider_environment must hide cross-workspace sandboxes" + ); + // --- handle_get_sandbox_logs --- let err = handle_get_sandbox_logs( &state, From 45494a52109c4215655ab11390cbf6c5b3a8269e Mon Sep 17 00:00:00 2001 From: Akber Raza Date: Thu, 10 Sep 2026 17:32:29 -0500 Subject: [PATCH 11/26] fix(mxc): reject expiring credential snapshots Fail sandbox creation for expiring static provider credentials because MXC has no live refresh channel, while retaining request-time dynamic token grants and documenting recreation requirements. Signed-off-by: Akber Raza --- crates/openshell-driver-mxc/README.md | 2 + crates/openshell-server/src/grpc/policy.rs | 64 ++++++++++++++++++++++ docs/reference/gateway-config.mdx | 2 +- 3 files changed, 67 insertions(+), 1 deletion(-) diff --git a/crates/openshell-driver-mxc/README.md b/crates/openshell-driver-mxc/README.md index 9605d68439..1f60809067 100644 --- a/crates/openshell-driver-mxc/README.md +++ b/crates/openshell-driver-mxc/README.md @@ -109,6 +109,8 @@ 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. +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 diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index f5df87003e..9c39b1d11d 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -3364,6 +3364,7 @@ pub(super) async fn resolve_sandbox_create_runtime_inputs( provider_environment.credential_expires_at_ms.remove(&key); provider_environment.static_credential_keys.remove(&key); } + validate_create_time_provider_credential_lifetimes(sandbox_id, &provider_environment)?; let provider_credentials = if provider_records.is_empty() { None @@ -3397,6 +3398,33 @@ pub(super) async fn resolve_sandbox_create_runtime_inputs( )) } +fn validate_create_time_provider_credential_lifetimes( + sandbox_id: &str, + provider_environment: &super::provider::ProviderEnvironment, +) -> Result<(), Status> { + let mut expiring_static_keys = provider_environment + .static_credential_keys + .iter() + .filter(|key| { + provider_environment + .credential_expires_at_ms + .get(*key) + .is_some_and(|expires_at_ms| *expires_at_ms > 0) + }) + .cloned() + .collect::>(); + expiring_static_keys.sort(); + + if expiring_static_keys.is_empty() { + Ok(()) + } else { + Err(Status::failed_precondition(format!( + "compute driver cannot refresh expiring provider credentials for running sandbox '{sandbox_id}'; recreate the sandbox with non-expiring credentials (expiring keys: {})", + expiring_static_keys.join(", ") + ))) + } +} + pub(super) async fn handle_get_sandbox_provider_environment( state: &Arc, request: Request, @@ -7373,6 +7401,7 @@ mod tests { use crate::auth::principal::{ Principal, SandboxIdentitySource, SandboxPrincipal, UserPrincipal, }; + use crate::grpc::provider::ProviderEnvironment; use crate::grpc::test_support::{authed_request, test_server_state}; use crate::persistence::test_store; use std::collections::HashMap; @@ -11960,6 +11989,41 @@ mod tests { assert_eq!(v2_env.get("GITHUB_TOKEN"), Some(&"ghp-test".to_string())); } + #[test] + fn create_time_provider_credentials_reject_expiring_static_values() { + let provider_environment = ProviderEnvironment { + credential_expires_at_ms: HashMap::from([ + ("B_TOKEN".to_string(), 20_000), + ("A_TOKEN".to_string(), 10_000), + ("NON_SECRET".to_string(), 30_000), + ]), + static_credential_keys: HashSet::from(["A_TOKEN".to_string(), "B_TOKEN".to_string()]), + ..Default::default() + }; + + let error = validate_create_time_provider_credential_lifetimes( + "sandbox-expiring", + &provider_environment, + ) + .expect_err("expiring static credentials must fail closed"); + + assert_eq!(error.code(), Code::FailedPrecondition); + assert!(error.message().contains("A_TOKEN, B_TOKEN")); + assert!(!error.message().contains("NON_SECRET")); + } + + #[test] + fn create_time_provider_credentials_allow_non_expiring_static_values() { + let provider_environment = ProviderEnvironment { + credential_expires_at_ms: HashMap::from([("STATIC_TOKEN".to_string(), 0)]), + static_credential_keys: HashSet::from(["STATIC_TOKEN".to_string()]), + ..Default::default() + }; + + validate_create_time_provider_credential_lifetimes("sandbox-static", &provider_environment) + .expect("non-expiring static credentials are supported"); + } + #[tokio::test] async fn provider_environment_withholds_static_credentials_from_legacy_supervisors() { use openshell_core::proto::GetSandboxProviderEnvironmentRequest; diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index b61ba7bdf3..8d3d2a7f68 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -888,7 +888,7 @@ OpenShell sends no override and Podman applies its runtime-selected profile. The setting applies to the workload container; the supervisor retains Podman's runtime-selected profile. -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 `agent_env` entries case-insensitively. Sandbox creation fails when attached provider material exists but `egress_proxy` is disabled. MXC takes its provider snapshot at creation; recreate the sandbox after attaching, detaching, or rotating a provider. +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. ### MicroVM From 0e8ff34fc1795a9f5e687d6be6ef5fef33f21a59 Mon Sep 17 00:00:00 2001 From: Akber Raza Date: Thu, 10 Sep 2026 17:34:06 -0500 Subject: [PATCH 12/26] docs(mxc): define the host proxy trust boundary Clarify that per-sandbox ports provide routing separation rather than peer authentication, place host-local processes in the trusted computing base, and retain M3 as the stronger source-attribution dependency. Signed-off-by: Akber Raza --- crates/openshell-driver-mxc/README.md | 2 ++ docs/reference/gateway-config.mdx | 2 ++ 2 files changed, 4 insertions(+) diff --git a/crates/openshell-driver-mxc/README.md b/crates/openshell-driver-mxc/README.md index 1f60809067..2335bb7f31 100644 --- a/crates/openshell-driver-mxc/README.md +++ b/crates/openshell-driver-mxc/README.md @@ -109,6 +109,8 @@ 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 listener port through its MXC redirect, but the listener does not authenticate its TCP peer. Processes that can connect directly to gateway-owned loopback ports are part of the trusted Windows host boundary. MXC feedback item M3 tracks stable source attribution for deployments that need a mutually untrusted shared-host boundary. + 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) diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index 8d3d2a7f68..a8db2d4d51 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -890,6 +890,8 @@ runtime-selected profile. 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 to each MXC sandbox, but the listener does not authenticate its TCP peer. Treat Windows processes that can connect directly to gateway-owned loopback ports as part of the trusted host boundary. + ### MicroVM Each sandbox runs inside its own libkrun microVM managed by the standalone `openshell-driver-vm` subprocess. Use this driver when you want stronger isolation than container namespaces alone. From 30e2d78cbf3160c0c0612acbc0a812126a7d6e75 Mon Sep 17 00:00:00 2001 From: Akber Raza Date: Fri, 11 Sep 2026 21:53:29 -0500 Subject: [PATCH 13/26] fix(mxc): harden provider credential test runner - preserve native JSON arguments under Windows PowerShell - tolerate existing gateway registrations - reject network executable paths with actionable launch diagnostics - record redacted failure context in result bundles Signed-off-by: Akber Raza --- .../examples/run-provider-credential-test.ps1 | 147 ++++++++++++++---- 1 file changed, 121 insertions(+), 26 deletions(-) diff --git a/crates/openshell-driver-mxc/examples/run-provider-credential-test.ps1 b/crates/openshell-driver-mxc/examples/run-provider-credential-test.ps1 index 1201b4cab5..daffcbe424 100644 --- a/crates/openshell-driver-mxc/examples/run-provider-credential-test.ps1 +++ b/crates/openshell-driver-mxc/examples/run-provider-credential-test.ps1 @@ -12,9 +12,12 @@ # $env:GITHUB_TOKEN = "github_pat_..." # mise run --skip-tools windows:build:x64 # -# Run from a demo-package folder containing openshell-gateway.exe, openshell.exe, -# the PowerShell probe, and the three configuration fixtures beside this script, -# or pass explicit gateway and CLI paths: +# 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 ` @@ -45,7 +48,10 @@ $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) { Write-Host "`n=== $message ===" -ForegroundColor Cyan } +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 } @@ -59,25 +65,95 @@ function Resolve-Artifact([string]$explicit, [string]$leaf) { function Escape-Toml([string]$value) { return $value.Replace('\', '\\') } -function Invoke-Cli([string[]]$CommandArgs, [switch]$AllowFailure) { - $previous = $ErrorActionPreference - $ErrorActionPreference = "Continue" - try { - $lines = & $cli @CommandArgs 2>&1 - $exitCode = $LASTEXITCODE - } finally { - $ErrorActionPreference = $previous +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 } - # Windows PowerShell wraps native stderr lines in ErrorRecord objects and - # Out-String adds a misleading "At ... NativeCommandError" block even when - # the command succeeds. Preserve the message without that decoration. - $text = (($lines | ForEach-Object { - if ($_ -is [System.Management.Automation.ErrorRecord]) { - $_.Exception.Message + $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 { - $_.ToString() + if ($backslashes -gt 0) { [void]$quoted.Append(('\' * $backslashes)) } + [void]$quoted.Append($ch) } - }) -join [Environment]::NewLine).Trim() + $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" } @@ -133,6 +209,8 @@ $providerName = "mxc-github-e2e" $passed = $false $rawTokenLeak = $false $artifactScanFailed = $false +$failureReason = "" +$failureStage = "initialization" $githubToken = $env:GITHUB_TOKEN try { @@ -144,6 +222,8 @@ try { 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" } @@ -222,10 +302,14 @@ try { # a successful test cannot be attributed to gateway environment inheritance. Remove-Item Env:GITHUB_TOKEN -ErrorAction SilentlyContinue try { - $gw = Start-Process -FilePath $gateway ` - -ArgumentList @("--disable-tls", "--db-url", "sqlite::memory:", "--log-level", "info") ` - -WorkingDirectory $here -PassThru -NoNewWindow ` - -RedirectStandardOutput $gwLog -RedirectStandardError $gwErrLog + 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 } @@ -242,7 +326,12 @@ try { Step "Configure provider and effective policy" $env:OPENSHELL_GATEWAY = "" - Invoke-Cli @("gateway", "add", "http://127.0.0.1:$Port", "--local", "--name", $GatewayName) | Out-Null + $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 @@ -294,7 +383,11 @@ try { Ok "placeholder isolation, authorized rewrite, and endpoint mismatch all passed" } catch { - Bad $_.Exception.Message + $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) { @@ -342,6 +435,8 @@ 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. From 9deb7e6f7b5f4c89554cf56ae7fe976be2ee9c4f Mon Sep 17 00:00:00 2001 From: Prashant S Khodade Date: Mon, 14 Sep 2026 23:23:49 -0700 Subject: [PATCH 14/26] fix(mxc): address PR #3296 review findings on credential lifetime and env keys Address the three blocking findings from the automated review of NVIDIA/OpenShell#3296 (head 550b81af): - Already-expired static provider credentials were silently withheld by the shared resolver before the create-time fail-closed check ran, so an MXC sandbox could be created without the configured credential instead of rejecting the request. Track withheld expired keys separately in ProviderEnvironment so the MXC create-time validator can still reject them. - Provider credential environment keys that collided case-insensitively with each other, or with the reserved TLS trust env vars injected later, were not validated before staging, producing an ambiguous or silently overwritten sandbox environment on Windows. Reject these synchronously at CreateSandbox instead. - The Global Policy Override docs said the global payload only supplies dynamic policy fields, but the implementation replaces the complete effective policy except UI. Corrected the description. Signed-off-by: Prashant S Khodade --- crates/openshell-driver-mxc/src/driver.rs | 91 ++++++++++++++++++++ crates/openshell-server/src/grpc/policy.rs | 27 +++++- crates/openshell-server/src/grpc/provider.rs | 10 +++ docs/sandboxes/policies.mdx | 5 +- 4 files changed, 131 insertions(+), 2 deletions(-) diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index 188761da43..3aca207950 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -321,6 +321,50 @@ fn append_provider_child_env( ); } +/// Rejects provider credential environment keys that would collide once +/// injected into the sandbox process, before any staging or launch happens. +/// +/// Windows environment variables are case-insensitive, so two provider keys +/// that differ only by case (or shadow one of the reserved TLS trust keys +/// `append_tls_env_vars` injects later) would otherwise merge or get silently +/// overwritten with no diagnostic, leaving the sandbox with an ambiguous, +/// wrong, or missing credential. +fn validate_provider_child_env_keys( + provider_credentials: Option<&ProviderCredentialState>, +) -> Result<(), tonic::Status> { + let Some(provider_credentials) = provider_credentials else { + return Ok(()); + }; + let mut seen: HashMap = HashMap::new(); + let mut collisions: Vec = Vec::new(); + let mut keys = provider_credentials + .child_env_with_gcp_resolved() + .into_keys() + .collect::>(); + keys.sort_unstable(); + for key in keys { + let folded = key.to_ascii_uppercase(); + if TLS_ENV_KEYS.iter().any(|reserved| folded == *reserved) { + collisions.push(format!("{key} (reserved for TLS trust configuration)")); + continue; + } + if let Some(existing) = seen.insert(folded, key.clone()) + && existing != key + { + collisions.push(format!("{key} (collides with {existing})")); + } + } + if collisions.is_empty() { + Ok(()) + } else { + Err(tonic::Status::failed_precondition(format!( + "provider credential environment keys are ambiguous on Windows (case-insensitive) \ + or reserved: {}", + collisions.join(", ") + ))) + } +} + fn configured_egress_addr(config: &MxcComputeConfig) -> Result, tonic::Status> { if !config.egress_proxy { return Ok(None); @@ -591,6 +635,7 @@ impl MxcComputeBackend { .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .remove(&sandbox_id); + validate_provider_child_env_keys(provider_credentials.as_ref())?; Self::validate_sandbox_fields(sandbox)?; let sandbox_config = sandbox_config(sandbox)?; @@ -1485,6 +1530,52 @@ mod lifecycle_tests { assert!(!env.iter().any(|entry| entry.contains("raw-test-token"))); } + #[test] + fn provider_child_env_keys_reject_case_insensitive_collision() { + let credentials = ProviderCredentialState::from_bound_environment( + 1, + HashMap::from([ + ("github_token".to_string(), "a".to_string()), + ("GITHUB_TOKEN".to_string(), "b".to_string()), + ]), + HashMap::new(), + HashMap::new(), + HashMap::new(), + vec!["github_token".to_string(), "GITHUB_TOKEN".to_string()], + ) + .expect("valid provider credential state"); + + let error = validate_provider_child_env_keys(Some(&credentials)) + .expect_err("case-colliding provider keys must fail closed"); + assert_eq!(error.code(), tonic::Code::FailedPrecondition); + assert!(error.message().contains("collides")); + } + + #[test] + fn provider_child_env_keys_reject_tls_reserved_name() { + let credentials = ProviderCredentialState::from_bound_environment( + 1, + HashMap::from([("SSL_CERT_FILE".to_string(), "not-a-ca-bundle".to_string())]), + HashMap::new(), + HashMap::new(), + HashMap::new(), + vec!["SSL_CERT_FILE".to_string()], + ) + .expect("valid provider credential state"); + + let error = validate_provider_child_env_keys(Some(&credentials)) + .expect_err("TLS-reserved provider keys must fail closed"); + assert_eq!(error.code(), tonic::Code::FailedPrecondition); + assert!(error.message().contains("reserved for TLS")); + } + + #[test] + fn provider_child_env_keys_allow_distinct_names() { + let credentials = github_provider_credentials(); + validate_provider_child_env_keys(Some(&credentials)) + .expect("non-colliding, non-reserved provider keys are allowed"); + } + #[tokio::test] async fn provider_state_requiring_resolution_fails_closed_without_governed_egress() { let backend = MxcComputeBackend::new_mocked(MxcComputeConfig::default()); diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index 9c39b1d11d..a178eb074e 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -3411,7 +3411,10 @@ fn validate_create_time_provider_credential_lifetimes( .get(*key) .is_some_and(|expires_at_ms| *expires_at_ms > 0) }) + .chain(provider_environment.expired_static_keys.iter()) .cloned() + .collect::>() + .into_iter() .collect::>(); expiring_static_keys.sort(); @@ -3419,7 +3422,7 @@ fn validate_create_time_provider_credential_lifetimes( Ok(()) } else { Err(Status::failed_precondition(format!( - "compute driver cannot refresh expiring provider credentials for running sandbox '{sandbox_id}'; recreate the sandbox with non-expiring credentials (expiring keys: {})", + "compute driver cannot refresh expiring or already-expired provider credentials for sandbox '{sandbox_id}'; recreate the sandbox with non-expiring, current credentials (affected keys: {})", expiring_static_keys.join(", ") ))) } @@ -12024,6 +12027,28 @@ mod tests { .expect("non-expiring static credentials are supported"); } + #[test] + fn create_time_provider_credentials_reject_already_expired_static_values() { + // The shared resolver withholds already-expired static credentials + // entirely -- they never appear in `static_credential_keys` or + // `credential_expires_at_ms` -- so this check must consult + // `expired_static_keys` independently instead of silently allowing + // sandbox creation without the configured credential. + let provider_environment = ProviderEnvironment { + expired_static_keys: HashSet::from(["GITHUB_TOKEN".to_string()]), + ..Default::default() + }; + + let error = validate_create_time_provider_credential_lifetimes( + "sandbox-expired", + &provider_environment, + ) + .expect_err("already-expired static credentials must fail closed"); + + assert_eq!(error.code(), Code::FailedPrecondition); + assert!(error.message().contains("GITHUB_TOKEN")); + } + #[tokio::test] async fn provider_environment_withholds_static_credentials_from_legacy_supervisors() { use openshell_core::proto::GetSandboxProviderEnvironmentRequest; diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index 21e2b3c18c..56ee844093 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -71,6 +71,13 @@ pub(super) struct ProviderEnvironment { pub dynamic_credentials: HashMap, pub static_credential_bindings: HashMap, pub static_credential_keys: HashSet, + /// Static credential keys withheld because they were already expired at + /// resolution time. Excluded from `environment`/`static_credential_keys` + /// like any other withheld key, but tracked separately so create-time + /// callers (see `validate_create_time_provider_credential_lifetimes`) can + /// fail closed instead of silently creating a sandbox without the + /// configured credential. + pub expired_static_keys: HashSet, } /// Immutable provider records used to build one provider-environment response. @@ -1128,6 +1135,7 @@ pub(super) async fn resolve_provider_environment_from_records_with_policy_bindin let mut expires = HashMap::new(); let mut static_credential_bindings = HashMap::new(); let mut static_credential_keys = HashSet::new(); + let mut expired_static_keys = HashSet::new(); let now_ms = crate::persistence::current_time_ms(); validate_provider_environment_records_unique_at(store, catalog, records, now_ms).await?; let registry = openshell_providers::ProviderRegistry::new(); @@ -1248,6 +1256,7 @@ pub(super) async fn resolve_provider_environment_from_records_with_policy_bindin expires_at_ms, "skipping expired provider credential" ); + expired_static_keys.insert(key.clone()); continue; } expires.entry(key.clone()).or_insert(expires_at_ms); @@ -1361,6 +1370,7 @@ pub(super) async fn resolve_provider_environment_from_records_with_policy_bindin dynamic_credentials: resolve_dynamic_credentials_from_records(catalog, records), static_credential_bindings, static_credential_keys, + expired_static_keys, }) } diff --git a/docs/sandboxes/policies.mdx b/docs/sandboxes/policies.mdx index 4ab6a54c52..028724d3b8 100644 --- a/docs/sandboxes/policies.mdx +++ b/docs/sandboxes/policies.mdx @@ -615,7 +615,10 @@ openshell policy set --global --policy ./global-policy.yaml When a global policy is configured: -- The global payload supplies the dynamic policy fields for all sandboxes. +- The global payload replaces every sandbox's complete effective policy, + including static sections such as `filesystem_policy`, `landlock`, and + `process`, not only the dynamic `network_policies` and + `network_middlewares` fields. - Each sandbox keeps the startup-only `ui` section from its own creation policy; a global policy containing `ui` is rejected. - Sandbox-level policy updates are rejected until the global policy is removed. From 6c97108178dca3c1b502121860fab962f9f72345 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 15 Sep 2026 10:44:10 -0700 Subject: [PATCH 15/26] fix(mxc): reject expired handle credentials Signed-off-by: Prekshi Vyas --- crates/openshell-server/src/credentials.rs | 5 ++ crates/openshell-server/src/grpc/provider.rs | 60 ++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/crates/openshell-server/src/credentials.rs b/crates/openshell-server/src/credentials.rs index 6098357f01..1b82ff4b6b 100644 --- a/crates/openshell-server/src/credentials.rs +++ b/crates/openshell-server/src/credentials.rs @@ -107,6 +107,10 @@ pub trait CredentialDriver: std::fmt::Debug + Send + Sync { pub struct ResolvedProviderCredentials { pub values: HashMap, pub expires_at_ms: HashMap, + /// Keys returned by a credential driver whose effective expiration has + /// already passed. Values stay withheld, but create-time consumers need + /// the identities to fail closed instead of silently omitting credentials. + pub expired_keys: HashSet, } #[derive(Debug, Clone, Copy)] @@ -712,6 +716,7 @@ impl CredentialRuntime { effective_expires_at_ms, "skipping expired handle-backed credential" ); + resolved.expired_keys.insert(credential_key); continue; } resolved diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index 56ee844093..2e24b29a0c 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -1354,6 +1354,23 @@ pub(super) async fn resolve_provider_environment_from_records_with_policy_bindin } } + // The credential runtime withholds expired handle-backed values. Keep + // the identity of any otherwise injectable key so MXC create-time + // validation can reject the incomplete credential snapshot. + for key in resolved_refs.expired_keys { + if accepted_stored_credential_keys + .as_ref() + .is_some_and(|accepted| !accepted.contains(&key)) + || is_non_injectable_provider_credential(provider, &key) + || broker_only_credential_keys.contains(&key) + || has_no_usable_endpoint + || !is_valid_env_key(&key) + { + continue; + } + expired_static_keys.insert(key); + } + // Build each provider's emitted environment independently so another // provider's earlier output cannot change how this provider classifies // or populates its own keys. Cross-provider credential/config @@ -10911,6 +10928,49 @@ mod tests { ); } + #[tokio::test] + async fn resolve_provider_env_preserves_expired_handle_key_for_create_time_rejection() { + let store = test_store().await; + let config = openshell_core::Config::new(None).with_credential_drivers(["test-static"]); + let credentials = crate::credentials::CredentialRuntime::from_config(&config).unwrap(); + let catalog = ProviderProfileSources::with_default_sources() + .snapshot_catalog(&store, "default") + .await + .unwrap(); + let mut provider = provider_with_credential_value( + "github-expired", + "github", + "GITHUB_TOKEN", + "github-token", + ); + provider.credential_expires_at_ms.insert( + "GITHUB_TOKEN".to_string(), + crate::persistence::current_time_ms() - 1, + ); + create_provider_record_validating( + &store, + "default", + &catalog, + provider, + Some(&credentials), + ) + .await + .unwrap(); + + let result = resolve_provider_environment_with_credentials( + &store, + &catalog, + "default", + &["github-expired".to_string()], + &credentials, + ) + .await + .unwrap(); + + assert!(!result.contains_key("GITHUB_TOKEN")); + assert!(result.expired_static_keys.contains("GITHUB_TOKEN")); + } + #[tokio::test] async fn resolve_provider_env_skips_expired_credentials_and_returns_expiry_metadata() { let store = test_store().await; From 36e62a8fc3195ef1e45bcbc9972ac2a1f358ff75 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Mon, 14 Sep 2026 14:52:47 -0700 Subject: [PATCH 16/26] feat(mxc): add relay lifecycle and per-sandbox proxy authentication Stack the combined GitLab !98, !105, and !108 port on the provider-credential branch without the later main integration. Preserve prerequisite configuration and protobuf compatibility, and include native Windows validation fixes. Co-authored-by: Prashant S Khodade Signed-off-by: Prekshi Vyas --- .../build-openshell-mxc-windows/SKILL.md | 15 +- AGENTS.md | 1 + Cargo.lock | 15 + architecture/compute-runtimes.md | 14 + architecture/gateway.md | 6 + architecture/sandbox.md | 10 + crates/openshell-cli/src/commands/common.rs | 41 + crates/openshell-cli/src/main.rs | 80 +- crates/openshell-cli/src/run.rs | 79 +- .../sandbox_create_lifecycle_integration.rs | 137 ++ crates/openshell-driver-mxc/Cargo.toml | 9 + crates/openshell-driver-mxc/README.md | 131 +- .../examples/README-openclaw-forward.txt | 141 ++ .../e2e-policies/openclaw-gateway.yaml | 49 + .../examples/e2e-policies/ws-agent.yaml | 42 + .../examples/install-nodejs-openclaw.ps1 | 157 ++ .../examples/mxc-openclaw-gateway.toml | 108 + .../examples/mxc-openclaw-isolation.toml | 70 + .../examples/mxc-openclaw-localnet.toml | 55 + .../examples/mxc-ws-agent.rs | 622 ++++++ .../examples/mxc-ws-gateway.toml | 79 + .../examples/openclaw-capture.mjs | 202 ++ .../examples/run-mxc-e2e.ps1 | 19 +- .../examples/run-openclaw-forward-test.ps1 | 692 +++++++ .../examples/run-ws-agent-test.ps1 | 749 +++++++ .../src/control_channel.rs | 410 ++++ crates/openshell-driver-mxc/src/driver.rs | 1795 ++++++++++++++--- crates/openshell-driver-mxc/src/lib.rs | 10 +- crates/openshell-driver-mxc/src/mxc.rs | 223 +- .../src/policy_map/map.rs | 68 +- .../src/policy_map/mod.rs | 4 +- crates/openshell-driver-mxc/src/relay.rs | 686 +++++++ .../tests/openclaw_appcontainer_compat.rs | 58 + .../tests/openclaw_environment_cleanup.ps1 | 68 + .../tests/policy_mapper_examples.rs | 82 +- .../tests/policy_mapper_matrix.rs | 27 +- .../tests/wxc_exec_real.rs | 12 +- crates/openshell-gateway/src/lib.rs | 25 + crates/openshell-sdk/src/client.rs | 2 + crates/openshell-server/src/compute/mod.rs | 225 ++- crates/openshell-server/src/grpc/sandbox.rs | 155 +- crates/openshell-server/src/lib.rs | 107 +- crates/openshell-server/src/storage_proto.rs | 1 + .../openshell-supervisor-network/src/host.rs | 151 +- .../openshell-supervisor-network/src/proxy.rs | 62 +- .../openshell-supervisor-network/src/run.rs | 1 + crates/openshell-supervisor-relay/Cargo.toml | 42 + crates/openshell-supervisor-relay/src/imp.rs | 1090 ++++++++++ crates/openshell-supervisor-relay/src/main.rs | 32 + .../tests/control_channel_contract.rs | 666 ++++++ crates/openshell-tui/src/lib.rs | 1 + docs/reference/gateway-config.mdx | 46 +- docs/sandboxes/manage-sandboxes.mdx | 26 +- docs/sandboxes/policies.mdx | 2 +- proto/openshell.proto | 10 + skills/openshell-cli/SKILL.md | 9 + tasks/scripts/windows-msvc.ps1 | 6 +- 57 files changed, 9110 insertions(+), 515 deletions(-) create mode 100644 crates/openshell-driver-mxc/examples/README-openclaw-forward.txt create mode 100644 crates/openshell-driver-mxc/examples/e2e-policies/openclaw-gateway.yaml create mode 100644 crates/openshell-driver-mxc/examples/e2e-policies/ws-agent.yaml create mode 100644 crates/openshell-driver-mxc/examples/install-nodejs-openclaw.ps1 create mode 100644 crates/openshell-driver-mxc/examples/mxc-openclaw-gateway.toml create mode 100644 crates/openshell-driver-mxc/examples/mxc-openclaw-isolation.toml create mode 100644 crates/openshell-driver-mxc/examples/mxc-openclaw-localnet.toml create mode 100644 crates/openshell-driver-mxc/examples/mxc-ws-agent.rs create mode 100644 crates/openshell-driver-mxc/examples/mxc-ws-gateway.toml create mode 100644 crates/openshell-driver-mxc/examples/openclaw-capture.mjs create mode 100644 crates/openshell-driver-mxc/examples/run-openclaw-forward-test.ps1 create mode 100644 crates/openshell-driver-mxc/examples/run-ws-agent-test.ps1 create mode 100644 crates/openshell-driver-mxc/src/control_channel.rs create mode 100644 crates/openshell-driver-mxc/src/relay.rs create mode 100644 crates/openshell-driver-mxc/tests/openclaw_appcontainer_compat.rs create mode 100644 crates/openshell-driver-mxc/tests/openclaw_environment_cleanup.ps1 create mode 100644 crates/openshell-supervisor-relay/Cargo.toml create mode 100644 crates/openshell-supervisor-relay/src/imp.rs create mode 100644 crates/openshell-supervisor-relay/src/main.rs create mode 100644 crates/openshell-supervisor-relay/tests/control_channel_contract.rs diff --git a/.agents/skills/build-openshell-mxc-windows/SKILL.md b/.agents/skills/build-openshell-mxc-windows/SKILL.md index cbc6b2b4a1..67aac83c54 100644 --- a/.agents/skills/build-openshell-mxc-windows/SKILL.md +++ b/.agents/skills/build-openshell-mxc-windows/SKILL.md @@ -17,6 +17,7 @@ Windows MSVC for the supported deliverables: - `openshell-gateway.exe` - `openshell.exe` +- `openshell-supervisor-relay.exe` (Windows-only MXC workload relay) It intentionally does not make Windows a Docker, Kubernetes, Podman, or VM runtime host. @@ -155,10 +156,18 @@ mise run --skip-tools windows:build:x64 mise run --skip-tools windows:build:arm64 mise run --skip-tools windows:test:x64 mise run --skip-tools windows:test:unsupported:x64 -mise run --skip-tools windows:test:mxc-real:x64 -mise run --skip-tools windows:test:mxc-real:arm64 ``` +The two `windows:test:mxc-real:*` tasks are host-specific and mutually +exclusive on a single host (each rejects the other architecture -- see the +table below): run `windows:test:mxc-real:x64` on an x64 host, or +`windows:test:mxc-real:arm64` on an ARM64 host, as part of validating this +subsystem -- run the one matching your host architecture, not both, and not +neither. Both are skip-safe (they print a SKIP reason and exit 0 when +`wxc-exec` or the matching backend isn't available), so running the +arch-appropriate task is always safe even without real MXC hardware. Neither +is part of `windows:ci`'s ordered contract, so invoke it explicitly. + For full validation, detect the Windows host architecture first and choose the native lane dynamically: @@ -167,12 +176,14 @@ $arch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture switch ($arch.ToString()) { "X64" { mise run --skip-tools windows:ci + mise run --skip-tools windows:test:mxc-real:x64 } "Arm64" { mise run --skip-tools windows:check:arm64 mise run --skip-tools windows:build:arm64 mise run --skip-tools windows:test:arm64 mise run --skip-tools windows:test:unsupported:arm64 + mise run --skip-tools windows:test:mxc-real:arm64 mise run --skip-tools windows:artifacts } default { diff --git a/AGENTS.md b/AGENTS.md index 3ac4eedf15..08eb3a489f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -64,6 +64,7 @@ These pipelines connect skills into end-to-end workflows. Individual skill files | `crates/openshell-driver-podman/` | Podman compute driver | In-process `ComputeDriver` backend for local Podman sandbox containers | | `crates/openshell-driver-vm/` | VM compute driver | Standalone libkrun-backed `ComputeDriver` subprocess (embeds its own rootfs + runtime) | | `crates/openshell-driver-mxc/` | Microsoft MXC compute driver | In-process Windows AppContainer and isolation-session compute backend | +| `crates/openshell-supervisor-relay/` | MXC supervisor relay | **Windows-only** standalone binary the MXC driver spawns inside a ProcessContainer/isolation session in place of `agent_command`; launches the real target process, exposes a JSON control channel (launch/shutdown/forward) over its own inherited stdin/stdout, and bridges dynamic TCP forwards (`openshell forward service`) to it | | `crates/openshell-prover/` | Policy prover | Policy verification and proof generation | | `crates/openshell-server-macros/` | Server macros | Compile-time helpers for gateway RPC authorization | | `crates/openshell-supervisor-middleware/` | Middleware runtime | Generic middleware registry, remote service integration, and chain execution | diff --git a/Cargo.lock b/Cargo.lock index 6d5c8afd55..854cfaa98a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4193,6 +4193,7 @@ dependencies = [ name = "openshell-driver-mxc" version = "0.0.0" dependencies = [ + "anyhow", "base64", "futures", "noyalib", @@ -4200,6 +4201,7 @@ dependencies = [ "openshell-ocsf", "openshell-policy", "openshell-supervisor-network", + "rand 0.9.4", "rustls", "serde", "serde_json", @@ -4207,6 +4209,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tokio-stream", + "tokio-tungstenite 0.26.2", "tonic", "tracing", "uuid", @@ -4843,6 +4846,18 @@ dependencies = [ "uuid", ] +[[package]] +name = "openshell-supervisor-relay" +version = "0.0.0" +dependencies = [ + "anyhow", + "base64", + "futures", + "serde_json", + "tokio", + "tokio-tungstenite 0.26.2", +] + [[package]] name = "openshell-tui" version = "0.0.0" diff --git a/architecture/compute-runtimes.md b/architecture/compute-runtimes.md index 74c4fe94d6..1a2433e9a2 100644 --- a/architecture/compute-runtimes.md +++ b/architecture/compute-runtimes.md @@ -167,6 +167,20 @@ on a server-only API. ## Stop and Start Lifecycle +On Windows, the MXC driver can wrap the workload in +`openshell-supervisor-relay`. Its inherited stdin/stdout control channel carries +the launch environment, shutdown requests, and multiplexed dynamic forwards. +The gateway accepts driver-reported readiness only after the configured target +port is reachable. Stop/delete interrupt readiness waits and await process +termination; they must not publish success while owned processes remain. + +With governed egress enabled, MXC denies direct Internet access and allows +host loopback. Proxy-aware workloads receive per-sandbox authenticated +`HTTP_PROXY`/`HTTPS_PROXY` URLs and public CA trust material. The host CONNECT +proxy enforces OpenShell network policy, but this configuration does not isolate +unrelated host-loopback services. See the MXC driver README for compatibility +settings and the remaining policy limitations. + The gateway persists lifecycle intent before mutating compute: ```text diff --git a/architecture/gateway.md b/architecture/gateway.md index 54d47a131d..321f1b078c 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -352,6 +352,12 @@ Public RPC contracts and durable protobuf formats have separate ownership. The ` `ReportEndpointStatus` is a sandbox-authenticated public gateway RPC. Its request, response, and `EndpointObservation` messages belong only to the public closure. `EndpointStatus` and `EndpointResult` also belong to the durable closure because `Sandbox.status.endpoint_statuses` persists them. The repeated status field uses a new wire tag; stored sandboxes without it decode with an empty endpoint list and retain their lifecycle fields. A fixed payload encoded with the earlier sandbox schema verifies that no database rewrite is required. +`DeleteSandboxRequest` adds optional identity and resource-version preconditions +at tags 4 and 5; the workspace selector retains tag 3. Omitted preconditions +preserve existing deletion behavior. Matching clients and servers are required +when relying on these checks: an older server can ignore unknown fields. These +request-only additions do not change any durable storage payload. + The removed `NetworkBinary.harness` field remains reserved by number and name, so protobuf implementations cannot reuse its wire slot or source identifier. The durable-policy compatibility decoder reads the former boolean before Prost diff --git a/architecture/sandbox.md b/architecture/sandbox.md index 60b8aa245e..541f688bac 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -87,6 +87,16 @@ signal status, whether or not an output attachment is open or the main process has exited. Waiting never holds the exec registry lock, so other operations can still signal or attach to the process. +## Deletion Authority + +The public delete API resolves a sandbox name to its immutable metadata ID and +holds that ID's lifecycle lock through the pre-mutation check. Callers that +already observed a sandbox may also supply its expected ID and resource +version. OpenShell revalidates those preconditions under the lifecycle and +gateway-global locks, then returns `ABORTED` without changing durable state or +calling the compute driver if either value drifted. An unguarded delete retains +the interactive CLI's existing name-based behavior. + ## Isolation Layers OpenShell uses overlapping controls rather than a single sandbox primitive: diff --git a/crates/openshell-cli/src/commands/common.rs b/crates/openshell-cli/src/commands/common.rs index 0f12c3df4e..29afe306af 100644 --- a/crates/openshell-cli/src/commands/common.rs +++ b/crates/openshell-cli/src/commands/common.rs @@ -878,6 +878,47 @@ pub fn parse_env_pairs(items: &[String]) -> Result> { Ok(map) } +/// Resolve `--env-from KEY[=ENVVAR]` values from the CLI process environment. +/// +/// This keeps environment values out of process arguments while preserving the +/// same sandbox environment validation as `--env KEY=VALUE`. +pub fn parse_env_from_pairs(items: &[String]) -> Result> { + let mut map = HashMap::new(); + + for item in items { + let (key, env_name) = match item.split_once('=') { + Some((key, env_name)) => (key.trim(), env_name.trim()), + None => (item.trim(), item.trim()), + }; + if !is_valid_env_name(key) { + return Err(miette::miette!( + "--env-from key must match [A-Za-z_][A-Za-z0-9_]*; got '{key}'" + )); + } + if key.starts_with("OPENSHELL_") { + return Err(miette::miette!( + "--env-from keys starting with OPENSHELL_ are reserved; got '{key}'" + )); + } + if !is_valid_env_name(env_name) { + return Err(miette::miette!( + "--env-from source must match [A-Za-z_][A-Za-z0-9_]*; got '{env_name}'" + )); + } + if map.contains_key(key) { + return Err(miette::miette!("duplicate --env-from sandbox key '{key}'")); + } + let value = std::env::var(env_name).map_err(|_| { + miette::miette!( + "--env-from source environment variable '{env_name}' is not set or is not valid Unicode" + ) + })?; + map.insert(key.to_string(), value); + } + + Ok(map) +} + /// Resolve `--secret-material-env KEY[=ENVVAR]` values from the CLI process /// environment (`ENVVAR` defaults to `KEY`) so secrets never transit argv. pub fn parse_secret_material_env_pairs(items: &[String]) -> Result> { diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index fdec4ce7e5..5f9d3b2935 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -1272,7 +1272,7 @@ enum SandboxCommands { name: Option, /// Create the sandbox from a named sandbox template. - #[arg(long, conflicts_with_all = ["from", "gpu", "cpu", "memory", "driver_config_json", "envs"])] + #[arg(long, conflicts_with_all = ["from", "gpu", "cpu", "memory", "driver_config_json", "envs", "env_from"])] template: Option, /// Sandbox source: a community sandbox name (e.g., `ollama`), a rootfs @@ -1396,6 +1396,13 @@ enum SandboxCommands { #[arg(long = "env", value_name = "KEY=VALUE")] envs: Vec, + /// Set a sandbox environment variable from the CLI process environment. + /// + /// Format: `KEY[=ENVVAR]`. When `ENVVAR` is omitted, `KEY` is used. + /// The value does not appear in the CLI process arguments. Repeatable. + #[arg(long = "env-from", value_name = "KEY[=ENVVAR]")] + env_from: Vec, + /// Suppress warnings when --env values look like credentials. #[arg(long = "no-credential-warnings")] no_credential_warnings: bool, @@ -1477,8 +1484,16 @@ enum SandboxCommands { names: Vec, /// Delete all sandboxes. - #[arg(long, conflicts_with = "names")] + #[arg(long, conflicts_with_all = ["names", "expected_id", "expected_resource_version"])] all: bool, + + /// Delete only if the current sandbox has this exact immutable ID. + #[arg(long = "expected-id", value_name = "ID")] + expected_id: Option, + + /// Delete only if the current sandbox has this resource version. + #[arg(long = "expected-resource-version", value_name = "VERSION")] + expected_resource_version: Option, }, /// Stop a sandbox while preserving its workspace. @@ -3067,6 +3082,7 @@ async fn run_async() -> Result<()> { no_auto_providers, labels, envs, + env_from, no_credential_warnings, approval_mode, output, @@ -3104,7 +3120,14 @@ async fn run_async() -> Result<()> { } // Parse --env flags into a HashMap. - let env_map = run::parse_env_pairs(&envs)?; + let mut env_map = run::parse_env_pairs(&envs)?; + for (key, value) in run::parse_env_from_pairs(&env_from)? { + if env_map.insert(key.clone(), value).is_some() { + return Err(miette::miette!( + "duplicate sandbox environment key '{key}' supplied through --env and --env-from" + )); + } + } run::warn_credential_env_vars(&env_map, no_credential_warnings); // Parse --upload specs into [(local_path, sandbox_path, git_ignore)]. @@ -3261,11 +3284,18 @@ async fn run_async() -> Result<()> { ) .await?; } - SandboxCommands::Delete { names, all } => { + SandboxCommands::Delete { + names, + all, + expected_id, + expected_resource_version, + } => { run::sandbox_delete( endpoint, &names, all, + expected_id.as_deref(), + expected_resource_version, &cli.workspace, &tls, &ctx.name, @@ -4805,6 +4835,48 @@ mod tests { )); } + #[test] + fn sandbox_delete_accepts_identity_preconditions() { + let cli = Cli::try_parse_from([ + "openshell", + "sandbox", + "delete", + "demo", + "--expected-id", + "sb-123", + "--expected-resource-version", + "17", + ]) + .expect("identity-guarded sandbox delete should parse"); + + assert!(matches!( + cli.command, + Some(Commands::Sandbox { + command: Some(SandboxCommands::Delete { + ref names, + all: false, + expected_id: Some(ref expected_id), + expected_resource_version: Some(17), + }) + }) if names == &["demo"] && expected_id == "sb-123" + )); + } + + #[test] + fn sandbox_delete_all_rejects_identity_preconditions() { + assert!( + Cli::try_parse_from([ + "openshell", + "sandbox", + "delete", + "--all", + "--expected-id", + "sb-123", + ]) + .is_err() + ); + } + #[test] fn sandbox_list_accepts_output_json() { let cli = Cli::try_parse_from(["openshell", "sandbox", "list", "-o", "json"]) diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index 9dbbe763b5..2d8cca3eb7 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -4,8 +4,8 @@ //! CLI command implementations. pub use crate::commands::common::{ - PolicyGetView, parse_credential_expiry_cli_value, parse_env_pairs, parse_key_value_pairs, - parse_secret_material_env_pairs, warn_credential_env_vars, + PolicyGetView, parse_credential_expiry_cli_value, parse_env_from_pairs, parse_env_pairs, + parse_key_value_pairs, parse_secret_material_env_pairs, warn_credential_env_vars, }; use crate::commands::common::{ ProvisioningDisplay, ProvisioningStep, confirm_global_setting_delete, @@ -407,7 +407,9 @@ async fn finalize_sandbox_create_session( } let names = [sandbox_name.to_string()]; - if let Err(err) = sandbox_delete(server, &names, false, workspace, tls, gateway).await { + if let Err(err) = + sandbox_delete(server, &names, false, None, None, workspace, tls, gateway).await + { if let Ok(exit_code) = session_result.as_ref() { return Err(miette::miette!( "sandbox command exited with status {exit_code}, but ephemeral cleanup failed: {err}" @@ -3244,14 +3246,35 @@ fn labels_display(labels: &HashMap) -> String { } /// Delete a sandbox by name, or all sandboxes when `all` is true. +#[allow(clippy::too_many_arguments)] // user-facing CLI command with explicit identity guards pub async fn sandbox_delete( server: &str, names: &[String], all: bool, + expected_id: Option<&str>, + expected_resource_version: Option, workspace: &str, tls: &TlsOptions, gateway: &str, ) -> Result<()> { + if (expected_id.is_some() || expected_resource_version.is_some()) && (all || names.len() != 1) { + return Err(miette!( + "--expected-id and --expected-resource-version require exactly one sandbox name" + )); + } + if expected_id.is_some_and(str::is_empty) { + return Err(miette!("--expected-id must not be empty")); + } + if expected_resource_version.is_some() && expected_id.is_none() { + return Err(miette!( + "--expected-resource-version requires --expected-id" + )); + } + if expected_resource_version == Some(0) { + return Err(miette!( + "--expected-resource-version must be greater than zero" + )); + } let mut client = grpc_client(server, tls).await?; let names_to_delete: Vec = if all { @@ -3303,6 +3326,8 @@ pub async fn sandbox_delete( allow_missing: true, name: name.clone(), workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), + expected_sandbox_id: expected_id.unwrap_or_default().to_string(), + expected_resource_version: expected_resource_version.unwrap_or_default(), }) .await { @@ -6099,7 +6124,7 @@ mod tests { use super::{ PolicyGetView, ProvisioningStep, build_sandbox_resource_limits, format_endpoint, format_log_line, git_sync_files, has_main_process_result, parse_cli_setting_value, - parse_credential_expiry_cli_value, parse_driver_config_json, + parse_credential_expiry_cli_value, parse_driver_config_json, parse_env_from_pairs, parse_secret_material_env_pairs, policy_revision_list_json, policy_revision_to_json, proto_execution_timeout, provisioning_timeout_message, ready_false_condition_message, resolve_from, rootfs_tar_sources_supported_for_gateway, sandbox_should_persist, @@ -6316,6 +6341,52 @@ mod tests { )); } + #[test] + fn parse_env_from_pairs_reads_named_and_same_name_environment_variables() { + let _named = EnvVarGuard::set("NAV_PARSE_ENV_FROM_NAMED", "named-value"); + let _same_name = EnvVarGuard::set("NAV_PARSE_ENV_FROM_SAME", "same-name-value"); + + let parsed = parse_env_from_pairs(&[ + "SANDBOX_NAMED=NAV_PARSE_ENV_FROM_NAMED".to_string(), + "NAV_PARSE_ENV_FROM_SAME".to_string(), + ]) + .expect("parse"); + assert_eq!( + parsed.get("SANDBOX_NAMED"), + Some(&"named-value".to_string()) + ); + assert_eq!( + parsed.get("NAV_PARSE_ENV_FROM_SAME"), + Some(&"same-name-value".to_string()) + ); + } + + #[test] + fn parse_env_from_pairs_rejects_missing_invalid_reserved_and_duplicate_keys() { + let _missing = EnvVarGuard::unset("NAV_PARSE_ENV_FROM_MISSING"); + let _present = EnvVarGuard::set("NAV_PARSE_ENV_FROM_PRESENT", "value"); + + for (input, expected) in [ + ("TARGET=NAV_PARSE_ENV_FROM_MISSING", "is not set"), + ("1BAD=NAV_PARSE_ENV_FROM_PRESENT", "key must match"), + ("TARGET=BAD-NAME", "source must match"), + ("OPENSHELL_RESERVED=NAV_PARSE_ENV_FROM_PRESENT", "reserved"), + ] { + let error = parse_env_from_pairs(&[input.to_string()]).expect_err("must reject"); + assert!( + error.to_string().contains(expected), + "unexpected error: {error}" + ); + } + + let error = parse_env_from_pairs(&[ + "TARGET=NAV_PARSE_ENV_FROM_PRESENT".to_string(), + "TARGET=NAV_PARSE_ENV_FROM_PRESENT".to_string(), + ]) + .expect_err("duplicate must reject"); + assert!(error.to_string().contains("duplicate --env-from")); + } + #[test] fn parse_secret_material_env_pairs_reads_value_from_named_environment_variable() { let _guard = EnvVarGuard::set("NAV_PARSE_SME_NAMED", "pem-material"); diff --git a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs index 5bf1e0e1c5..665549e4c6 100644 --- a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs +++ b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs @@ -60,6 +60,7 @@ fn selected_workspace( #[derive(Clone, Default)] struct SandboxState { deleted_names: Arc>>>, + delete_requests: Arc>>, create_requests: Arc>>, fail_delete_sandbox_message: Arc>>, vm_error_after_started: Arc, @@ -332,6 +333,11 @@ impl OpenShell for TestOpenShell { request: tonic::Request, ) -> Result, Status> { let request = request.into_inner(); + self.state + .delete_requests + .lock() + .await + .push(request.clone()); self.state .deleted_names .lock() @@ -1390,6 +1396,10 @@ async fn deleted_names(server: &TestServer) -> Vec> { server.openshell.state.deleted_names.lock().await.clone() } +async fn delete_requests(server: &TestServer) -> Vec { + server.openshell.state.delete_requests.lock().await.clone() +} + async fn create_requests(server: &TestServer) -> Vec { server.openshell.state.create_requests.lock().await.clone() } @@ -1487,6 +1497,8 @@ async fn sandbox_delete_continues_after_entry_failure() { &server.endpoint, &["failing-sandbox".to_string(), "later-sandbox".to_string()], false, + None, + None, "default", &tls, "openshell", @@ -1508,6 +1520,79 @@ async fn sandbox_delete_continues_after_entry_failure() { ); } +#[tokio::test] +async fn sandbox_delete_forwards_identity_preconditions() { + let server = run_server().await; + let tls = test_tls(&server); + + run::sandbox_delete( + &server.endpoint, + &["guarded-sandbox".to_string()], + false, + Some("sb-123"), + Some(17), + "default", + &tls, + "openshell", + ) + .await + .expect("identity-guarded delete should succeed"); + + let requests = delete_requests(&server).await; + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].name, "guarded-sandbox"); + assert_eq!(requests[0].expected_sandbox_id, "sb-123"); + assert_eq!(requests[0].expected_resource_version, 17); +} + +#[tokio::test] +async fn sandbox_delete_rejects_preconditions_for_multiple_names_before_rpc() { + let server = run_server().await; + let tls = test_tls(&server); + + let error = run::sandbox_delete( + &server.endpoint, + &["sandbox-a".to_string(), "sandbox-b".to_string()], + false, + Some("sb-123"), + None, + "default", + &tls, + "openshell", + ) + .await + .expect_err("identity preconditions must be single-sandbox only"); + + assert!( + error + .to_string() + .contains("require exactly one sandbox name") + ); + assert!(delete_requests(&server).await.is_empty()); +} + +#[tokio::test] +async fn sandbox_delete_rejects_resource_version_without_immutable_identity() { + let server = run_server().await; + let tls = test_tls(&server); + + let error = run::sandbox_delete( + &server.endpoint, + &["guarded-sandbox".to_string()], + false, + None, + Some(17), + "default", + &tls, + "openshell", + ) + .await + .expect_err("resource version without immutable ID must fail closed"); + + assert!(error.to_string().contains("requires --expected-id")); + assert!(delete_requests(&server).await.is_empty()); +} + #[tokio::test] async fn sandbox_create_keeps_command_sessions_by_default() { let server = run_server().await; @@ -2820,6 +2905,16 @@ async fn run_cli_sandbox_create_with_xdg( xdg_dir: &TempDir, name: &str, extra_args: &[&str], +) -> std::process::Output { + run_cli_sandbox_create_with_xdg_and_env(server, xdg_dir, name, extra_args, &[]).await +} + +async fn run_cli_sandbox_create_with_xdg_and_env( + server: &TestServer, + xdg_dir: &TempDir, + name: &str, + extra_args: &[&str], + environment: &[(&str, &str)], ) -> std::process::Output { let mut cmd = tokio::process::Command::new(env!("CARGO_BIN_EXE_openshell")); for (key, _) in std::env::vars().filter(|(k, _)| k.starts_with("OPENSHELL_")) { @@ -2838,6 +2933,7 @@ async fn run_cli_sandbox_create_with_xdg( "--no-auto-providers", ]) .args(extra_args) + .envs(environment.iter().copied()) .env("XDG_CONFIG_HOME", xdg_dir.path()) .env("HOME", xdg_dir.path()) .env("OPENSHELL_PROVISION_TIMEOUT", "5") @@ -2856,6 +2952,47 @@ async fn run_cli_sandbox_create( run_cli_sandbox_create_with_xdg(server, &xdg_dir, name, extra_args).await } +async fn run_cli_sandbox_create_with_env( + server: &TestServer, + name: &str, + extra_args: &[&str], + environment: &[(&str, &str)], +) -> std::process::Output { + let xdg_dir = tempfile::tempdir().unwrap(); + prepare_cli_xdg(server, &xdg_dir); + run_cli_sandbox_create_with_xdg_and_env(server, &xdg_dir, name, extra_args, environment).await +} + +#[tokio::test] +async fn sandbox_create_env_from_reaches_request() { + let server = run_server().await; + let value = "qualification-value-not-in-argv"; + + let output = run_cli_sandbox_create_with_env( + &server, + "env-from-test", + &["--env-from", "SANDBOX_VALUE=HOST_VALUE", "--output=json"], + &[("HOST_VALUE", value)], + ) + .await; + assert!( + output.status.success(), + "sandbox create failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let requests = create_requests(&server).await; + let environment = &requests[0] + .spec + .as_ref() + .expect("spec should be present") + .environment; + assert_eq!( + environment.get("SANDBOX_VALUE").map(String::as_str), + Some(value) + ); +} + async fn run_cli_sandbox_template_create( server: &TestServer, name: &str, diff --git a/crates/openshell-driver-mxc/Cargo.toml b/crates/openshell-driver-mxc/Cargo.toml index 5907e92a84..a85e709d4e 100644 --- a/crates/openshell-driver-mxc/Cargo.toml +++ b/crates/openshell-driver-mxc/Cargo.toml @@ -34,9 +34,18 @@ uuid = { workspace = true } [target.'cfg(target_os = "windows")'.dependencies] openshell-supervisor-network = { path = "../openshell-supervisor-network" } windows = { workspace = true } +# WebSocket relay embedded in the gateway for ProcessContainer host<->sandbox +# connectivity (see src/relay.rs). +tokio-tungstenite = { workspace = true } +futures = { workspace = true } +# Per-forward auth nonce for the relay (see src/relay.rs module docs). +rand = { workspace = true } [dev-dependencies] +anyhow = { workspace = true } tokio = { workspace = true } +tokio-tungstenite = { workspace = true } +futures = { workspace = true } # tempfile is not a workspace dependency; 3.27 is already resolved in Cargo.lock. tempfile = "3" # Used by Windows-only integration tests to parse policy YAML into the typed diff --git a/crates/openshell-driver-mxc/README.md b/crates/openshell-driver-mxc/README.md index 2335bb7f31..4edf3935b7 100644 --- a/crates/openshell-driver-mxc/README.md +++ b/crates/openshell-driver-mxc/README.md @@ -12,24 +12,24 @@ effective `SandboxPolicy` and carries it on the driver-only copy of `DriverSandboxSpec.policy`. `process_container` launches a one-shot AppContainer and is the default. The opt-in `isolation_session` backend uses the state-aware `provision` → `start` → `exec` → `stop` → `deprovision` lifecycle. -The driver launches and monitors the configured workload itself and self-reports -readiness; there is no in-sandbox supervisor or `ConnectSupervisor` relay. +The driver launches and monitors the configured workload and self-reports +readiness. Optional `openshell-supervisor-relay` wrapping provides launch, +shutdown, and dynamic forwarding over an inherited stdin/stdout control channel; +it does not implement the Linux `ConnectSupervisor` protocol. ## Capability Matrix -| Capability | MXC driver | Closing it requires | -|---|---|---| -| Filesystem policy (read-write / read-only grants) | ✅ provision-time AppContainer shares | — | -| 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. | MXC support for persistent sessions | -| Governed egress (CONNECT proxy + OPA + L7) | Available behind `egress_proxy` on `process_container`; the driver starts a per-sandbox host CONNECT proxy, generates HTTPS MITM trust material, and injects the CA bundle into the sandbox process env | Gateway event-bus wiring follow-on | -| Network policy | Split into MXC `network.proxy` + trimmed OpenShell policy on `process_container`; `isolation_session` still rejects network config | MXC feedback item M1 for persistent sessions | -| 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. | — | -| Network middleware | ❌ rejected before launch because the MXC host proxy does not receive the gateway middleware registry | Gateway middleware-registry injection | -| Process policy (seccomp, uid/gid) | ❌ host-side governance design; OS isolation only | not pursued | -| Interactive exec/connect/forward | ❌ exec runs in-driver, no client attach | gateway interactive-exec surgery (follow-on) | -| Bundled agent image | ❌ no OCI image; relies on Windows host install | — | -| Restart durability | ❌ in-memory registry; restart orphans live sessions | follow-on | -| Concurrent sandboxes | ⚠️ isolation_session v1 is single-session | MXC backend feature | +| Capability | MXC driver | +|---|---| +| Filesystem policy | Read-only/read-write grants come only from `SandboxPolicy`. `process_container` enforces default-deny; `isolation_session` is an explicit grant-only compatibility mode. | +| UI policy | `process_container` advertises complete support and maps portable graphical UI, clipboard-direction, and input-injection controls to MXC; omitted fields inside an explicit section deny. `isolation_session` advertises no support, so the gateway rejects any explicit section before provisioning. | +| Network policy | With `egress_proxy = true` on `process_container`, split into MXC 0.8 loopback-only egress plus the full policy enforced by a per-sandbox OpenShell host CONNECT proxy. The driver injects proxy environment variables for proxy-aware clients; direct Internet access remains denied by MXC. Otherwise rejected synchronously. `isolation_session` remains fail-closed. | +| Provider credentials | The child receives revision-scoped placeholders and non-secret provider environment only. The per-sandbox host proxy retains the resolver and substitutes credentials only for their bound endpoints. | +| Process policy | Unsupported; MXC supplies OS isolation only. | +| Dynamic forwarding | Supported through `openshell-supervisor-relay`; interactive exec/connect remain unsupported. | +| Network middleware | Rejected before launch until the host proxy receives the gateway middleware registry. | +| ETW/OCSF audit | Optional Windows Sandboxing ETW consumer attributes host events to OpenShell sandboxes and emits OCSF records. | +| Restart durability | Unsupported; the in-memory registry cannot recover live sessions. | The filesystem enforcement proof has two paths: @@ -48,8 +48,37 @@ backend = "process_container" default_configuration_id = "composable" pc_least_privilege = false pc_capabilities = [] -# Pattern-C governed egress. The address is a loopback seed; each sandbox -# receives a unique ephemeral proxy port. +# processContainer only: launch openshell-supervisor-relay instead of +# agent_command/legacy 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) + agent_env; pc_minimal_env +# starts from an EMPTY env (agent_env only) for runtimes that choke on an +# unrecognized host env; pc_inherit_full_env is an explicit unsafe opt-in +# to the gateway host's entire environment (secrets included) + agent_env, +# ignored when pc_minimal_env is also set. +pc_minimal_env = false +pc_inherit_full_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 +# Legacy workload settings, used only as a fallback when a sandbox's +# CreateSandbox request carries no --driver-config-json (see below) -- +# agent_command is required for a sandbox to succeed via this fallback path. +agent_command = ["cmd", "/c", "echo hello > C:\\work\\demo\\hello.txt"] +agent_cwd = "C:\\work\\demo" +# Host directory mapped read-write into the sandbox. NOT an automatic +# filesystem grant on its own -- the sandbox's SandboxPolicy is the only +# source of filesystem grants, so a policy's filesystem_policy.read_write +# must include this path explicitly for the workload to reach it. +share_dir = "C:\\work\\demo" +# Pattern C governed egress. Requires backend = "process_container". egress_proxy = false egress_proxy_addr = "" debug = false @@ -58,7 +87,7 @@ etw_audit = false When `egress_proxy` is enabled, `egress_proxy_addr` must be a loopback `IP:PORT` seed. The driver preserves the configured IP and allocates a unique -ephemeral port for each sandbox's `network.proxy` redirect. +ephemeral port for each sandbox's authenticated host CONNECT proxy. Supply workload settings for each sandbox. The public config is keyed by driver name; the gateway forwards only the inner `mxc` object to the driver: @@ -68,19 +97,11 @@ openshell sandbox create --name mxc-demo --policy demo.yaml ` --driver-config-json $config --env MODE=demo --no-tty ``` -The `command` array is required and preserves Windows argument boundaries. `cwd` -is optional. Environment variables come from the standard sandbox and template -environment maps; the driver never copies values from the gateway host -environment. Provider-owned keys override matching entries case-insensitively. -MXC receives revision-scoped placeholders and explicitly classified non-secret -provider configuration, while credential values remain in the host proxy. When -governed egress is enabled, the driver replaces common TLS trust environment -variables with paths to the proxy-generated CA material and grants that -sandbox-unique CA directory read-write so the AppContainer can read it. +The `command` array is required and preserves Windows argument boundaries. `cwd` is optional. Per-sandbox environment variables come from the standard sandbox and template environment maps only; this path never copies values from the gateway host environment. The legacy `agent_env`/`pc_inherit_full_env` TOML fields above are a separate, gateway-wide mechanism and are the only way the gateway host's own environment reaches a sandbox -- bare `agent_env` keys opt specific host values in. 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 `share_dir`, and injects `HTTP_PROXY`/`HTTPS_PROXY` while clearing `NO_PROXY` so inherited bypass rules cannot skip policy enforcement. + +UI capability (Win32k syscalls, clipboard, input injection) is a `SandboxPolicy` concern, not gateway TOML -- see the Capability Matrix above and `docs/reference/policy-schema.mdx`'s `ui` section. Defaults to disabled (Win32k syscall lockdown) when a policy has no explicit `ui:` section; set `allow_graphical_ui: true` for agents that touch user32/gdi32 at startup even without opening a real window (e.g. Node.js-based targets like OpenClaw's gateway -- see `examples/e2e-policies/openclaw-gateway.yaml`). -The host CONNECT proxy enforces network policy when governed egress is enabled. -Live policy replacement or merge updates remain unsupported; delete and recreate -the sandbox to apply a different policy. +`egress_proxy_addr` must be a `127.0.0.1:PORT` address. The port acts only as a configuration seed: the driver reserves a unique ephemeral loopback port for every sandbox. MXC 0.8 denies direct Internet egress and permits `127.0.0.1/32`; the driver points proxy-aware clients at the per-sandbox listener using environment variables. The current policy permits all loopback ports, so sandboxes can also reach unrelated host services bound to loopback. Control-channel forwarding does not require the legacy reverse-WebSocket connections to fresh host ports; restricting the generated policy is separate hardening work. Do not treat this path as loopback-service isolation. Live policy replacement or merge updates remain unsupported; delete and recreate the sandbox to apply a different policy. When `etw_audit` is enabled, each gateway process owns a distinct real-time ETW session named from the stable `OpenShell-MXC-ETW` prefix, its process ID, and a @@ -109,7 +130,7 @@ 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 listener port through its MXC redirect, but the listener does not authenticate its TCP peer. Processes that can connect directly to gateway-owned loopback ports are part of the trusted Windows host boundary. MXC feedback item M3 tracks stable source attribution for deployments that need a mutually untrusted shared-host boundary. +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. @@ -136,8 +157,8 @@ separate, create-scoped in-process handoff because it intentionally cannot be represented in the public compute-driver protobuf. When `egress_proxy` is enabled, `EmbeddedPolicyMapper` uses `split_policy` -instead: MXC receives filesystem grants plus a loopback `network.proxy` -redirect, and the driver starts a host CONNECT proxy from the trimmed +instead: MXC receives filesystem grants plus loopback-only egress, +and the driver starts a host CONNECT proxy from the trimmed network-only `SandboxPolicy`. Policies containing `network_middlewares` are rejected synchronously until this host-proxy path can receive the gateway's built-in and remote middleware registry. The proxy uses the configured agent @@ -145,30 +166,17 @@ command as the static sandbox process identity because MXC does not expose Linux-style procfs socket ownership. For HTTPS L7 inspection, the host proxy generates a per-sandbox CA and injects `NODE_EXTRA_CA_CERTS`, `DENO_CERT`, `SSL_CERT_FILE`, `REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`, and `GIT_SSL_CAINFO` into the agent -process env. It does not add the generated CA directory to MXC read-only grants: -released `wxc-exec` BaseContainer builds require `WRITE_DAC` on every such -grant and reject the user-owned proxy temp directory. Instead, the driver adds -the sandbox-unique directory as an internal read-write share so HTTPS clients -can read the injected paths. The directory contains only public CA certificates; +process env. In curated-environment mode, the driver stages the public CA files +under the authorized `share_dir/.openshell-proxy/` directory. Other +environment modes grant the sandbox's unique public-CA directory as an internal +read-write share. The directory contains only public CA certificates; the ephemeral CA private key remains in the host proxy's memory. The driver seeds only `SYSTEMROOT`, `WINDIR`, `PATH`, `COMSPEC`, and `LOCALAPPDATA` from the gateway host before applying sandbox and TLS overrides, so required Windows bootstrap values remain available without exposing the gateway's full -environment. The development export surface remains the -[`policy-to-mxc`](examples/policy-to-mxc.rs) example; there is no production -`openshell policy export-mxc` subcommand yet. - -The mapper normalizes filesystem paths to Windows form and does not add -gateway-configured host paths. The policy supplied for the sandbox is the only -source of filesystem grants. For `process_container`, the driver advertises -`supports_ui_policy = true` and emits the top-level `ui` object shared by MXC's -0.8 stable and 0.9 development schemas, with restrictive defaults or the exact -requested clipboard direction, graphical UI setting, and input-injection -setting. `isolation_session` advertises false, so the gateway rejects explicit -UI policy before the driver RPC; the mapper also rejects it before lifecycle -side effects as defense in depth. - -If governed egress is disabled, any network rule fails closed rather than launching without an enforcement path. +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. @@ -187,15 +195,6 @@ 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. -## Packaging the demo for the demo box - -Use [`examples/package-demo.ps1`](examples/package-demo.ps1) to assemble -the gateway EXE, CLI EXE, runtime DLLs (`libz3.dll`), `demo.yaml`, the -gateway config, and the runbook into one folder, then copy that folder to -the demo Windows host and follow `mxc-demo-runbook.md` inside it. The -script prints a SHA256 manifest so the operator can sanity-check what -landed before moving it. - ## Real-MXC test lane Three tasks drive real `wxc-exec.exe` hardware; all are **skip-safe** — any test @@ -226,12 +225,12 @@ powershell -NoProfile -ExecutionPolicy Bypass ` **Skip semantics:** tests in `wxc_exec_real.rs` are marked `#[ignore = "requires real wxc-exec"]` — the standard `windows:test:x64` suite never runs them. `OPENSHELL_WXC_EXEC_PATH` overrides the default -`C:\mxc\wxc-exec.exe` lookup. See `docs4gtb/mxc-box-capabilities.md` for the -empirical capability snapshot of the development box (build 26200, processcontainer -velocity keys not enabled, isolation_session absent). +`C:\mxc\wxc-exec.exe` lookup. Run the probe on the actual test host; a different +machine's capability report is not evidence that its backend is available here. ## Deferred work -- **Interactive exec/connect/forward** — gateway interactive-exec surgery (follow-on) +- **Interactive exec/connect** — gateway interactive-exec integration (follow-on); dynamic service forwarding is supported through the relay. +- **Persistent-session governed egress** remains fail-closed until `isolation_session` exposes an enforceable proxy path. - **Restart durability** (deprovision orphaned sessions on startup) → follow-on - **GPU passthrough** → not pursued in host-side-governance design diff --git a/crates/openshell-driver-mxc/examples/README-openclaw-forward.txt b/crates/openshell-driver-mxc/examples/README-openclaw-forward.txt new file mode 100644 index 0000000000..96ae4924a1 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/README-openclaw-forward.txt @@ -0,0 +1,141 @@ +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 agent_env onto the full inherited host environment rather than + replacing it, so none of ProcessContainer's pc_minimal_env / LOCALAPPDATA + workaround is needed -- see mxc-openclaw-isolation.toml's own comments for + what else differs (ProcessContainer-only fields it ignores entirely). + +PREREQUISITES (on this test box) + - An ELEVATED (Administrator) PowerShell session, for -Backend + process_container specifically. On this box's wxc-exec build, + process_container falls back to an "AppContainer + DACL" isolation + tier that needs two privileged operations: (1) WRITE_DAC on share_dir + to stamp the AppContainer's ACL -- fixable non-elevated if you own + share_dir yourself (first run wins ownership; icacls /setowner fixes a + folder an earlier elevated run left owned by Administrators), but + (2) with egress_proxy = true (mxc-openclaw-gateway.toml's default), + wxc-exec also calls NetworkIsolationSetAppContainerConfig to grant the + AppContainer a loopback exemption so it can reach the host's egress + proxy -- that Windows API requires Administrator regardless of file + ownership. Non-elevated fails both with ERROR_ACCESS_DENIED (0x5), the + second as "Network proxy error: Failed to set loopback exemption: + 0x00000005". -Backend isolation_session does not hit either path. + - wxc-exec.exe present (default expected: C:\mxc-kit\bin\wxc-exec.exe) + - process_container or isolation_session backend live (whichever -Backend + you pass) + - Your own OpenClaw install: a node.exe binary + the openclaw npm package + (the directory containing openclaw.mjs and its own node_modules). + Neither ships in this package -- point the script at your existing + install with -NodeExePath / -OpenClawInstallDir. Don't have one? Run + install-nodejs-openclaw.ps1 first (see below) -- it fetches both and + prints the exact paths to pass here. + - Windows has curl.exe / robocopy.exe built in (they do on Win10+). + - Outbound internet to nodejs.org and registry.npmjs.org, ONLY if you use + install-nodejs-openclaw.ps1 to fetch Node.js/OpenClaw. Not needed if you + already have both. + +DON'T HAVE NODE.JS / OPENCLAW YET? + powershell -NoProfile -ExecutionPolicy Bypass -File .\install-nodejs-openclaw.ps1 + Downloads a pinned, SHA256-verified Node.js build and installs the + "openclaw" package from the public npm registry, laid out exactly how this + test expects them. Prints the -NodeExePath / -OpenClawInstallDir values to + pass through. One-time step (or pass -Force to re-fetch); if you already + have a working install elsewhere, skip this and point directly at it. + +HOW TO RUN + 1. Open PowerShell in THIS folder. + 2. Run: + powershell -NoProfile -ExecutionPolicy Bypass -File .\run-openclaw-forward-test.ps1 ` + -WxcExecPath C:\mxc-kit\bin\wxc-exec.exe ` + -NodeExePath C:\path\to\node.exe ` + -OpenClawInstallDir C:\path\to\node_modules\openclaw + + Add -Backend isolation_session to exercise that backend instead of the + default process_container. + + The script COPIES your node.exe, the OpenClaw install, and this package's + own openclaw-capture.mjs / openshell-supervisor-relay.exe into a share_dir + (default C:\openshell-openclaw) before creating the sandbox -- the + AppContainer here can only read paths under share_dir, so everything the + sandboxed process touches has to live there. The OpenClaw copy uses + robocopy and only re-copies changed files on a rerun. + +WHAT YOU GET BACK + The script prints PASS/FAIL and creates: + results-openclaw-forward-.zip + Hand that zip back. It contains the transcript, gateway logs (including the + sandbox's own forwarded stdout/stderr), the `openshell forward service` + output, the raw OpenClaw health-check response, OpenClaw's own captured + log, and the exact config + policy used. + + The capture wrapper also makes one credential-free WebSocket handshake to + OpenClaw from inside the sandbox after the gateway reports ready. It records + only an outcome and response-byte count, never response content. This is a + diagnostic boundary check: a local response with a failed host-side health + check points at the sandbox-boundary/forward path; no local response points + at the sandboxed OpenClaw target. A `started-no-completion` outcome means + even the probe's bounded socket/timer callbacks stopped progressing after + OpenClaw reported ready, which is evidence of a blocked target event loop. + The diagnostic never changes the PASS/FAIL verdict, which still requires + the authenticated host-side OpenClaw client. + +FILES IN THIS PACKAGE + openshell-gateway.exe the gateway (self-contained; needs only VC++ runtime) + openshell.exe the CLI + openshell-supervisor-relay.exe generic spawn+relay-bridge binary the driver + launches inside the sandbox in place of + OpenClaw directly (OpenClaw itself has no + relay awareness) + openclaw-capture.mjs thin Node.js wrapper that appends the + sandboxed process's stdout/stderr to a log + file in share_dir and records only the + outcome/byte count of a credential-free + target-side self-probe (OpenShell's own + adapter code, not OpenClaw's) + mxc-openclaw-gateway.toml gateway/driver config (process_container, default) + mxc-openclaw-isolation.toml gateway/driver config (-Backend isolation_session) + mxc-openclaw-localnet.toml experimental alternate process_container config + (-UseLocalNetwork; currently non-functional, + see run-openclaw-forward-test.ps1's own comment) + openclaw-gateway.yaml sandbox policy (read-write grant to share_dir + only -- see the comment at its top for why) + run-openclaw-forward-test.ps1 the orchestrator you run + install-nodejs-openclaw.ps1 optional prerequisite: fetches Node.js + + OpenClaw if you don't already have them + README-openclaw-forward.txt this file + +NOTES + - The control plane between CLI and gateway runs with --disable-tls on + loopback (that's a separate test point, T2). This test's relay traffic + (host <-> sandbox) is a separate, unrelated WebSocket tunnel. + - A "supervisor session not connected" / ssh 255 message during sandbox + create is EXPECTED on MXC and harmless - the agent already ran in-driver. + - `pc_minimal_env = true` in mxc-openclaw-gateway.toml (process_container + only) means the sandboxed process gets ONLY the env vars listed in + agent_env -- see the comment above that list for the (non-obvious) + minimum Windows needs just to let CreateProcessW succeed, independent of + anything Node.js-specific. mxc-openclaw-isolation.toml doesn't need this + at all: isolation_session merges agent_env onto the full host env. + - The relay is entirely on-demand: nothing is listening on any fixed host + port before you run `openshell forward service`, and nothing is left + listening after the forward process exits. diff --git a/crates/openshell-driver-mxc/examples/e2e-policies/openclaw-gateway.yaml b/crates/openshell-driver-mxc/examples/e2e-policies/openclaw-gateway.yaml new file mode 100644 index 0000000000..1b86b32d73 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/e2e-policies/openclaw-gateway.yaml @@ -0,0 +1,49 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# openclaw-gateway.yaml - Sandbox policy for the openclaw gateway running in a ProcessContainer. +# +# node.js (openclaw) needs: +# - Read-write access to share_dir (C:/openshell-openclaw) for node.exe, +# the openclaw install, home/temp dirs, and log files -- granted +# explicitly below via read_write, since the policy is the only source +# of filesystem grants (the driver no longer adds gateway-configured +# host paths on its own). This AppContainer configuration has no other +# read-only grants, so everything the sandboxed process touches +# (including its own Node.js runtime and the OpenClaw package) must +# live under share_dir; see run-openclaw-forward-test.ps1's staging +# step. run-openclaw-forward-test.ps1 patches this path (alongside the +# TOML's share_dir) when -ShareDir overrides the default below. +# - TCP socket binding on port 18889 (loopback) — governed by pc_capabilities. +# - Outbound TCP through the egress proxy — governed by egress_proxy in the TOML. +# - Win32k syscall access (ui.allow_graphical_ui) even though it never +# opens a real window: Node.js touches user32/gdi32 during its own +# startup and dies with STATUS_DLL_INIT_FAILED under the Win32k +# lockdown that applies by default when a policy has no `ui:` section, +# confirmed empirically against mxc-release-binaries-v0.8.0. +# +# The network policy is enforced by the OpenShell host CONNECT proxy. MXC's +# default-deny egress independently blocks direct Internet bypass attempts. +version: 1 + +filesystem_policy: + include_workdir: false + read_only: [] + read_write: + - "C:/openshell-openclaw" + +ui: + allow_graphical_ui: true + clipboard: none + allow_input_injection: false + +network_policies: + qualification_allowed: + name: qualification-allowed + endpoints: + - host: example.com + port: 443 + protocol: tcp + binaries: + # The harness replaces the default share root when -ShareDir is set. + - path: "C:/openshell-openclaw/node.exe" diff --git a/crates/openshell-driver-mxc/examples/e2e-policies/ws-agent.yaml b/crates/openshell-driver-mxc/examples/e2e-policies/ws-agent.yaml new file mode 100644 index 0000000000..3d17c81729 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/e2e-policies/ws-agent.yaml @@ -0,0 +1,42 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# ws-agent.yaml - Sandbox policy for the WebSocket agent (mxc-ws-agent.exe, +# wrapped by openshell-supervisor-relay.exe -- see mxc-ws-gateway.toml). +# +# The wrapped server process only needs: +# - Execute access to mxc-ws-agent.exe / openshell-supervisor-relay.exe and +# their runtime DLLs (provided by the AppContainer inheriting access to +# system paths and share_dir). +# - TCP socket binding on port 22000 (governed by pc_capabilities in the +# gateway TOML, not by filesystem policy here). +# - Outbound TCP through the egress proxy, for openshell-supervisor-relay +# to dial the driver's on-demand relay — governed by egress_proxy in the +# TOML. +# - No writes to the host filesystem. +# +# share_dir (configured in mxc-ws-gateway.toml, 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 (alongside the TOML's share_dir) when -AgentDir overrides +# the default below. +# +# This example intentionally omits network_policies, not because the driver +# would reject it: with egress_proxy = true (set in mxc-ws-gateway.toml), the +# driver takes the lossless split path (policy_map::split_policy) and +# delegates network_policies verbatim to the OpenShell host CONNECT proxy for +# enforcement -- an "info" loss item, not an error, so it would be accepted. +# (Only the no-proxy coarse path, or an unsupported rule shape, can turn a +# network_policies entry into a rejected "error" loss item -- see +# policy_map/map.rs.) This scenario just doesn't need host-enforced network +# rules beyond the loopback/pc_capabilities grant above. +version: 1 + +filesystem_policy: + include_workdir: false + read_only: + - "C:/work/openshell-mxc-ws" + read_write: [] diff --git a/crates/openshell-driver-mxc/examples/install-nodejs-openclaw.ps1 b/crates/openshell-driver-mxc/examples/install-nodejs-openclaw.ps1 new file mode 100644 index 0000000000..8e05b9f3f4 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/install-nodejs-openclaw.ps1 @@ -0,0 +1,157 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# install-nodejs-openclaw.ps1 - fetch a pinned Node.js and the OpenClaw npm +# package, laid out exactly how run-openclaw-forward-test.ps1 expects them. +# +# This is a PREREQUISITE step, not part of the test itself: run it once (or +# with -Force to re-fetch), then pass its printed -NodeExePath / +# -OpenClawInstallDir values straight through to run-openclaw-forward-test.ps1. +# If you already have a working Node.js + OpenClaw install elsewhere, you +# don't need this script at all -- just point run-openclaw-forward-test.ps1 at +# it directly. +# +# What it does: +# 1. Downloads the official Node.js Windows x64 zip build (no installer, no +# admin rights needed) for a pinned version, verifies its SHA256 against +# Node.js's published SHASUMS256.txt, and extracts it. +# 2. Uses that Node's bundled npm to install the "openclaw" package from the +# public npm registry into the same install directory (`npm install +# openclaw --prefix `), which lays it out at +# \node_modules\openclaw -- exactly what -OpenClawInstallDir expects. +# +# Usage: +# powershell -NoProfile -ExecutionPolicy Bypass -File .\install-nodejs-openclaw.ps1 +# powershell -NoProfile -ExecutionPolicy Bypass -File .\run-openclaw-forward-test.ps1 ` +# -WxcExecPath C:\mxc-kit\bin\wxc-exec.exe ` +# -NodeExePath C:\openshell-openclaw-install\node\node.exe ` +# -OpenClawInstallDir C:\openshell-openclaw-install\node_modules\openclaw +# +# Needs outbound internet access to nodejs.org and registry.npmjs.org. If +# your box only reaches the internet through a corporate proxy, set the usual +# HTTP_PROXY/HTTPS_PROXY env vars before running this script -- both +# Invoke-WebRequest and npm respect them. + +[CmdletBinding()] +param( + # Must be a DIRECT CHILD of a drive root (e.g. C:\openshell-openclaw-install, + # not C:\work\openshell-openclaw-install) for the SAME reason + # run-openclaw-forward-test.ps1's -ShareDir must be: nothing here actually + # runs inside the AppContainer, but keeping this path shape consistent + # avoids surprises if you ever point -ShareDir at this same location. + [string] $InstallDir = "C:\openshell-openclaw-install", + # Pinned to the version this package's OpenClaw scenario was validated + # against (observed working: Node.js v22.22.3). Override if you need a + # different one, but that combination is untested by this package. + [string] $NodeVersion = "22.22.3", + # Pinned to the version this package's OpenClaw scenario was validated + # against (the package actually exercised by run-openclaw-forward-test.ps1 + # across this repo's live testing). Override (or pass "" for whatever + # "npm install openclaw" resolves to latest at run time) for ad hoc testing, + # but that's untested by this package. + [string] $OpenClawVersion = "2026.7.1", + # Re-download/re-install even if InstallDir already looks populated. + [switch] $Force +) + +$ErrorActionPreference = "Stop" +$ProgressPreference = "SilentlyContinue" # Invoke-WebRequest is dramatically faster with the progress bar off. + +function Step([string]$m) { Write-Host "`n=== $m ===" -ForegroundColor Cyan } +function Info([string]$m) { Write-Host " $m" } +function Ok([string]$m) { Write-Host "[OK] $m" -ForegroundColor Green } +function Bad([string]$m) { Write-Host "[FAIL] $m" -ForegroundColor Red } + +$installDirNorm = $InstallDir.TrimEnd('\','/').Replace('/', '\') +$nodeDir = Join-Path $installDirNorm "node" +$nodeExe = Join-Path $nodeDir "node.exe" +$npmCmd = Join-Path $nodeDir "npm.cmd" +$openClawDir = Join-Path $installDirNorm "node_modules\openclaw" +$downloadDir = Join-Path $installDirNorm "_download" + +try { + Step "Node.js v$NodeVersion for win-x64" + if ((Test-Path $nodeExe) -and -not $Force) { + $existing = & $nodeExe --version + Info "already installed at $nodeExe (version $existing) -- pass -Force to re-fetch" + } else { + New-Item -ItemType Directory -Force $downloadDir | Out-Null + $distBase = "https://nodejs.org/dist/v$NodeVersion" + $zipName = "node-v$NodeVersion-win-x64.zip" + $zipPath = Join-Path $downloadDir $zipName + + Info "downloading $distBase/$zipName" + Invoke-WebRequest -Uri "$distBase/$zipName" -OutFile $zipPath + + Info "verifying SHA256 against $distBase/SHASUMS256.txt" + $shasums = Invoke-WebRequest -Uri "$distBase/SHASUMS256.txt" -UseBasicParsing | Select-Object -ExpandProperty Content + $expectedLine = ($shasums -split "`n") | Where-Object { $_ -match [regex]::Escape($zipName) } | Select-Object -First 1 + if (-not $expectedLine) { throw "no SHASUMS256.txt entry found for $zipName -- refusing to install an unverified download" } + $expectedHash = ($expectedLine -split '\s+')[0].Trim().ToLowerInvariant() + $actualHash = (Get-FileHash $zipPath -Algorithm SHA256).Hash.ToLowerInvariant() + if ($expectedHash -ne $actualHash) { + throw "SHA256 mismatch for $zipName`n expected: $expectedHash`n actual: $actualHash`nDeleting the download; do not use it." + } + Ok "SHA256 verified: $actualHash" + + Info "extracting to $nodeDir" + if (Test-Path $nodeDir) { Remove-Item -Recurse -Force $nodeDir } + $extractStaging = Join-Path $downloadDir "extract" + if (Test-Path $extractStaging) { Remove-Item -Recurse -Force $extractStaging } + Expand-Archive -Path $zipPath -DestinationPath $extractStaging -Force + # The zip's own top-level entry is "node-v-win-x64\..."; flatten + # that one level so callers get a stable \node\node.exe path + # regardless of version. + $innerDir = Get-ChildItem $extractStaging -Directory | Select-Object -First 1 + if (-not $innerDir) { throw "unexpected zip layout: no top-level directory found after extraction" } + Move-Item $innerDir.FullName $nodeDir + Remove-Item -Recurse -Force $extractStaging, $zipPath -ErrorAction SilentlyContinue + + if (-not (Test-Path $nodeExe)) { throw "extraction completed but $nodeExe is missing -- unexpected zip layout" } + $installedVersion = & $nodeExe --version + Ok "installed node.exe ($installedVersion) at $nodeExe" + } + + Step "OpenClaw (npm)" + if (-not (Test-Path $npmCmd)) { throw "npm.cmd not found next to node.exe at $npmCmd -- Node.js install looks incomplete" } + # This Node.js install is a standalone zip extraction, not the installer -- + # nothing put it on PATH. npm spawns pre/postinstall scripts (OpenClaw and + # some of its native-addon deps have them) via cmd.exe, and those scripts + # invoke bare "node"; without $nodeDir on PATH that fails with "'node' is + # not recognized...", which in turn makes npm's own cleanup of the + # half-installed tree fail with a wall of unrelated-looking EPERM rmdir + # warnings. Prepend $nodeDir to PATH for this call only. + $env:Path = "$nodeDir;$env:Path" + if ((Test-Path (Join-Path $openClawDir "openclaw.mjs")) -and -not $Force) { + Info "already installed at $openClawDir -- pass -Force to re-install" + } else { + $pkgSpec = if ($OpenClawVersion) { "openclaw@$OpenClawVersion" } else { "openclaw" } + Info "npm install $pkgSpec --prefix $installDirNorm" + # --no-save: this prefix dir isn't a real npm project (no package.json we + # want npm managing); we just want node_modules\openclaw populated. + & $npmCmd install $pkgSpec --prefix $installDirNorm --no-save --no-audit --no-fund + if ($LASTEXITCODE -ne 0) { throw "npm install failed (exit $LASTEXITCODE)" } + if (-not (Test-Path (Join-Path $openClawDir "openclaw.mjs"))) { + throw "npm install succeeded but $openClawDir\openclaw.mjs is missing -- is 'openclaw' really the right package name/layout on the registry you're using?" + } + Ok "installed OpenClaw at $openClawDir" + } + + Remove-Item -Recurse -Force $downloadDir -ErrorAction SilentlyContinue + + Step "Done" + Write-Host "" + Write-Host "Pass these to run-openclaw-forward-test.ps1:" -ForegroundColor Yellow + Write-Host " -NodeExePath `"$nodeExe`"" + Write-Host " -OpenClawInstallDir `"$openClawDir`"" + Write-Host "" + Write-Host "Example:" -ForegroundColor Yellow + Write-Host " powershell -NoProfile -ExecutionPolicy Bypass -File .\run-openclaw-forward-test.ps1 ``" + Write-Host " -WxcExecPath C:\mxc-kit\bin\wxc-exec.exe ``" + Write-Host " -NodeExePath `"$nodeExe`" ``" + Write-Host " -OpenClawInstallDir `"$openClawDir`"" +} +catch { + Bad $_.Exception.Message + exit 1 +} diff --git a/crates/openshell-driver-mxc/examples/mxc-openclaw-gateway.toml b/crates/openshell-driver-mxc/examples/mxc-openclaw-gateway.toml new file mode 100644 index 0000000000..c315125829 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/mxc-openclaw-gateway.toml @@ -0,0 +1,108 @@ +# 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 share_dir before creating the sandbox +# (the AppContainer here can only read paths under share_dir -- 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 — use only the curated set in agent_env below. +pc_minimal_env = true + +share_dir = "C:/openshell-openclaw" +agent_cwd = "C:/openshell-openclaw" + +agent_command = [ + "C:/openshell-openclaw/node.exe", + "C:/openshell-openclaw/openclaw-capture.mjs", + "gateway", "run", "--dev", "--allow-unconfigured", + "--auth", "token", "--bind", "loopback", "--port", "18889", +] + +# Verified-minimal env set (binary-searched down from a 39-entry "copy the +# whole host env" list): each var here was confirmed load-bearing by +# stripping it and re-testing against a live sandbox. Two surprises from that +# search, worth knowing before touching this list: +# - SYSTEMROOT/WINDIR/PATH/COMSPEC alone are NOT enough. Without +# LOCALAPPDATA, CreateProcessW itself fails with ERROR_ENVVAR_NOT_FOUND +# (Win32 203) under this sandbox's appcontainer-dacl fallback tier -- +# before Node.js ever runs. This is a Windows AppContainer-profile +# requirement, not a Node.js one. +# - SYSTEMDRIVE was tested and confirmed NOT required, despite looking +# like a natural companion to SYSTEMROOT. +# Bare keys are resolved from the gateway host env at sandbox creation time. +# KEY=VALUE entries are passed verbatim. +agent_env = [ + "SYSTEMROOT", + "WINDIR", + "PATH", + "COMSPEC", + # LOCALAPPDATA redirected to share_dir: the AppContainer cannot write to the + # host user's AppData path, and OpenClaw uses LOCALAPPDATA as a fallback + # temp dir for its SQLite read-only backup worker. + "LOCALAPPDATA=C:/openshell-openclaw/local", + # Home/temp redirected to share_dir so openclaw can write state + "HOME=C:/openshell-openclaw/home", + "USERPROFILE=C:/openshell-openclaw/home", + "TEMP=C:/openshell-openclaw/temp", + "TMP=C:/openshell-openclaw/temp", + "NEMOCLAW_MXC_CAPTURE_ENTRY=C:/openshell-openclaw/runtime/node_modules/openclaw/openclaw.mjs", + "NEMOCLAW_MXC_CAPTURE_LOG=C:/openshell-openclaw/openclaw-capture.log", + # Node 22 only consults HTTP_PROXY/HTTPS_PROXY for fetch when this option + # is enabled. The driver supplies the actual per-sandbox proxy URL. + "NODE_OPTIONS=--use-env-proxy", + "NEMOCLAW_MXC_EGRESS_PROOF=1", + "NEMOCLAW_MXC_EGRESS_ALLOWED_URL=https://example.com/", + "NEMOCLAW_MXC_EGRESS_DENIED_URL=https://example.org/", + "NEMOCLAW_MXC_EGRESS_DIRECT_HOST=1.1.1.1", + "NEMOCLAW_MXC_EGRESS_LOOPBACK_PORT=29999", + # Credential-free target-side probe used only by the qualification harness. + # It records response byte counts, never response payloads. + "NEMOCLAW_MXC_CAPTURE_SELF_PROBE_PORT=18889", + # Required for a stable/known token instead of a fresh random one every + # restart. run-openclaw-forward-test.ps1 sets this in the gateway's own + # process env before launching it. + "OPENCLAW_GATEWAY_TOKEN", +] + +# Launch agent_command via the generic openshell-supervisor-relay binary +# instead of directly. The driver sends agent_command/agent_env over the +# control channel once the spawner announces readiness (the "launch" +# handshake), and spawns OpenClaw with no relay awareness. pc_relay_target_port +# is OpenClaw's own +# --port above: used as an early liveness check (does the target ever bind +# it?) and as the target port openshell-supervisor-relay bridges by default. +# There is no static bridge -- relay bridging is entirely on-demand via +# ForwardSink::open_dynamic_forward / the control channel's "forward" op, +# driven by `openshell forward service --target-port 18889`. +pc_relay_spawner_path = "C:/openshell-openclaw/openshell-supervisor-relay.exe" +pc_relay_target_port = 18889 + +debug = true diff --git a/crates/openshell-driver-mxc/examples/mxc-openclaw-isolation.toml b/crates/openshell-driver-mxc/examples/mxc-openclaw-isolation.toml new file mode 100644 index 0000000000..6faa69bd24 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/mxc-openclaw-isolation.toml @@ -0,0 +1,70 @@ +# 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 injected agent_env 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 (see mxc-openclaw-gateway.toml's +# agent_env comment for that whole story). +# - 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 +# share_dir before creating the sandbox, same as the ProcessContainer path. + +[openshell.drivers.mxc] +wxc_exec_path = "C:\\mxc-kit\\bin\\wxc-exec.exe" + +backend = "isolation_session" +default_configuration_id = "composable" + +share_dir = "C:/openshell-openclaw" +agent_cwd = "C:/openshell-openclaw" + +agent_command = [ + "C:/openshell-openclaw/node.exe", + "C:/openshell-openclaw/openclaw-capture.mjs", + "gateway", "run", "--dev", "--allow-unconfigured", + "--auth", "token", "--bind", "loopback", "--port", "18889", +] + +agent_env = [ + "OPENCLAW_NO_UPDATE_CHECK=1", + "NO_UPDATE_NOTIFIER=1", + "HOME=C:/openshell-openclaw/home", + "USERPROFILE=C:/openshell-openclaw/home", + "TEMP=C:/openshell-openclaw/temp", + "TMP=C:/openshell-openclaw/temp", + "NEMOCLAW_MXC_CAPTURE_ENTRY=C:/openshell-openclaw/runtime/node_modules/openclaw/openclaw.mjs", + "NEMOCLAW_MXC_CAPTURE_LOG=C:/openshell-openclaw/openclaw-capture.log", + # Credential-free target-side probe used only by the qualification harness. + # It records response byte counts, never response payloads. + "NEMOCLAW_MXC_CAPTURE_SELF_PROBE_PORT=18889", + # Required for a stable/known token instead of a fresh random one every + # restart. run-openclaw-forward-test.ps1 sets this in the gateway's own + # process env before launching it. + "OPENCLAW_GATEWAY_TOKEN", +] + +# No static bridge -- relay bridging is entirely on-demand via +# ForwardSink::open_dynamic_forward / the control channel's "forward" op, +# driven by `openshell forward service --target-port 18889`. +pc_relay_spawner_path = "C:/openshell-openclaw/openshell-supervisor-relay.exe" +pc_relay_target_port = 18889 + +debug = true diff --git a/crates/openshell-driver-mxc/examples/mxc-openclaw-localnet.toml b/crates/openshell-driver-mxc/examples/mxc-openclaw-localnet.toml new file mode 100644 index 0000000000..eab8f27301 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/mxc-openclaw-localnet.toml @@ -0,0 +1,55 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# mxc-openclaw-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. + +share_dir = "C:/openshell-openclaw" +agent_cwd = "C:/openshell-openclaw" + +agent_command = [ + "C:/openshell-openclaw/node.exe", + "C:/openshell-openclaw/openclaw-capture.mjs", + "gateway", "run", "--dev", "--allow-unconfigured", + "--auth", "token", "--bind", "loopback", "--port", "18889", +] + +agent_env = [ + "OPENCLAW_GATEWAY_TOKEN", + "OPENCLAW_NO_UPDATE_CHECK=1", + "NO_UPDATE_NOTIFIER=1", + "HOME=C:/openshell-openclaw/home", + "USERPROFILE=C:/openshell-openclaw/home", + "TEMP=C:/openshell-openclaw/temp", + "TMP=C:/openshell-openclaw/temp", + "NEMOCLAW_MXC_CAPTURE_ENTRY=C:/openshell-openclaw/runtime/node_modules/openclaw/openclaw.mjs", + "NEMOCLAW_MXC_CAPTURE_LOG=C:/openshell-openclaw/openclaw-capture.log", + # Credential-free target-side probe used only by the qualification harness. + # It records response byte counts, never response payloads. + "NEMOCLAW_MXC_CAPTURE_SELF_PROBE_PORT=18889", +] + +# No static bridge -- relay bridging is entirely on-demand via +# ForwardSink::open_dynamic_forward / the control channel's "forward" op, +# driven by `openshell forward service --target-port 18889`. +pc_relay_spawner_path = "C:/openshell-openclaw/openshell-supervisor-relay.exe" +pc_relay_target_port = 18889 + +debug = true diff --git a/crates/openshell-driver-mxc/examples/mxc-ws-agent.rs b/crates/openshell-driver-mxc/examples/mxc-ws-agent.rs new file mode 100644 index 0000000000..dbd63f1426 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/mxc-ws-agent.rs @@ -0,0 +1,622 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! WebSocket demo agent for `OpenShell` MXC `ProcessContainer`. +//! +//! **Active role: `server`** — a plain WebSocket echo server on port 22000, +//! used by `run-ws-agent-test.ps1` as the target application. It is launched +//! directly as `agent_command` and wrapped by `openshell-supervisor-relay.exe` +//! (see `mxc-ws-gateway.toml`'s `pc_relay_spawner_path`/`pc_relay_target_port`) +//! for connectivity, exactly like the `OpenClaw` scenario wraps `node.exe` -- +//! `openshell forward service --target-port 22000` opens an on-demand relay +//! for a host client to reach it. `server` has no relay awareness at all. +//! +//! **Legacy roles: `spawner` and `proxy-for`** — implement an older, +//! *removed* static-relay protocol (`pc_relay_port` config field + a +//! `reverse-relay-addr.txt` file the driver would write into `share_dir` +//! before sandbox creation). The driver no longer supports this: `mxc.rs`/ +//! `driver.rs` have no code path that binds `pc_relay_port` or writes that +//! file, so these modes cannot work against the current driver -- kept in +//! this file only as a historical reference for the pre-dynamic-forward +//! design, not exercised by any current test. + +use std::net::SocketAddr; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::Duration; + +use futures::{SinkExt, StreamExt}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::sync::oneshot; +use tokio_tungstenite::tungstenite::Message; + +const WS_PORT: u16 = 22000; + +// ── Entry point ─────────────────────────────────────────────────────────────── + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let mode = std::env::args().nth(1).unwrap_or_default(); + match mode.as_str() { + "spawner" => spawner().await, + "server" => server().await, + "proxy-for" => { + // proxy-for : start the command in agent-cmd.txt as a child + // process, wait for it to bind , connect outward to the + // gateway relay, and bridge bidirectionally. Used to expose an + // arbitrary WebSocket server (e.g. openclaw gateway) to host + // clients via the OpenShell relay without modifying that server. + let port = std::env::args() + .nth(2) + .and_then(|s| s.parse::().ok()) + .expect("Usage: mxc-ws-agent proxy-for "); + proxy_for(port).await + } + other => { + eprintln!("mxc-ws-agent: unknown mode {other:?}. Use 'spawner' or 'server'."); + std::process::exit(2); + } + } +} + +// ── AppContainer SID (Windows only) ────────────────────────────────────────── + +/// Returns the `AppContainer` SID string of the current process, or `None` if +/// not running in an `AppContainer`. Written to `appcontainer-sid.txt` in the +/// share dir as a diagnostic aid. +#[cfg(windows)] +#[allow(unsafe_code)] // Windows token-query FFI is confined to this diagnostic helper. +fn appcontainer_sid() -> Option { + use std::ptr; + + #[link(name = "advapi32")] + unsafe extern "system" { + fn OpenProcessToken(process: isize, access: u32, token: *mut isize) -> i32; + fn GetTokenInformation( + token: isize, + class: i32, + info: *mut u8, + len: u32, + ret_len: *mut u32, + ) -> i32; + fn ConvertSidToStringSidW(sid: *const u8, str_sid: *mut *mut u16) -> i32; + } + #[link(name = "kernel32")] + unsafe extern "system" { + fn GetCurrentProcess() -> isize; + fn LocalFree(mem: *mut u8) -> *mut u8; + fn CloseHandle(handle: isize) -> i32; + } + + // SAFETY: output buffers remain alive through each call; successful token + // information contains a SID pointer into that buffer. The SID conversion + // returns a NUL-terminated allocation freed with LocalFree, and the owned + // token handle is closed on every return path. + unsafe { + let proc = GetCurrentProcess(); + let mut token: isize = 0; + if OpenProcessToken(proc, 0x0008, &raw mut token) == 0 { + return None; + } + let mut buf = [0u8; 256]; + let mut ret_len: u32 = 0; + let ok = GetTokenInformation(token, 31, buf.as_mut_ptr(), 256, &raw mut ret_len); + if ok == 0 { + CloseHandle(token); + return None; + } + let sid_ptr = ptr::read_unaligned(buf.as_ptr().cast::<*const u8>()); + if sid_ptr.is_null() { + CloseHandle(token); + return None; + } + let mut wide_ptr: *mut u16 = ptr::null_mut(); + if ConvertSidToStringSidW(sid_ptr, &raw mut wide_ptr) == 0 || wide_ptr.is_null() { + CloseHandle(token); + return None; + } + let mut len = 0; + while *wide_ptr.add(len) != 0 { + len += 1; + } + let slice = std::slice::from_raw_parts(wide_ptr, len); + let result = String::from_utf16_lossy(slice); + LocalFree(wide_ptr.cast()); + CloseHandle(token); + if result.is_empty() { + None + } else { + Some(result) + } + } +} + +#[cfg(not(windows))] +fn appcontainer_sid() -> Option { + None +} + +// ── Signal / relay address helpers ──────────────────────────────────────────── + +fn exe_dir() -> anyhow::Result { + Ok(std::env::current_exe()? + .parent() + .ok_or_else(|| anyhow::anyhow!("exe has no parent dir"))? + .to_path_buf()) +} + +fn signal_file_path() -> anyhow::Result { + Ok(exe_dir()?.join("openshell-shutdown.signal")) +} + +/// Read a file written by the host (ASCII, possibly with UTF-8 BOM from +/// `PowerShell` Set-Content -Encoding UTF8) and return the trimmed string. +fn read_host_file(path: &std::path::Path) -> Option { + std::fs::read_to_string(path) + .ok() + .map(|s| s.trim_start_matches('\u{FEFF}').trim().to_string()) + .filter(|s| !s.is_empty()) +} + +// ── Spawner ─────────────────────────────────────────────────────────────────── + +/// Process #1 — the sandbox `agent_command`. +/// +/// Responsibilities: +/// 1. Spawns the server subprocess and holds its stdin pipe. +/// 2. Waits for the server to bind its port (ws-server-started.txt). +/// 3. Reads reverse-relay-addr.txt and starts the relay proxy bridge. +/// 4. Polls for the shutdown signal file; on detection kills the server and exits. +/// +/// The server is a plain WebSocket application with no relay knowledge. +async fn spawner() -> anyhow::Result<()> { + let exe = std::env::current_exe()?; + let dir = exe_dir()?; + let signal = signal_file_path()?; + + // Remove stale files from a previous run. ws-server-started.txt in + // particular must go too: it encodes the server's port, and a stale copy + // would let the readiness wait below observe an old run's port instead + // of actually waiting for this run's server to (re)bind. + let _ = std::fs::remove_file(&signal); + if let Ok(dir) = exe_dir() { + let _ = std::fs::remove_file(dir.join("relay-ready.txt")); + let _ = std::fs::remove_file(dir.join("ws-server-started.txt")); + } + + // Write AppContainer SID for diagnostic use. + if let Some(sid) = appcontainer_sid() { + let _ = std::fs::write(dir.join("appcontainer-sid.txt"), &sid); + eprintln!("[spawner] AppContainer SID: {sid}"); + } else { + eprintln!("[spawner] not running in an AppContainer (no SID)"); + } + + // Spawn the server. + let mut cmd = tokio::process::Command::new(&exe); + cmd.arg("server") + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()); + + let mut child = cmd.spawn()?; + let _pipe = child.stdin.take(); // keep write-end alive + eprintln!("[spawner] server started (pid {:?})", child.id()); + + // Wait up to 30 s for the server to write its startup marker, then start + // the relay proxy. We launch the proxy in a background task so the main + // lifecycle loop can still react to shutdown signals and server exit. + let relay_addr_file = dir.join("reverse-relay-addr.txt"); + let server_marker = dir.join("ws-server-started.txt"); + + let server_ready_deadline = tokio::time::Instant::now() + Duration::from_secs(30); + loop { + if server_marker.exists() { + break; + } + if tokio::time::Instant::now() >= server_ready_deadline { + eprintln!("[spawner] server did not start within 30 s; relay proxy skipped"); + break; + } + tokio::time::sleep(Duration::from_millis(200)).await; + } + + // Read the local server port from ws-server-started.txt ("port="). + let local_ws_port = read_host_file(&server_marker) + .and_then(|s| s.strip_prefix("port=").and_then(|n| n.parse::().ok())) + .unwrap_or(WS_PORT); + + // Start the relay proxy bridge if the gateway relay address is available. + let mut relay_task = read_host_file(&relay_addr_file).map_or_else( + || { + eprintln!("[spawner] no reverse-relay-addr.txt; relay proxy disabled"); + None + }, + |relay_addr| { + let local_url = format!("ws://127.0.0.1:{local_ws_port}"); + let relay_url = format!("ws://{relay_addr}"); + eprintln!("[spawner] starting relay proxy: {relay_url} <-> {local_url}"); + + let (bridge_stop_tx, bridge_stop_rx) = oneshot::channel::<()>(); + tokio::spawn(run_relay_proxy(relay_url, local_url, bridge_stop_rx)); + Some(bridge_stop_tx) + }, + ); + + // Main lifecycle loop: server exit or shutdown signal. + loop { + tokio::select! { + status = child.wait() => { + let code = status.map_or(1, |s| s.code().unwrap_or(1)); + eprintln!("[spawner] server exited with code {code}"); + let _ = std::fs::remove_file(&signal); + std::process::exit(code); + } + () = tokio::time::sleep(Duration::from_millis(500)) => { + if signal.exists() { + eprintln!("[spawner] shutdown signal -- killing server"); + // Stop the relay proxy first so the relay connection closes + // cleanly before we tear down the server. + if let Some(tx) = relay_task.take() { let _ = tx.send(()); } + let _ = child.kill().await; + let _ = child.wait().await; + let _ = std::fs::remove_file(&signal); + eprintln!("[spawner] done"); + std::process::exit(0); + } + } + } + } +} + +// ── Relay proxy bridge ──────────────────────────────────────────────────────── + +/// Connect outward to the gateway relay and inward to the local server, then +/// bridge WebSocket messages bidirectionally until either side closes or the +/// shutdown signal fires. +/// +/// The local server is a plain WebSocket application. The spawner acts as a +/// transparent proxy between the gateway relay and the local server, so the +/// server needs no knowledge of the relay. +async fn run_relay_proxy(relay_url: String, local_url: String, mut stop_rx: oneshot::Receiver<()>) { + const LOCAL_CONNECT_ATTEMPTS: u32 = 15; + const LOCAL_CONNECT_TIMEOUT: Duration = Duration::from_millis(500); + const LOCAL_CONNECT_BACKOFF: Duration = Duration::from_millis(300); + // Connect to the gateway relay (outbound via egress_proxy). + let relay_ws = match tokio_tungstenite::connect_async(&relay_url).await { + Ok((ws, _)) => { + eprintln!("[spawner] relay connected: {relay_url}"); + ws + } + Err(e) => { + let msg = format!("relay connect failed: {e}"); + eprintln!("[spawner] {msg}"); + if let Ok(dir) = exe_dir() { + let _ = std::fs::write(dir.join("relay-debug.txt"), &msg); + } + return; + } + }; + + // Connect to the local server (AppContainer-internal loopback), with + // retries. The in-sandbox server and the spawner's own AppContainer + // network-permission state can still be settling when this fires, so the + // first attempt can race the server's listen() call or a brief + // AppContainer network-policy warmup window. Without retry, a lost race + // manifests as a ~20s OS-level connect timeout (os error 10060) rather + // than an instant refusal, because the SYN is silently dropped, not + // rejected -- so each retry attempt uses a short timeout instead of + // waiting out that OS timeout on every try. + let mut local_ws = None; + let mut last_err = String::new(); + for attempt in 1..=LOCAL_CONNECT_ATTEMPTS { + match tokio::time::timeout( + LOCAL_CONNECT_TIMEOUT, + tokio_tungstenite::connect_async(&local_url), + ) + .await + { + Ok(Ok((ws, _))) => { + eprintln!( + "[spawner] local server connected: {local_url} (attempt {attempt}/{LOCAL_CONNECT_ATTEMPTS})" + ); + local_ws = Some(ws); + break; + } + Ok(Err(e)) => last_err = e.to_string(), + Err(_) => last_err = format!("timed out after {LOCAL_CONNECT_TIMEOUT:?}"), + } + eprintln!( + "[spawner] local server connect attempt {attempt}/{LOCAL_CONNECT_ATTEMPTS} failed ({last_err}); retrying" + ); + if attempt < LOCAL_CONNECT_ATTEMPTS { + tokio::time::sleep(LOCAL_CONNECT_BACKOFF).await; + } + } + let Some(local_ws) = local_ws else { + let msg = format!( + "local server connect failed after {LOCAL_CONNECT_ATTEMPTS} attempts ({local_url}): {last_err}" + ); + eprintln!("[spawner] {msg}"); + if let Ok(dir) = exe_dir() { + let _ = std::fs::write(dir.join("relay-debug.txt"), &msg); + } + return; + }; + + let (mut relay_write, mut relay_read) = relay_ws.split(); + let (mut local_write, mut local_read) = local_ws.split(); + + eprintln!("[spawner] relay proxy bridge active"); + + // Write a marker so the host can wait until the bridge is fully connected + // before sending the first message. + if let Ok(dir) = exe_dir() { + let _ = std::fs::write(dir.join("relay-ready.txt"), b"ok"); + } + + loop { + tokio::select! { + // Relay -> local server + msg = relay_read.next() => match msg { + Some(Ok(Message::Text(t))) => { + if local_write.send(Message::Text(t)).await.is_err() { break; } + } + Some(Ok(Message::Binary(b))) => { + if local_write.send(Message::Binary(b)).await.is_err() { break; } + } + Some(Ok(Message::Close(_))) | None => { + eprintln!("[spawner] relay closed"); + break; + } + Some(Ok(_)) => {} // ping/pong + Some(Err(e)) => { + eprintln!("[spawner] relay read error: {e}"); + break; + } + }, + // Local server -> relay + msg = local_read.next() => match msg { + Some(Ok(Message::Text(t))) => { + if relay_write.send(Message::Text(t)).await.is_err() { break; } + } + Some(Ok(Message::Binary(b))) => { + if relay_write.send(Message::Binary(b)).await.is_err() { break; } + } + Some(Ok(Message::Close(_))) | None => { + eprintln!("[spawner] local server closed"); + break; + } + Some(Ok(_)) => {} + Some(Err(e)) => { + eprintln!("[spawner] local server read error: {e}"); + break; + } + }, + _ = &mut stop_rx => { + eprintln!("[spawner] relay proxy stopped by shutdown"); + break; + } + } + } + + eprintln!("[spawner] relay proxy bridge exited"); +} + +// ── Server ──────────────────────────────────────────────────────────────────── + +/// Process #2 — a plain WebSocket echo server. +/// +/// This is a stand-in for any real application. It has no knowledge of any +/// relay -- launched directly as the `agent_command` `openshell-supervisor- +/// relay.exe` wraps (see mxc-ws-gateway.toml's `pc_relay_spawner_path`/ +/// `pc_relay_target_port`), the same way `OpenClaw`'s gateway is. Runs until +/// killed; there is no cooperative shutdown protocol to implement (the +/// generic spawner just kills its target on shutdown, same as any other +/// wrapped process), so this loops on `listener.accept()` alone. +async fn server() -> anyhow::Result<()> { + let listener = TcpListener::bind(("0.0.0.0", WS_PORT)).await?; + eprintln!("[server] WebSocket listening on 0.0.0.0:{WS_PORT}"); + + let active = Arc::new(AtomicUsize::new(0)); + + loop { + let (stream, addr) = listener.accept().await?; + let active2 = active.clone(); + active2.fetch_add(1, Ordering::Relaxed); + tokio::spawn(async move { + handle_connection(stream, addr).await; + active2.fetch_sub(1, Ordering::Relaxed); + }); + } +} + +// ── WebSocket connection handler ────────────────────────────────────────────── + +async fn handle_connection(stream: TcpStream, addr: SocketAddr) { + eprintln!("[server] new connection from {addr}"); + + let ws = match tokio_tungstenite::accept_async(stream).await { + Ok(ws) => ws, + Err(e) => { + eprintln!("[server] handshake failed from {addr}: {e}"); + return; + } + }; + + let (mut write, mut read) = ws.split(); + + while let Some(msg) = read.next().await { + match msg { + Ok(Message::Text(text)) => { + eprintln!("[server] {addr} recv: {text}"); + if write.send(Message::Text(text)).await.is_err() { + break; + } + } + Ok(Message::Binary(bin)) => { + if write.send(Message::Binary(bin)).await.is_err() { + break; + } + } + Ok(Message::Close(_)) => break, + Ok(_) => {} + Err(e) => { + eprintln!("[server] {addr} error: {e}"); + break; + } + } + } + + eprintln!("[server] connection from {addr} ended"); +} + +// ── proxy-for mode ──────────────────────────────────────────────────────────── + +/// Start the command listed in `agent-cmd.txt` in the share dir as a child +/// process, wait for it to accept TCP on `port`, then connect outward to the +/// gateway relay and bridge all WebSocket traffic to/from the local server. +/// +/// This lets any WebSocket server (e.g. openclaw gateway) be exposed to host +/// clients via the `OpenShell` relay without any changes to that server. +async fn proxy_for(port: u16) -> anyhow::Result<()> { + // Early sentinel: write to exe_dir so it works in any container directory. + if let Ok(exe) = std::env::current_exe() + && let Some(d) = exe.parent() + { + let _ = std::fs::write( + d.join("proxy-for-started.txt"), + format!("port={port} exe={}", exe.display()), + ); + } + + let dir = match exe_dir() { + Ok(d) => d, + Err(e) => { + let _ = std::fs::write( + "C:\\work\\openshell-mxc-openclaw\\proxy-for-error.txt", + format!("exe_dir failed: {e}"), + ); + return Err(e); + } + }; + let signal = signal_file_path()?; + let _ = std::fs::remove_file(&signal); + if let Ok(d) = exe_dir() { + let _ = std::fs::remove_file(d.join("relay-ready.txt")); + } + + // Read the command to launch from agent-cmd.txt in the share dir. + // Each line is one argument; the first line is the executable. + let cmd_file = dir.join("agent-cmd.txt"); + let mut child = if cmd_file.exists() { + let lines: Vec = std::fs::read_to_string(&cmd_file)? + .lines() + .map(|l| l.trim_start_matches('\u{FEFF}').trim().to_string()) + .filter(|l| !l.is_empty()) + .collect(); + if lines.is_empty() { + anyhow::bail!("agent-cmd.txt is empty"); + } + let stdout_file = std::fs::File::create(dir.join("agent-stdout.txt")).ok(); + let stderr_file = std::fs::File::create(dir.join("agent-stderr.txt")).ok(); + // If agent-env.txt exists in the share dir, set the child process env + // explicitly (clear parent env, then set only those vars). This allows + // running runtimes like node.js that fail with STATUS_DLL_INIT_FAILED + // when presented with the full host env, while mxc-ws-agent itself + // (the parent) still runs with the full env it needs. + // agent-env.txt format: one KEY=VALUE per line. + let env_file = dir.join("agent-env.txt"); + let mut cmd = tokio::process::Command::new(&lines[0]); + cmd.args(&lines[1..]); + if env_file.exists() + && let Ok(content) = std::fs::read_to_string(&env_file) + { + let child_env: Vec<(String, String)> = content + .lines() + .map(|l| l.trim_start_matches('\u{FEFF}').trim().to_string()) + .filter(|l| !l.is_empty() && l.contains('=')) + .filter_map(|l| { + let pos = l.find('=')?; + Some((l[..pos].to_string(), l[pos + 1..].to_string())) + }) + .collect(); + eprintln!( + "[proxy-for] using {} child env vars from agent-env.txt", + child_env.len() + ); + cmd.env_clear().envs(child_env); + } + cmd.stdout( + stdout_file.map_or_else(std::process::Stdio::inherit, std::process::Stdio::from), + ) + .stderr(stderr_file.map_or_else(std::process::Stdio::inherit, std::process::Stdio::from)); + eprintln!("[proxy-for] starting: {}", lines.join(" ")); + Some(cmd.spawn()?) + } else { + eprintln!("[proxy-for] no agent-cmd.txt; assuming server already running on port {port}"); + None + }; + + // Wait up to 60 s for the server to accept TCP on `port`. + eprintln!("[proxy-for] waiting for server on 127.0.0.1:{port} ..."); + let deadline = tokio::time::Instant::now() + Duration::from_mins(1); + loop { + if TcpStream::connect(format!("127.0.0.1:{port}")) + .await + .is_ok() + { + eprintln!("[proxy-for] server is up on port {port}"); + break; + } + if tokio::time::Instant::now() >= deadline { + anyhow::bail!("[proxy-for] timeout waiting for server on port {port}"); + } + tokio::time::sleep(Duration::from_millis(500)).await; + // Also check for early child exit + if let Some(ref mut c) = child + && let Ok(Some(status)) = c.try_wait() + { + anyhow::bail!("[proxy-for] child exited early: {status}"); + } + } + + // Connect relay and run bridge. + let relay_addr_file = dir.join("reverse-relay-addr.txt"); + if let Some(relay_addr) = read_host_file(&relay_addr_file) { + let relay_url = format!("ws://{relay_addr}"); + let local_url = format!("ws://127.0.0.1:{port}"); + eprintln!("[proxy-for] relay bridge: {relay_url} <-> {local_url}"); + let (bridge_stop_tx, bridge_stop_rx) = oneshot::channel::<()>(); + tokio::spawn(run_relay_proxy(relay_url, local_url, bridge_stop_rx)); + + // Main lifecycle: child exit or shutdown signal. + loop { + tokio::select! { + status = async { + if let Some(ref mut c) = child { c.wait().await.ok() } else { std::future::pending().await } + } => { + eprintln!("[proxy-for] child exited: {status:?}"); + let _ = std::fs::remove_file(&signal); + break; + } + () = tokio::time::sleep(Duration::from_millis(500)) => { + if signal.exists() { + eprintln!("[proxy-for] shutdown signal -- stopping"); + let _ = bridge_stop_tx.send(()); + if let Some(ref mut c) = child { let _ = c.kill().await; let _ = c.wait().await; } + let _ = std::fs::remove_file(&signal); + break; + } + } + } + } + } else { + eprintln!("[proxy-for] no reverse-relay-addr.txt; relay bridge disabled"); + // Still manage child lifecycle. + if let Some(mut c) = child { + let _ = c.wait().await; + } + } + Ok(()) +} diff --git a/crates/openshell-driver-mxc/examples/mxc-ws-gateway.toml b/crates/openshell-driver-mxc/examples/mxc-ws-gateway.toml new file mode 100644 index 0000000000..8d0d2dda8c --- /dev/null +++ b/crates/openshell-driver-mxc/examples/mxc-ws-gateway.toml @@ -0,0 +1,79 @@ +# 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, share_dir, agent_cwd, agent_command, pc_relay_spawner_path) +# are patched at test runtime by the script; the values below are +# safe-to-commit placeholders. +# +# Layout: +# agent_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 agent_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" + +# Host folder mapped read-write into the AppContainer by the policy mapper. +# Must contain mxc-ws-agent.exe and openshell-supervisor-relay.exe. +# Patched at runtime by run-ws-agent-test.ps1. +share_dir = "C:/work/openshell-mxc-ws" + +# Working directory inside the AppContainer. Must match share_dir. +# Patched at runtime by run-ws-agent-test.ps1. +agent_cwd = "C:/work/openshell-mxc-ws" + +# The target application -- launched via the spawner wrapper below, not +# directly. Patched at runtime by run-ws-agent-test.ps1. +agent_command = [ + "C:/work/openshell-mxc-ws/mxc-ws-agent.exe", + "server", +] + +# Launch agent_command via the generic openshell-supervisor-relay binary. +# Patched at runtime by run-ws-agent-test.ps1. +pc_relay_spawner_path = "C:/work/openshell-mxc-ws/openshell-supervisor-relay.exe" +pc_relay_target_port = 22000 + +# Enable for verbose wxc-exec output during debugging. +debug = true diff --git a/crates/openshell-driver-mxc/examples/openclaw-capture.mjs b/crates/openshell-driver-mxc/examples/openclaw-capture.mjs new file mode 100644 index 0000000000..fd3fa41543 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/openclaw-capture.mjs @@ -0,0 +1,202 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// openclaw-capture.mjs - generic launcher/log-capture shim for running an +// arbitrary Node.js entry point as agent_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 agent_env in the gateway TOML): +// NEMOCLAW_MXC_CAPTURE_ENTRY absolute path to the real entry .mjs to run +// (e.g. /openclaw.mjs) +// NEMOCLAW_MXC_CAPTURE_LOG absolute path to append captured output to +// +// Usage: node openclaw-capture.mjs +// Equivalent to: node , except stdout +// and stderr are also appended to NEMOCLAW_MXC_CAPTURE_LOG as they're written. + +import fs, { appendFileSync } from "node:fs"; +import { syncBuiltinESMExports } from "node:module"; +import { createConnection } from "node:net"; +import { pathToFileURL } from "node:url"; +import { promisify } from "node:util"; + +// Node's promises realpath implementation uses a native Windows binding that +// requests privileges unavailable to AppContainer tokens. The callback +// implementation has the same realpath contract without those privileges. +// Patch before importing OpenClaw so node:fs/promises consumers see it too. +if (process.platform === "win32") { + fs.promises.realpath = promisify(fs.realpath); + syncBuiltinESMExports(); +} + +const required = (name) => { + const value = process.env[name]; + if (!value) throw new Error(name + " is required"); + return value; +}; + +const entry = required("NEMOCLAW_MXC_CAPTURE_ENTRY"); +const logPath = required("NEMOCLAW_MXC_CAPTURE_LOG"); +const selfProbePort = process.env.NEMOCLAW_MXC_CAPTURE_SELF_PROBE_PORT; + +const append = (label, value) => { + try { + appendFileSync(logPath, "[" + label + "] " + String(value), "utf8"); + } catch { + // best-effort; never let logging failure take down the wrapped process + } +}; + +const connectProbe = (host, port, timeoutMs = 5000) => + new Promise((resolve) => { + let settled = false; + const socket = createConnection({ host, port: Number(port) }); + const finish = (connected, detail) => { + if (settled) return; + settled = true; + clearTimeout(deadline); + socket.destroy(); + resolve({ connected, detail }); + }; + const deadline = setTimeout(() => finish(false, "timeout"), timeoutMs); + socket.once("connect", () => finish(true, "connected")); + socket.once("error", (error) => + finish(false, String(error?.code || error?.message || error)), + ); + }); + +const fetchProbe = async (url, timeoutMs = 15000) => { + try { + const response = await fetch(url, { + redirect: "manual", + signal: AbortSignal.timeout(timeoutMs), + }); + await response.body?.cancel(); + return { connected: true, detail: "status=" + response.status }; + } catch (error) { + return { + connected: false, + detail: String(error?.cause?.code || error?.code || error?.message || error), + }; + } +}; + +const runEgressProof = async () => { + if (process.env.NEMOCLAW_MXC_EGRESS_PROOF !== "1") return; + const result = { + proxyConfigured: Boolean(process.env.HTTPS_PROXY || process.env.https_proxy), + allowedViaProxy: await fetchProbe(required("NEMOCLAW_MXC_EGRESS_ALLOWED_URL")), + deniedViaProxy: await fetchProbe(required("NEMOCLAW_MXC_EGRESS_DENIED_URL")), + directInternetBypass: await connectProbe( + required("NEMOCLAW_MXC_EGRESS_DIRECT_HOST"), + 443, + ), + unrelatedHostLoopback: await connectProbe( + "127.0.0.1", + required("NEMOCLAW_MXC_EGRESS_LOOPBACK_PORT"), + ), + }; + append("egress-proof", JSON.stringify(result) + "\n"); + console.log("[egress-proof] " + JSON.stringify(result)); +}; + +let selfProbeStarted = false; +let readinessWindow = ""; + +const startSelfProbe = () => { + if (selfProbeStarted || !selfProbePort) return; + selfProbeStarted = true; + append("self-probe-attempt", "started\n"); + + const port = Number(selfProbePort); + if (!Number.isSafeInteger(port) || port < 1 || port > 65535) { + append("self-probe", "invalid_port\n"); + return; + } + + const maxAttempts = 6; + let attempt = 0; + const probe = () => { + attempt += 1; + let responseBytes = 0; + let settled = false; + const socket = createConnection({ host: "127.0.0.1", port }); + const finish = (outcome, errorCode = "none") => { + if (settled) return; + settled = true; + clearTimeout(deadline); + socket.destroy(); + if (responseBytes > 0 || attempt >= maxAttempts) { + append( + "self-probe", + `outcome=${responseBytes > 0 ? "response" : outcome} response_bytes=${responseBytes} attempts=${attempt} error_code=${errorCode}\n`, + ); + } else { + setTimeout(probe, 1000); + } + }; + const deadline = setTimeout(() => finish("timeout"), 2000); + + socket.once("connect", () => { + // This is the same protocol boundary exercised by the host-side health + // client, but it intentionally sends no token or other credential. Any + // HTTP or WebSocket response proves the target can service a connection + // from inside the ProcessContainer; payload content is never recorded. + socket.write( + `GET / HTTP/1.1\r\nHost: 127.0.0.1:${port}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: b3BlbnNoZWxsLW14YyE=\r\nSec-WebSocket-Version: 13\r\n\r\n`, + ); + }); + socket.on("data", (chunk) => { + responseBytes += chunk.length; + finish("response"); + }); + socket.once("error", (error) => + finish("error", String(error?.code || "unknown").replace(/[^A-Z0-9_-]/gi, "_")), + ); + socket.once("close", () => { + if (!settled) finish(responseBytes > 0 ? "response" : "closed"); + }); + }; + probe(); +}; + +const wrap = (stream, label) => { + const original = stream.write.bind(stream); + stream.write = (chunk, encoding, callback) => { + const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk); + append(label, text); + // Logging libraries may split one rendered line across multiple writes + // (and may insert ANSI sequences), so detect readiness across chunk + // boundaries rather than requiring one exact write call. + readinessWindow = (readinessWindow + text).slice(-512); + if (/\[gateway\][\s\S]{0,256}ready/.test(readinessWindow)) startSelfProbe(); + return original(chunk, encoding, callback); + }; +}; + +wrap(process.stdout, "stdout"); +wrap(process.stderr, "stderr"); +append("self-probe", selfProbePort ? "configured\n" : "disabled\n"); +if (selfProbePort) { + // Readiness normally triggers the probe immediately. Keep a delayed + // fallback because some logging stacks bypass or split stdout writes in a + // way the wrapper cannot observe reliably. + setTimeout(startSelfProbe, 10000); +} +process.on("uncaughtExceptionMonitor", (error) => + append("uncaught", String(error?.stack || error) + "\n"), +); +process.on("unhandledRejection", (error) => + append("rejection", String(error?.stack || error) + "\n"), +); + +process.argv = [process.execPath, entry, ...process.argv.slice(2)]; +await runEgressProof(); +await import(pathToFileURL(entry).href); diff --git a/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 b/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 index 46536ff4a3..0cefca119b 100644 --- a/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 +++ b/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 @@ -146,6 +146,7 @@ $policyDir = Join-Path $here "e2e-policies" $cmdExe = "C:\Windows\System32\cmd.exe" $demoDirFwd = $DemoDir.Replace('\', '/') +$defaultDemoDir = "C:\work\openshell-mxc-e2e" $roSrc = "$DemoDir-ro-src" # matches e2e-policies/fs-readonly.yaml read_only path $denyProbe = "$DemoDir-deny-probe" # ungranted, NOT the share: used to prove default-deny @@ -515,6 +516,19 @@ try { continue } + # Render a disposable policy for every scenario. The source YAML + # intentionally carries the documented default paths, while + # -DemoDir is a supported override. Both the read-write path and + # the read-only sibling share the same default prefix, so one + # exact prefix substitution keeps their relative naming intact. + $policyUsed = Join-Path $resultDir "policy.$($sc.Name).yaml" + $policyText = Get-Content $sc.PolicyFile -Raw + $policyText = $policyText.Replace( + $defaultDemoDir.Replace('\', '/'), + $DemoDir.Replace('\', '/') + ) + Set-Content -Path $policyUsed -Value $policyText -Encoding UTF8 + # Per-scenario gateway logs land in the bundle under their own names. $gwLog = Join-Path $resultDir "gateway.$($sc.Name).log" $gwErrLog = Join-Path $resultDir "gateway.$($sc.Name).err.log" @@ -547,7 +561,8 @@ try { Render-Toml # Preserve the exact rendered config + policy fixture used for this scenario. Copy-Item $toml (Join-Path $resultDir "mxc-gateway.$($sc.Name).toml") -Force -ErrorAction SilentlyContinue - Copy-Item $sc.PolicyFile (Join-Path $resultDir "policy.$($sc.Name).yaml") -Force -ErrorAction SilentlyContinue + # policyUsed already lives in the result bundle and is the exact + # rendered policy passed to OpenShell. $gw = Start-Gw Info "gateway pid $($gw.Id)" @@ -563,7 +578,7 @@ try { try { $createResult = Invoke-NativeCaptured $cli @( "sandbox", "create", "--name", $sandboxName, - "--policy", [string]$sc.PolicyFile, + "--policy", [string]$policyUsed, "--driver-config-json", $driverConfig, "--no-tty" ) diff --git a/crates/openshell-driver-mxc/examples/run-openclaw-forward-test.ps1 b/crates/openshell-driver-mxc/examples/run-openclaw-forward-test.ps1 new file mode 100644 index 0000000000..65b8eae884 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/run-openclaw-forward-test.ps1 @@ -0,0 +1,692 @@ +# 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 agent_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 agent_env + # (mxc-openclaw-gateway.toml). isolation_session: persistent + # provision/start/exec session, merges agent_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 (C:/openshell-openclaw) + # as a literal in share_dir, agent_cwd, agent_command, pc_relay_spawner_path, + # and the NEMOCLAW_MXC_CAPTURE_*/HOME/TEMP agent_env entries -- not just + # wxc_exec_path. When -ShareDir overrides the default, every one of those + # needs to move too, or the sandbox ends up reading/writing the wrong + # directory while wxc_exec_path alone points at the right wxc-exec. + $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 + 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 a bare key + # in agent_env, resolved from THIS process's env at sandbox-create time, + # so setting it here gives the sandboxed OpenClaw a stable, known token + # instead of a fresh random one every restart. + $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)" } + } + try { $createOut = & $cli sandbox create --name $SandboxName --policy $policyUsed --no-tty -- exit 2>&1; $createCode = $LASTEXITCODE } + catch { $createOut = $_.Exception.Message; $createCode = 1 } + $createBenign = Show-SandboxCreate $createOut $SandboxName + if ($createCode -ne 0 -and -not $createBenign) { + throw "sandbox create '$SandboxName' failed (exit $createCode): $($createOut | Out-String)" + } + + # 9. Wait for OpenClaw's gateway to report ready, by tailing the gateway's + # own log for the line it prints on successful startup (forwarded from + # the sandbox's stdout via "wxc-exec stdout:"). Generous timeout: Node + # startup + AppContainer/UAC elevation + plugin warmup can take a while + # on a cold run. + Step "Wait for OpenClaw gateway readiness" + $readyDeadline = (Get-Date).AddSeconds(90) + $openclawReady = $false + while ((Get-Date) -lt $readyDeadline) { + if (Test-Path $gwLog) { + # `.*` (not `\s+`) between "[gateway]" and "ready": OpenClaw wraps its + # log lines in ANSI color codes whenever it inherits enough of the host + # env to detect a color-capable terminal -- which happens with + # mxc-openclaw-localnet.toml (-UseLocalNetwork), since that config + # doesn't set pc_minimal_env and so inherits the full host env, unlike + # mxc-openclaw-gateway.toml's curated minimal set. A strict \s+ match + # missed this entirely and timed out waiting for a line that had + # already printed. Those codes render in this log as LITERAL backslash- + # escaped text (e.g. "...\x1b[36mready..."), not real ESC bytes -- so + # "m" from "36m" directly abuts "ready" with no word boundary, which is + # why a \bready\b tightening (tried once) also failed to match; a bare + # substring check is what actually works here. The resulting collision + # risk with "already" is theoretical -- no such line has been observed + # on this "[gateway]"-tagged forwarded-stdout path in practice. + if (Select-String -Path $gwLog -Pattern '\[gateway\].*ready' -Quiet -ErrorAction SilentlyContinue) { $openclawReady = $true; break } + } + Start-Sleep -Seconds 2 + } + if (-not $openclawReady) { throw "OpenClaw did not report ready within 90s (see gateway.log in the results bundle)" } + Ok "OpenClaw gateway ready" + + # 10. openshell forward service: opens a fresh, on-demand relay for this + # one call and bridges TargetPort (inside the sandbox) to + # ForwardLocalPort (on this host). No port needs to be pre-declared + # anywhere except pc_relay_target_port's startup liveness check. + Step "openshell forward service --target-port $TargetPort --local $ForwardLocalPort" + $fwdProc = Start-Process -FilePath $cli ` + -ArgumentList @("forward", "service", "--target-port", "$TargetPort", "--local", "$ForwardLocalPort", $SandboxName) ` + -WorkingDirectory $here -PassThru -NoNewWindow ` + -RedirectStandardOutput $fwdLog -RedirectStandardError $fwdErrLog + Info "forward pid $($fwdProc.Id)" + $fwdDeadline = (Get-Date).AddSeconds(20); $fwdUp = $false + while ((Get-Date) -lt $fwdDeadline) { + if ($fwdProc.HasExited) { throw "forward process exited early (code $($fwdProc.ExitCode)); see forward.log/forward.err.log" } + if ((Test-Path $fwdLog) -and (Select-String -Path $fwdLog -Pattern 'Forwarding' -Quiet -ErrorAction SilentlyContinue)) { $fwdUp = $true; break } + if ((Test-Path $fwdErrLog) -and (Select-String -Path $fwdErrLog -Pattern 'Forwarding' -Quiet -ErrorAction SilentlyContinue)) { $fwdUp = $true; break } + Start-Sleep -Milliseconds 500 + } + if (-not $fwdUp) { throw "forward did not report 'Forwarding ...' within 20s; see forward.log/forward.err.log" } + Ok "forward active: 127.0.0.1:$ForwardLocalPort -> sandbox:$TargetPort" + + # 11. Real OpenClaw client, on the HOST, through the forwarded port. This + # is the actual end-to-end proof: authenticate + get a real response + # from the sandboxed gateway via the relay, exactly as an external + # client would use `openshell forward service` in practice. + # + # Retried: OpenClaw's own log declares a startup-grace window ("[health- + # monitor] started (interval: 300s, startup-grace: 60s, ...)") after + # printing "[gateway] ready" -- on a slower/more heavily-loaded machine + # (observed on a domain-joined box with corporate AV/EDR) it can still + # be settling internally for longer than that, and a request landing in + # that window gets silently dropped with ZERO trace in any log (not a + # WS close, not an error -- the client's own 10s timeout just fires). + # Each attempt already blocks for up to 10s on failure, so a handful of + # attempts comfortably covers the declared 60s grace without a fixed + # sleep that would either undershoot on a slow box or waste time on a + # fast one. + Step "OpenClaw client: gateway health via the forwarded port" + $healthArgs = @($openClawEntry, "gateway", "health", "--port", "$ForwardLocalPort", "--token", $GatewayToken, "--json") + # Isolate the 2026.7.1 host client from any newer ~/.openclaw schema/state. + $savedOpenClawConfigPath = $env:OPENCLAW_CONFIG_PATH + $savedOpenClawStateDir = $env:OPENCLAW_STATE_DIR + $cleanOpenClawStateDir = Join-Path $ShareDir "home\.openclaw" + try { + $env:OPENCLAW_CONFIG_PATH = Join-Path $cleanOpenClawStateDir "openclaw.json" + $env:OPENCLAW_STATE_DIR = $cleanOpenClawStateDir + # Bumped from 6 -> 14 (2026-09-10): on this box OpenClaw's actual startup + # (port bind -> SQLite agent-db open -> HTTP server listening -> "ready") + # measured ~90s wall clock, longer than 6 attempts' ~60s budget covers -- + # the sandbox was torn down mid-startup before the health check could ever + # succeed. 14 attempts at up to 10s each comfortably covers 90s+ without + # a fixed sleep that would undershoot on a slower box. + $healthAttempts = 14 + for ($attempt = 1; $attempt -le $healthAttempts; $attempt++) { + $healthRaw = & $NodeExePath @healthArgs 2>&1 + $healthRaw | Out-File (Join-Path $resultDir "openclaw-health-raw.txt") -Encoding UTF8 + # --json output is PRETTY-PRINTED (multi-line), not compact -- extract from + # the first '{' to the last '}' across the whole output rather than + # assuming any single line is a complete JSON document. + $rawJoined = ($healthRaw | ForEach-Object { [string]$_ }) -join "`n" + $startIdx = $rawJoined.IndexOf('{') + $endIdx = $rawJoined.LastIndexOf('}') + $healthJson = $null + if ($startIdx -ge 0 -and $endIdx -gt $startIdx) { + $jsonText = $rawJoined.Substring($startIdx, $endIdx - $startIdx + 1) + try { $healthJson = $jsonText | ConvertFrom-Json } catch { Info "could not parse health JSON: $($_.Exception.Message)" } + } + if ($healthJson -and $healthJson.ok -eq $true) { + $passed = $true + Ok "gateway health: ok=true (attempt $attempt/$healthAttempts)" + break + } else { + Info "attempt $attempt/${healthAttempts}: no ok=true response yet$(if ($attempt -lt $healthAttempts) { ' -- retrying (still inside OpenClaws own startup-grace window)' })" + } + } + if (-not $passed) { + Bad "gateway health did not report ok=true after $healthAttempts attempts" + $healthRaw | ForEach-Object { Info "$_" } + } + + # Treat egress as a qualification gate, not just diagnostic output. The + # capture script runs these probes before importing OpenClaw, so the record + # is available by the time gateway health succeeds. + Step "Verify governed egress evidence" + $capturePath = Join-Path $ShareDir "openclaw-capture.log" + $proofMatch = Select-String -Path $capturePath -Pattern '^\[egress-proof\] (?\{.*\})$' -ErrorAction SilentlyContinue | Select-Object -Last 1 + $proof = $null + if ($proofMatch) { + try { $proof = $proofMatch.Matches[0].Groups['json'].Value | ConvertFrom-Json } + catch { Info "could not parse egress proof JSON: $($_.Exception.Message)" } + } + $proofPassed = $proof -and + $proof.proxyConfigured -eq $true -and + $proof.allowedViaProxy.connected -eq $true -and + $proof.deniedViaProxy.connected -eq $false -and + $proof.directInternetBypass.connected -eq $false + if ($proofPassed) { + Ok "allowed host passed proxy; denied host and direct Internet bypass were blocked" + Info "unrelated host loopback reachable: $($proof.unrelatedHostLoopback.connected) (known limitation)" + } else { + $passed = $false + Bad "governed egress proof failed or was not recorded" + } + } finally { + if ($null -eq $savedOpenClawConfigPath) { + Remove-Item Env:OPENCLAW_CONFIG_PATH -ErrorAction SilentlyContinue + } else { + $env:OPENCLAW_CONFIG_PATH = $savedOpenClawConfigPath + } + if ($null -eq $savedOpenClawStateDir) { + Remove-Item Env:OPENCLAW_STATE_DIR -ErrorAction SilentlyContinue + } else { + $env:OPENCLAW_STATE_DIR = $savedOpenClawStateDir + } + } +} +catch { + Bad $_.Exception.Message +} +finally { + # Stop the forward before the sandbox so its relay tears down cleanly. + if ($fwdProc -and -not $fwdProc.HasExited) { + try { Stop-Process -Id $fwdProc.Id -Force -ErrorAction SilentlyContinue } catch {} + } + + # Tear down the sandbox while the gateway is still up (delete needs it). + if ($cli -and $SandboxName) { + try { & $cli sandbox delete $SandboxName 2>&1 | Out-Null } + catch { Info "sandbox teardown '$SandboxName': $($_.Exception.Message) (continuing)" } + } + + if ($KeepRunning -and $gw -and -not $gw.HasExited) { + Info "leaving gateway pid $($gw.Id) running (-KeepRunning); stop with: Stop-Process -Id $($gw.Id) -Force" + } elseif ($gw -and -not $gw.HasExited) { + Step "Cleanup"; Stop-Process -Id $gw.Id -Force -ErrorAction SilentlyContinue + try { $gw.WaitForExit(5000) | Out-Null } catch {} + Info "stopped gateway pid $($gw.Id)" + } + + Step "Gateway log (tail)" + if (Test-Path $gwLog) { + Get-Content $gwLog -Tail 30 -Encoding UTF8 -ErrorAction SilentlyContinue | ForEach-Object { Info $_ } + } + + # Copy the OpenClaw capture log (if it made it far enough to write one) for + # post-hoc debugging, then extract only the credential-free target-side + # self-probe outcome. The probe records a byte count, never response data. + # It is diagnostic and cannot make the end-to-end verdict pass: only the + # authenticated host-side OpenClaw client above owns that verdict. + $captureLog = Join-Path $shareDirNorm "openclaw-capture.log" + if (Test-Path $captureLog) { + Copy-Item $captureLog (Join-Path $resultDir "openclaw-capture.log") -Force -ErrorAction SilentlyContinue + $selfProbeLine = Select-String -Path $captureLog -Pattern '\[self-probe\] outcome=([^ ]+) response_bytes=([0-9]+)' -AllMatches -ErrorAction SilentlyContinue | Select-Object -Last 1 + if ($selfProbeLine -and $selfProbeLine.Matches.Count -gt 0) { + $selfProbeOutcome = $selfProbeLine.Matches[0].Groups[1].Value + $selfProbeResponseBytes = [int64]$selfProbeLine.Matches[0].Groups[2].Value + } elseif (Select-String -Path $captureLog -Pattern '\[self-probe\] invalid_port' -Quiet -ErrorAction SilentlyContinue) { + $selfProbeOutcome = "invalid-port" + } elseif (Select-String -Path $captureLog -Pattern '\[self-probe-attempt\] started' -Quiet -ErrorAction SilentlyContinue) { + $selfProbeOutcome = "started-no-completion" + } + } + if ($selfProbeOutcome -eq "response" -and $selfProbeResponseBytes -gt 0) { + Info "target-side self-probe: response ($selfProbeResponseBytes bytes); OpenClaw serviced a local sandbox connection" + } else { + Info "target-side self-probe: $selfProbeOutcome ($selfProbeResponseBytes bytes); inspect the sandboxed OpenClaw target/event loop" + } + + Step "RESULT" + $verdict = if ($passed) { "PASS" } else { "FAIL" } + $summary = @" +OpenShell MXC OpenClaw + dynamic forward test +===================================================================== +timestamp : $stamp +machine : $env:COMPUTERNAME +verdict : $verdict +sandbox : $SandboxName +backend : $Backend +config : $tomlName +target_port : $TargetPort (inside sandbox) +forward_local_port : $ForwardLocalPort (on this host) +wxc_exec : $WxcExecPath +node_exe : $NodeExePath +openclaw_install : $OpenClawInstallDir +target_self_probe : $selfProbeOutcome ($selfProbeResponseBytes response bytes; diagnostic only) + +What PASS means: the gateway created a sandbox on the $Backend backend (no +in-sandbox supervisor process; ProcessContainer also has no inbound network +capability at all); openshell-supervisor-relay launched OpenClaw's gateway +inside it via the driver's control channel; +`openshell forward service` opened a fresh, on-demand WebSocket relay for +this one call (nothing pre-declared beyond the startup liveness port); and a +REAL OpenClaw client running on this host, talking only through that +forwarded port, authenticated with a token and got back a real 'ok: true' +health response. The egress proof also required an allowed HTTPS request to +pass through the OpenShell proxy while a denied host and direct Internet +bypass were blocked. Host loopback remains broadly reachable because dynamic +forwarding uses ephemeral loopback ports. + +Files in this bundle: + transcript.txt full console transcript + gateway.log/.err.log gateway stdout/stderr (includes + forwarded sandbox stdout/stderr, tagged + "wxc-exec stdout:"/"wxc-exec stderr:") + forward.log/.err.log `openshell forward service` stdout/stderr + openclaw-health-raw.txt raw output of the OpenClaw health client + openclaw-capture.log OpenClaw's own captured stdout/stderr + plus credential-free target self-probe + outcome/byte count (no response payload) + (if the sandbox got far enough to write it) + ${tomlBaseName}.used.toml exact config used (wxc_exec_path patched) + openclaw-gateway.used.yaml exact policy used +"@ + Set-Content -Path (Join-Path $resultDir "summary.txt") -Value $summary -Encoding UTF8 + Write-Host $summary -ForegroundColor ($(if ($passed) { "Green" } else { "Red" })) + + try { Stop-Transcript | Out-Null } catch {} + try { + $zip = Join-Path $here "results-openclaw-forward-$stamp.zip" + if (Test-Path $zip) { Remove-Item $zip -Force } + Compress-Archive -Path (Join-Path $resultDir "*") -DestinationPath $zip -Force + Write-Host "`nBUNDLE: $zip" -ForegroundColor Yellow + Write-Host "Hand that zip back for evaluation." -ForegroundColor Yellow + } catch { Write-Host "zip failed: $($_.Exception.Message)" -ForegroundColor Red } +} + +if ($passed) { exit 0 } else { exit 1 } diff --git a/crates/openshell-driver-mxc/examples/run-ws-agent-test.ps1 b/crates/openshell-driver-mxc/examples/run-ws-agent-test.ps1 new file mode 100644 index 0000000000..e9ddc0e5ba --- /dev/null +++ b/crates/openshell-driver-mxc/examples/run-ws-agent-test.ps1 @@ -0,0 +1,749 @@ +# 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 (becomes share_dir in + # the gateway TOML). 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 agent_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)`"") + } + + $agentDirFwd = Fwd $AgentDir + $agentExeFwd = Fwd $agentExe + $relayExeFwd = Fwd $relayExe + + $t = [regex]::Replace($t, '(?m)^\s*#?\s*share_dir\s*=.*$', + "share_dir = `"$agentDirFwd`"") + $t = [regex]::Replace($t, '(?m)^\s*#?\s*agent_cwd\s*=.*$', + "agent_cwd = `"$agentDirFwd`"") + $t = [regex]::Replace($t, '(?ms)^agent_command\s*=\s*\[.*?\]', + "agent_command = [`"$agentExeFwd`", `"server`"]") + $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 no longer adds share_dir automatically) -- it hardcodes the +# same default AgentDir literal as the TOML's share_dir, so it needs the +# same -AgentDir substitution, or an overridden AgentDir 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 + 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 ` + --no-tty ` + -- cmd.exe /c exit 0 ` + 2>&1 + $createExitCode = $LASTEXITCODE + } catch { + $createOut = $_.Exception.Message; $createExitCode = 1 + } + $createStr = ($createOut -join "`n") + Info "create exit: $createExitCode (non-zero expected for MXC -- no SSH server)" + + # Verify the sandbox actually exists by fetching it. + Start-Sleep -Milliseconds 500 + $getOut = $null; $getExitCode = 0 + try { + $getOut = & $cli sandbox get $sandboxName 2>&1 + $getExitCode = $LASTEXITCODE + } catch { + $getOut = $_.Exception.Message; $getExitCode = 1 + } + $getStr = ($getOut -join "`n") + # `sandbox get`'s text output prints "Phase: " (see run.rs); + # require Ready, not just presence -- a sandbox that exists but is stuck + # Provisioning/Error is not actually usable for the WebSocket check below. + $createOk = ($getExitCode -eq 0) -and ($getStr -notmatch 'not found|does not exist') -and ($getStr -match '(?m)^\s*Phase:\s*Ready\s*$') + Record "sandbox-create" $createOk "sandbox $sandboxName $(if ($createOk) {'exists and is Ready'} else {'not found or not Ready after create'})" + + if (-not $createOk) { + Info "create output: $createStr" + Info "get output: $getStr" + throw "sandbox create failed: sandbox does not exist or is not Ready after create" + } + + # --- WebSocket connectivity (real mode only) ------------------------------ + + if (-not $Mock) { + + Step "Wait for WebSocket server on port $WsPort" + $serverUp = Wait-PortOpen -port $WsPort -seconds 30 + if ($serverUp) { + Record "server-port-open" $true "port $WsPort is listening" + } else { + $gwText = (Get-Content $gwLog, $gwErrLog -Raw -ErrorAction SilentlyContinue) -join "`n" + $detail = "port $WsPort did NOT open within 30 s" + if ($gwText -match 'CreateProcessW failed') { + $detail = $detail + " (gateway log: agent launch failure)" + } + Record "server-port-open" $false $detail + } + + # Cross-check server-port-open against openshell-supervisor-relay's own + # confirmation, from inside the sandbox: it detects target-port + # readiness itself (wait_for_port_ready, gated by the "launch" + # handshake) and logs it, forwarded into the gateway log the same way + # as every other wxc-exec stdout/stderr line. No share_dir file + # needed -- this is the same information the marker file used to + # carry, just sourced from the spawner's own diagnostic instead. + if ($serverUp) { + Step "Verify spawner's own port-ready confirmation (gateway log)" + # The spawner's own polling (wait_for_port_ready, 300ms interval) + # runs independently of this script's Wait-PortOpen above -- its + # log line can land a couple of seconds after the raw TCP connect + # already succeeded (observed up to ~2.3s). Poll for it rather + # than checking once immediately, or this races and fails spuriously. + $readyPattern = "port $WsPort ready after" + $readyDeadline = (Get-Date).AddSeconds(15) + $readyOk = $false + while ((Get-Date) -lt $readyDeadline -and -not $readyOk) { + $readyOk = (Test-Path $gwLog -PathType Leaf) -and (Select-String -Path $gwLog -Pattern $readyPattern -Quiet -ErrorAction SilentlyContinue) + if (-not $readyOk) { + $readyOk = (Test-Path $gwErrLog -PathType Leaf) -and (Select-String -Path $gwErrLog -Pattern $readyPattern -Quiet -ErrorAction SilentlyContinue) + } + if (-not $readyOk) { Start-Sleep -Milliseconds 300 } + } + if ($readyOk) { + Record "ws-server-marker" $true "spawner logged '$readyPattern' in the gateway log" + } else { + Record "ws-server-marker" $false "spawner's port-ready log line not found in gateway.log/gateway.err.log within 15 s" + } + } else { + Warn "skipping ws-server-marker: server did not start" + } + + # `openshell forward service` opens a fresh, on-demand relay for this + # one call, bridging $WsPort (inside the sandbox) to $RelayPort (on + # this host). No port needs to be pre-declared anywhere except + # pc_relay_target_port's own startup liveness check. Mirrors + # run-openclaw-forward-test.ps1's step 10. + if ($serverUp) { + Step "openshell forward service --target-port $WsPort --local $RelayPort" + $script:fwdProc = Start-Process -FilePath $cli ` + -ArgumentList @("forward", "service", "--target-port", "$WsPort", "--local", "$RelayPort", $sandboxName) ` + -WorkingDirectory $here -PassThru -NoNewWindow ` + -RedirectStandardOutput $fwdLog -RedirectStandardError $fwdErrLog + Info "forward pid $($script:fwdProc.Id)" + $fwdDeadline = (Get-Date).AddSeconds(20); $fwdUp = $false + while ((Get-Date) -lt $fwdDeadline) { + if ($script:fwdProc.HasExited) { break } + if ((Test-Path $fwdLog) -and (Select-String -Path $fwdLog -Pattern 'Forwarding' -Quiet -ErrorAction SilentlyContinue)) { $fwdUp = $true; break } + if ((Test-Path $fwdErrLog) -and (Select-String -Path $fwdErrLog -Pattern 'Forwarding' -Quiet -ErrorAction SilentlyContinue)) { $fwdUp = $true; break } + Start-Sleep -Milliseconds 500 + } + + if ($fwdUp) { + Ok "forward active: 127.0.0.1:$RelayPort -> sandbox:$WsPort" + + Step "WebSocket echo test via forwarded port (ws://127.0.0.1:$RelayPort)" + $echoOk = Test-WsEcho -wsHost "127.0.0.1" -port $RelayPort -msg $WsMessage + if ($echoOk) { + Record "ws-echo" $true ("'" + $WsMessage + "' echoed via forwarded port $RelayPort") + } else { + Record "ws-echo" $false "echo failed via forwarded port $RelayPort -- see transcript" + } + } else { + $exitDetail = if ($script:fwdProc.HasExited) { " (forward process exited early, code $($script:fwdProc.ExitCode))" } else { "" } + Record "ws-echo" $false "forward did not report 'Forwarding ...' within 20 s$exitDetail -- see forward.log/forward.err.log" + } + } else { + Warn "skipping ws-echo: server did not start" + } + + } else { + Info "[mock] skipping server-port-open and ws-echo" + } + + # Stop the forward before deleting the sandbox it points at. + if ($script:fwdProc -and -not $script:fwdProc.HasExited) { + try { Stop-Process -Id $script:fwdProc.Id -Force -ErrorAction SilentlyContinue } catch {} + } + + # --- Delete sandbox ------------------------------------------------------- + + Step "Delete sandbox '$sandboxName'" + $deleteOut = $null; $deleteExitCode = 0 + try { + $deleteOut = & $cli sandbox delete $sandboxName 2>&1 + $deleteExitCode = $LASTEXITCODE + } catch { + $deleteOut = $_.Exception.Message; $deleteExitCode = 1 + } + $deleteStr = ($deleteOut -join "`n") + Info "delete exit: $deleteExitCode" + if ($deleteExitCode -ne 0) { Info "output: $deleteStr" } + Record "sandbox-delete" ($deleteExitCode -eq 0) "exit $deleteExitCode" + + # --- Port freed (real mode only) ------------------------------------------ + + if (-not $Mock) { + Step "Verify port $WsPort is released after delete" + # The driver sends a "shutdown" control-channel request (openshell- + # supervisor-relay kills the server directly) and kills wxc-exec as a + # backstop regardless. Allow 30 s for that plus the OS to release the + # port. + $portClosed = Wait-PortClosed -port $WsPort -seconds 30 + if ($portClosed) { + Record "port-freed" $true "port $WsPort released within 30 s" + } else { + Record "port-freed" $false "port $WsPort still bound after 30 s" + } + } else { + Info "[mock] skipping port-freed check" + } + +} catch { + $harnessError = $_.Exception.Message + Bad "harness error: $harnessError" +} finally { + # --- Teardown ------------------------------------------------------------- + + # Best-effort forward/sandbox cleanup in case the test failed mid-run. + if ($script:fwdProc -and -not $script:fwdProc.HasExited) { + try { Stop-Process -Id $script:fwdProc.Id -Force -ErrorAction SilentlyContinue } catch {} + } + try { & $cli sandbox delete $sandboxName 2>&1 | Out-Null } catch {} + + if (-not $KeepRunning) { + Stop-Gw $script:gwProc + $script:gwProc = $null + } elseif ($script:gwProc) { + Info "gateway pid $($script:gwProc.Id) left running (-KeepRunning)" + } + + # --- Summary -------------------------------------------------------------- + + Step "Summary" + $checks | Format-Table -AutoSize + + $failCount = @($checks | Where-Object { $_.Result -eq "FAIL" }).Count + $passCount = @($checks | Where-Object { $_.Result -eq "PASS" }).Count + Write-Host "PASS=$passCount FAIL=$failCount" + + $verdict = if ($harnessError -or $failCount -gt 0) { "FAIL" } else { "PASS" } + $checkLines = ($checks | ForEach-Object { " " + $_.Result + " " + $_.Check + ": " + $_.Detail }) -join "`n" + $modeStr = if ($Mock) { "MOCK (no wxc-exec, no WS connectivity)" } else { "REAL" } + $wxcStr = if ($Mock) { "(mock)" } else { $WxcExecPath } + $errStr = if ($harnessError) { "harness_error: $harnessError" } else { "" } + + $summary = "OpenShell MXC WebSocket agent test`n" + + "====================================`n" + + "timestamp : $stamp`n" + + "machine : $env:COMPUTERNAME`n" + + "verdict : $verdict`n" + + "mode : $modeStr`n" + + "gateway : $gateway (port $Port)`n" + + "agent_dir : $AgentDir`n" + + "agent_exe : $agentExe`n" + + "relay_exe : $relayExe`n" + + "policy : $policyUsed`n" + + "sandbox : $sandboxName`n" + + "ws_port : $WsPort`n" + + "relay_port : $RelayPort (on this host)`n" + + "ws_message : $WsMessage`n" + + "wxc_exec : $wxcStr`n" + + "totals : PASS=$passCount FAIL=$failCount`n" + + "$errStr`n" + + "`nChecks:`n$checkLines`n" + + "`nFiles in this bundle ($resultDir):`n" + + " transcript.txt full console transcript`n" + + " gateway.log / gateway.err.log gateway stdout / stderr`n" + + " forward.log / forward.err.log 'openshell forward service' stdout / stderr`n" + + " mxc-ws-gateway.rendered.toml exact gateway config used`n" + + " ws-agent.yaml sandbox policy used`n" + + "`nWhat PASS means:`n" + + " gateway-start gateway bound port $Port within 30 s`n" + + " sandbox-create sandbox reached Ready after create (CLI exit may be non-zero on MXC without SSH)`n" + + " server-port-open WS server bound port $WsPort within 30 s`n" + + " ws-server-marker spawner logged its own port-ready confirmation in the gateway log`n" + + " ws-echo '$WsMessage' echoed via a dynamic 'openshell forward service' relay at 127.0.0.1:$RelayPort`n" + + " (fresh, on-demand relay for this one call -- no static bridge, nothing pre-declared)`n" + + " sandbox-delete CLI returned exit 0 for sandbox delete`n" + + " port-freed port $WsPort released within 30 s of sandbox delete`n" + + Set-Content (Join-Path $resultDir "summary.txt") -Value $summary -Encoding UTF8 + $color = if ($verdict -eq "PASS") { "Green" } else { "Red" } + Write-Host $summary -ForegroundColor $color + + if ($transcriptStarted) { try { Stop-Transcript | Out-Null } catch {} } + + # Zip the bundle. + try { + $zip = Join-Path $here "results-ws-$stamp.zip" + if (Test-Path $zip) { Remove-Item $zip -Force } + Compress-Archive -Path (Join-Path $resultDir "*") -DestinationPath $zip -Force + Write-Host "`nResults bundle: $zip" -ForegroundColor Yellow + } catch { Write-Host "zip failed: $($_.Exception.Message)" -ForegroundColor Red } +} + +if ($harnessError -or (@($checks | Where-Object { $_.Result -eq "FAIL" }).Count -gt 0)) { + Write-Host "`nTEST FAILED" -ForegroundColor Red + exit 1 +} else { + Write-Host "`nTEST PASSED" -ForegroundColor Green + exit 0 +} diff --git a/crates/openshell-driver-mxc/src/control_channel.rs b/crates/openshell-driver-mxc/src/control_channel.rs new file mode 100644 index 0000000000..673b82177e --- /dev/null +++ b/crates/openshell-driver-mxc/src/control_channel.rs @@ -0,0 +1,410 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Request/response JSON control channel to a sandboxed process, riding +//! `wxc-exec`'s inherited stdin/stdout (STDIO passthrough) — see the +//! `openshell-supervisor-relay` crate's module docs for the protocol +//! and why this needs no `AppContainer` network capability at all: it's +//! inherited process handles, not network traffic. + +use serde_json::Value; +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Duration; +use tokio::io::AsyncWriteExt; +use tokio::process::ChildStdin; +use tokio::sync::{Mutex, oneshot}; + +#[derive(Debug, thiserror::Error)] +pub enum ControlChannelError { + #[error("control channel write failed: {0}")] + Write(#[source] std::io::Error), + #[error("control channel response sender dropped")] + Dropped, + #[error("control channel request timed out after {0:?}")] + Timeout(Duration), + #[error("control channel serialize failed: {0}")] + Serialize(#[from] serde_json::Error), +} + +type PendingMap = Mutex>>; +/// Slot for one of the spawner's one-time, unsolicited events -- startup- +/// ready (see `try_route_ready`) and target-ready (see +/// `try_route_target_ready`) each get their own instance of this type. +/// Not part of `PendingMap`: neither has a correlation id or is a reply to +/// anything the driver sent. The payload is `Ok(())` for a normal fire, or +/// `Err(reason)` when the event fired but something about it was rejected +/// (currently only the "ready" event's protocol version check uses this; +/// `"target_ready"` always sends `Ok(())`). +pub type ReadySlot = Mutex>>>; + +/// Wire protocol version this driver requires from +/// `openshell-supervisor-relay`'s startup `"ready"` event (see +/// `try_route_ready`). Must match that crate's own `PROTOCOL_VERSION` +/// constant -- duplicated rather than shared via a common crate, matching +/// how the rest of this wire protocol (event/op names, the auth nonce +/// encoding, etc.) is already duplicated across the two sides. Bump both +/// together whenever the control-channel protocol changes in a way an +/// out-of-sync peer can't safely ignore (e.g. the "nonce" field added to +/// "forward", or the `"target_ready"` event itself) -- an independently +/// staged, stale relay binary then fails fast with a clear error instead of +/// hanging or misbehaving against fields/events it doesn't understand. +const REQUIRED_SUPERVISOR_RELAY_PROTOCOL_VERSION: u64 = 2; + +/// One control channel per sandboxed process. `request()` is safe to call +/// concurrently — each call gets its own correlation id and awaits only its +/// own response, so multiple in-flight requests (e.g. concurrent `forward` +/// calls) don't interfere with each other. +pub struct ControlChannel { + stdin: Mutex, + next_id: AtomicU64, + pending: Arc, +} + +impl ControlChannel { + pub fn new(stdin: ChildStdin) -> Self { + Self { + stdin: Mutex::new(stdin), + next_id: AtomicU64::new(1), + pending: Arc::new(Mutex::new(HashMap::new())), + } + } + + /// A clonable handle to the pending-requests map, for the stdout-reader + /// task (which owns the read side) to route responses into. + pub fn pending_handle(&self) -> Arc { + self.pending.clone() + } + + /// Try to parse `line` as a control-channel response and complete the + /// matching pending request. Returns `true` if `line` was consumed this + /// way; `false` means the caller should treat it as plain log text + /// instead (covers wxc-exec's own banner/config-dump lines, which are + /// never `{"id":...}`-shaped). + pub async fn try_route_response(pending: &PendingMap, line: &str) -> bool { + let Ok(value) = serde_json::from_str::(line) else { + return false; + }; + let Some(id) = value.get("id").and_then(Value::as_u64) else { + return false; + }; + let mut map = pending.lock().await; + map.remove(&id).is_some_and(|tx| { + let _ = tx.send(value); + true + }) + } + + /// Try to recognize `line` as the spawner's unsolicited startup-ready + /// event (`{"event":"ready","protocol_version":N}`) -- sent once, + /// before it's spawned anything, so the driver knows when to send the + /// `"launch"` request carrying the real command/env (see driver.rs's + /// launch handshake and the `openshell-supervisor-relay` crate's module + /// docs). Unlike a query response this has no correlation id, so it + /// can't go through `try_route_response`. Returns `true` if `line` was + /// consumed this way (regardless of whether the version check passed -- + /// the caller distinguishes that via the channel payload). + /// + /// Validates `protocol_version` against + /// `REQUIRED_SUPERVISOR_RELAY_PROTOCOL_VERSION` so an independently + /// staged, out-of-sync relay binary (e.g. left over from an older + /// package drop in a shared `share_dir`) fails the sandbox immediately + /// with a clear "wrong version" error instead of hanging or misbehaving + /// later against a "launch"/"forward" field or a `"target_ready"` event it + /// doesn't understand. A missing field means a pre-versioning binary -- + /// also rejected, since there's no version to compare. + pub async fn try_route_ready(ready: &ReadySlot, line: &str) -> bool { + Self::try_route_named_event(ready, line, "ready", |value| { + match value.get("protocol_version").and_then(Value::as_u64) { + Some(v) if v == REQUIRED_SUPERVISOR_RELAY_PROTOCOL_VERSION => Ok(()), + Some(v) => Err(format!( + "openshell-supervisor-relay reports protocol_version {v}, this driver requires {REQUIRED_SUPERVISOR_RELAY_PROTOCOL_VERSION} -- restage a matching build" + )), + None => Err(format!( + "openshell-supervisor-relay's ready event has no protocol_version field (pre-versioning binary); this driver requires {REQUIRED_SUPERVISOR_RELAY_PROTOCOL_VERSION} -- restage a matching build" + )), + } + }) + .await + } + + /// Try to recognize `line` as the spawner's unsolicited target-ready + /// event (`{"event":"target_ready"}`) -- sent once the spawner has + /// actually spawned the target and confirmed its configured port is + /// accepting connections (see `wait_for_port_ready` in + /// `openshell-supervisor-relay`). Distinct from the `"launch"` + /// control-channel *response*, which only confirms the command/env + /// arrived, not that the target is running: driver.rs awaits this event + /// too before publishing the sandbox `Ready=True`, so a caller acting on + /// `Ready` can't race a target that hasn't bound its port yet. Returns + /// `true` if `line` was consumed this way. No version gate here -- the + /// startup "ready" handshake above already rejected an incompatible + /// peer long before this could fire. + pub async fn try_route_target_ready(target_ready: &ReadySlot, line: &str) -> bool { + Self::try_route_named_event(target_ready, line, "target_ready", |_| Ok(())).await + } + + async fn try_route_named_event( + slot: &ReadySlot, + line: &str, + event_name: &str, + validate: impl FnOnce(&Value) -> Result<(), String>, + ) -> bool { + let Ok(value) = serde_json::from_str::(line) else { + return false; + }; + if value.get("event").and_then(|v| v.as_str()) != Some(event_name) { + return false; + } + let sender = slot.lock().await.take(); + if let Some(tx) = sender { + let _ = tx.send(validate(&value)); + } + true + } + + /// Fail every currently pending request with `Dropped`, e.g. when the + /// stdout-reader task observes EOF/error on the child's stdout: once the + /// reader is gone, no response will ever arrive for these ids, so let + /// callers fail fast instead of sitting out their individual timeouts. + /// Dropping each sender (rather than sending a value) is what makes the + /// waiting `request()` call observe `ControlChannelError::Dropped`. + pub async fn fail_all_pending(pending: &PendingMap) { + let mut map = pending.lock().await; + map.clear(); + } + + /// Send `{"id":N,"op":op,"data":data}` and await the correlated + /// response, or an error on write failure, timeout, or a dropped sender + /// (the reader task exited, e.g. the process died). + pub async fn request( + &self, + op: &str, + data: Value, + timeout: Duration, + ) -> Result { + let id = self.next_id.fetch_add(1, Ordering::Relaxed); + let (tx, rx) = oneshot::channel(); + self.pending.lock().await.insert(id, tx); + + let req = serde_json::json!({"id": id, "op": op, "data": data}); + let mut line = serde_json::to_string(&req)?; + line.push('\n'); + + let write_result = { + let mut stdin = self.stdin.lock().await; + match stdin.write_all(line.as_bytes()).await { + Ok(()) => stdin.flush().await, + Err(e) => Err(e), + } + }; + if let Err(e) = write_result { + self.pending.lock().await.remove(&id); + return Err(ControlChannelError::Write(e)); + } + + match tokio::time::timeout(timeout, rx).await { + Ok(Ok(value)) => Ok(value), + Ok(Err(_)) => Err(ControlChannelError::Dropped), + Err(_) => { + self.pending.lock().await.remove(&id); + Err(ControlChannelError::Timeout(timeout)) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn empty_ready_slot() -> ReadySlot { + Mutex::new(None) + } + + fn armed_ready_slot() -> (ReadySlot, oneshot::Receiver>) { + let (tx, rx) = oneshot::channel(); + (Mutex::new(Some(tx)), rx) + } + + // ── try_route_response ────────────────────────────────────────────── + + #[tokio::test] + async fn try_route_response_completes_matching_pending_id() { + let pending: PendingMap = Mutex::new(HashMap::new()); + let (tx, rx) = oneshot::channel(); + pending.lock().await.insert(7, tx); + + let consumed = + ControlChannel::try_route_response(&pending, r#"{"id":7,"ok":true,"data":42}"#).await; + + assert!(consumed); + let value = rx.await.unwrap(); + assert_eq!(value["data"], 42); + assert!(pending.lock().await.is_empty()); + } + + #[tokio::test] + async fn try_route_response_ignores_unknown_id() { + let pending: PendingMap = Mutex::new(HashMap::new()); + let (tx, _rx) = oneshot::channel(); + pending.lock().await.insert(1, tx); + + let consumed = ControlChannel::try_route_response(&pending, r#"{"id":99,"ok":true}"#).await; + + assert!( + !consumed, + "an id with no pending sender must not be consumed" + ); + assert_eq!( + pending.lock().await.len(), + 1, + "the real pending entry survives" + ); + } + + #[tokio::test] + async fn try_route_response_ignores_non_json_and_id_less_lines() { + let pending: PendingMap = Mutex::new(HashMap::new()); + + assert!(!ControlChannel::try_route_response(&pending, "not json at all").await); + assert!(!ControlChannel::try_route_response(&pending, r#"{"event":"ready"}"#).await); + } + + // ── try_route_ready (protocol-version handshake) ──────────────────── + + #[tokio::test] + async fn try_route_ready_accepts_matching_protocol_version() { + let (slot, rx) = armed_ready_slot(); + + let consumed = + ControlChannel::try_route_ready(&slot, r#"{"event":"ready","protocol_version":2}"#) + .await; + + assert!(consumed); + assert_eq!(rx.await.unwrap(), Ok(())); + } + + #[tokio::test] + async fn try_route_ready_rejects_mismatched_protocol_version() { + let (slot, rx) = armed_ready_slot(); + + let consumed = + ControlChannel::try_route_ready(&slot, r#"{"event":"ready","protocol_version":1}"#) + .await; + + assert!( + consumed, + "a recognized ready event is consumed even when rejected" + ); + let err = rx.await.unwrap().expect_err("version 2 must be rejected"); + assert!( + err.contains('2'), + "error should name the offending version: {err}" + ); + assert!( + err.contains('1'), + "error should name the required version: {err}" + ); + } + + #[tokio::test] + async fn try_route_ready_rejects_missing_protocol_version_field() { + let (slot, rx) = armed_ready_slot(); + + let consumed = ControlChannel::try_route_ready(&slot, r#"{"event":"ready"}"#).await; + + assert!(consumed); + let err = rx + .await + .unwrap() + .expect_err("a missing field must be rejected"); + assert!( + err.contains("pre-versioning"), + "error should call out the pre-versioning case: {err}" + ); + } + + #[tokio::test] + async fn try_route_ready_ignores_other_events_and_non_json() { + let slot = empty_ready_slot(); + + assert!(!ControlChannel::try_route_ready(&slot, r#"{"event":"target_ready"}"#).await); + assert!(!ControlChannel::try_route_ready(&slot, "garbage").await); + } + + // ── try_route_target_ready ─────────────────────────────────────────── + + #[tokio::test] + async fn try_route_target_ready_fires_ok_with_no_version_gate() { + let (slot, rx) = armed_ready_slot(); + + // No protocol_version field at all -- unlike "ready", "target_ready" + // must not be gated on one (see the doc comment on + // try_route_target_ready). + let consumed = + ControlChannel::try_route_target_ready(&slot, r#"{"event":"target_ready"}"#).await; + + assert!(consumed); + assert_eq!(rx.await.unwrap(), Ok(())); + } + + #[tokio::test] + async fn try_route_target_ready_ignores_ready_event() { + let slot = empty_ready_slot(); + + // "ready" and "target_ready" must not be cross-routed into each + // other's slot. + let consumed = ControlChannel::try_route_target_ready( + &slot, + r#"{"event":"ready","protocol_version":2}"#, + ) + .await; + + assert!(!consumed); + } + + #[tokio::test] + async fn try_route_named_event_is_a_safe_no_op_once_the_slot_is_already_empty() { + let (slot, rx) = armed_ready_slot(); + + assert!(ControlChannel::try_route_target_ready(&slot, r#"{"event":"target_ready"}"#).await); + // The slot's sender was taken (and used) on the first fire. A + // repeat of the same event on the wire is still recognized as a + // "target_ready" line (so the caller doesn't mistake it for plain + // log text) but must not panic just because the slot is now empty. + let consumed_again = + ControlChannel::try_route_target_ready(&slot, r#"{"event":"target_ready"}"#).await; + + assert!( + consumed_again, + "still recognized as the event, even as a no-op" + ); + assert_eq!( + rx.await.unwrap(), + Ok(()), + "only the first fire's Ok(()) was ever sent" + ); + } + + // ── fail_all_pending ────────────────────────────────────────────────── + + #[tokio::test] + async fn fail_all_pending_drops_every_sender() { + let pending: PendingMap = Mutex::new(HashMap::new()); + let (tx1, rx1) = oneshot::channel(); + let (tx2, rx2) = oneshot::channel(); + pending.lock().await.insert(1, tx1); + pending.lock().await.insert(2, tx2); + + ControlChannel::fail_all_pending(&pending).await; + + assert!(pending.lock().await.is_empty()); + assert!( + rx1.await.is_err(), + "dropped sender must surface as a recv error" + ); + assert!(rx2.await.is_err()); + } +} diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index 3aca207950..7335db7f95 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -4,8 +4,11 @@ //! MXC compute backend: lifecycle logic, in-memory registry, exec-in-driver, //! and self-reported readiness. +use crate::control_channel::ControlChannel; use crate::mxc::{MxcFilesystem, MxcNetwork, MxcProcess, MxcProcessContainer, WxcExecInvoker}; use crate::policy::{EmbeddedPolicyMapper, MapCtx, MappedConfig, PolicyMapper}; +use crate::relay; +use base64::Engine as _; use futures::Stream; use openshell_core::gpu::{driver_gpu_requirements, effective_driver_gpu_count}; use openshell_core::proto::SandboxPolicy; @@ -19,11 +22,12 @@ use openshell_core::provider_credentials::ProviderCredentialState; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::net::SocketAddr; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::pin::Pin; use std::sync::{Arc, Mutex as StdMutex}; -use tokio::sync::{Mutex, broadcast, mpsc, watch}; -use tokio::task::JoinHandle; +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::process::Child; +use tokio::sync::{Mutex, broadcast, mpsc, oneshot, watch}; use tokio_stream::wrappers::ReceiverStream; use tracing::{info, warn}; @@ -77,12 +81,74 @@ pub struct MxcComputeConfig { pub pc_least_privilege: bool, /// `processContainer` only: `AppContainer` capabilities to grant. pub pc_capabilities: Vec, + /// `processContainer` only: inject a network section with + /// `defaultPolicy: "allow"` so the `AppContainer` has unrestricted outbound + /// TCP access. Required when `pc_capabilities` alone is insufficient to + /// enable network access in the target wxc-exec build. + pub pc_network_allow: bool, + /// `processContainer` only: include `"allowLocalNetwork": true` in the + /// MXC network section. Required for node.js (and other runtimes that + /// need loopback during DLL initialization) to start inside a + /// processcontainer. + pub pc_allow_local_network: bool, + /// `processContainer` only: when `true`, start with an EMPTY process env + /// (not even `MINIMAL_WINDOWS_BOOTSTRAP_ENV`) instead of the safe + /// default -- only the entries in `agent_env` are passed to the process. + /// Use for agents like Node.js that fail with `STATUS_DLL_INIT_FAILED` + /// when unrecognised host env vars are present; the caller is then + /// responsible for supplying `SYSTEMROOT`/`WINDIR`/`PATH`/`COMSPEC`/ + /// `LOCALAPPDATA` themselves via `agent_env` if the agent needs them + /// (`CreateProcessW` itself won't succeed without `LOCALAPPDATA` at + /// least -- see `MINIMAL_WINDOWS_BOOTSTRAP_ENV`). + /// + /// Three tiers overall, safest first: this flag (`agent_env` only) -> + /// the default (`MINIMAL_WINDOWS_BOOTSTRAP_ENV` + `agent_env`) -> + /// `pc_inherit_full_env` (the gateway's entire host env + `agent_env`, + /// explicit unsafe opt-in). + pub pc_minimal_env: bool, + /// `processContainer` only: when `true`, seed the process env from the + /// gateway host's ENTIRE environment instead of the safe + /// `MINIMAL_WINDOWS_BOOTSTRAP_ENV` default. This hands whatever the + /// gateway process itself happens to have in its environment -- + /// including host secrets unrelated to this sandbox, e.g. API keys or + /// tokens picked up from the operator's shell -- to whatever untrusted + /// code `agent_command` runs inside the sandbox. Explicit, unsafe + /// opt-in only; ignored when `pc_minimal_env` is also set (that flag + /// wins). See `pc_minimal_env` for the full tier breakdown. + pub pc_inherit_full_env: bool, + /// `processContainer` only: path to a generic spawn+relay-bridge binary + /// (see the `openshell-supervisor-relay` crate). When non-empty (and + /// `pc_relay_target_port != 0`), the driver launches this binary instead + /// of `agent_command` directly, sending the real `agent_command` / env + /// over the control channel once the spawner announces readiness (the + /// "launch" handshake) rather than writing them to `share_dir`. This + /// decouples the relay-bridging logic from the target application (e.g. + /// `OpenClaw`) entirely — the target needs no awareness of the relay + /// protocol. It's also what gives the driver a control channel into the + /// sandbox at all, which `ForwardSink::open_dynamic_forward` (dynamic + /// `openshell forward service` bridging) depends on regardless of any + /// particular port being pre-declared. + pub pc_relay_spawner_path: String, + /// `processContainer` only: the TCP port `agent_command`'s target process + /// binds, which `pc_relay_spawner_path` bridges to the gateway relay. + /// Ignored unless `pc_relay_spawner_path` is set. `0` disables spawner + /// wrapping (default) — `agent_command` runs directly as before. + pub pc_relay_target_port: u16, /// MXC `configurationId` for isolation session. Default: `"composable"`. /// Never use `"small"` (known OS bug). pub default_configuration_id: String, - /// Enable Pattern-C governed egress. When true, MXC receives filesystem - /// grants plus a `network.proxy` redirect and the host CONNECT proxy - /// receives the trimmed network-only policy. + /// Legacy gateway-wide workload command. New callers should use + /// `template.driver_config.mxc.command`. + pub agent_command: Vec, + /// Legacy gateway-wide workload directory. + pub agent_cwd: String, + /// Legacy gateway-host environment passthrough entries. + pub agent_env: Vec, + /// Legacy default working directory used when `agent_cwd` is empty. + pub share_dir: String, + /// Enable Pattern-C governed egress. When true, MXC permits loopback-only + /// egress, the driver injects proxy environment variables, and the host + /// CONNECT proxy receives the full network policy. pub egress_proxy: bool, /// Loopback `IP:PORT` seed for MXC `network.proxy` while governed egress is /// enabled. The driver preserves the loopback IP and allocates a unique @@ -104,7 +170,17 @@ impl Default for MxcComputeConfig { backend: MxcBackend::default(), pc_least_privilege: false, pc_capabilities: Vec::new(), + pc_network_allow: false, + pc_relay_spawner_path: String::new(), + pc_relay_target_port: 0, + pc_allow_local_network: false, + pc_minimal_env: false, + pc_inherit_full_env: false, default_configuration_id: crate::mxc::DEFAULT_CONFIGURATION_ID.into(), + agent_command: Vec::new(), + agent_cwd: String::new(), + agent_env: Vec::new(), + share_dir: String::new(), egress_proxy: false, egress_proxy_addr: String::new(), @@ -139,13 +215,49 @@ struct SandboxEntry { iso_sandbox_id: Option, isolation_stopped: bool, phase_state: PhaseState, - /// Serializes stop/delete with provisioning and process launch. + /// Serializes stop/delete with provisioning and process launch: taken as + /// an owned guard (`startup_guard`) in `create_sandbox` before the entry + /// is published, and only released once `run_lifecycle` has installed + /// `exec_child`/`shutdown_tx`/`terminated_rx`/`control_channel` (or + /// failed). `stop_sandbox`/`delete_sandbox` block on this same gate + /// before touching any of those fields, so a stop/delete arriving while + /// a sandbox is still starting can't race a launch that hasn't finished + /// wiring the kill/shutdown machinery yet. lifecycle_gate: Arc>, - monitor_cancel: Option>, - monitor_task: Option>, + exec_child: Option, + /// Fires when `delete_sandbox` is called on a `ProcessContainer` sandbox so + /// `monitor_exec` can kill the `wxc-exec` child and release all resources + /// (including ports bound inside the `AppContainer`) before the entry is + /// removed from the registry. + shutdown_tx: Option>, + /// Set to `true` (from `monitor_exec`) once the `wxc-exec` child has + /// genuinely exited -- whether that's a natural exit or the forced kill + /// triggered via `shutdown_tx` above. Lets `stop_sandbox`/`delete_sandbox` + /// await *confirmed* termination (bounded by a timeout) instead of firing + /// the kill signal and immediately reporting success regardless of + /// whether the process actually died. + /// + /// A `watch::Receiver` rather than a `oneshot::Receiver` deliberately: + /// it's `.clone()`d (never `.take()`n) by callers, so it survives a + /// caller that times out and retries -- unlike a consumed oneshot, the + /// retry can still observe the same underlying completion instead of + /// silently skipping the wait because the field looks empty. + terminated_rx: Option>, + /// Path to the shutdown signal file written by `delete_sandbox` so + /// `mxc-ws-agent.rs` (set directly as `agent_command`, no control + /// channel) can detect a deletion and exit cleanly. Only set for that + /// case -- when spawner wrapping is active, `delete_sandbox` sends a + /// `"shutdown"` control-channel request to `openshell-supervisor-relay` + /// instead, so this stays `None`. + signal_file: Option, trimmed_policy: Option, proxy_addr: Option, host_proxy: Option, + /// JSON request/response control channel over the spawner's inherited + /// stdin/stdout (see `control_channel.rs`). Only present when spawner + /// wrapping is active (`pc_relay_spawner_path` configured); dropped on + /// delete, which closes the child's stdin. + control_channel: Option>, } impl std::fmt::Debug for SandboxEntry { @@ -235,21 +347,29 @@ impl std::fmt::Debug for MxcComputeBackend { } } -fn sandbox_config(sandbox: &DriverSandbox) -> Result { +fn sandbox_config( + sandbox: &DriverSandbox, + legacy: &MxcComputeConfig, +) -> Result { let config = sandbox .spec .as_ref() .and_then(|spec| spec.template.as_ref()) - .and_then(|template| template.driver_config.as_ref()) - .ok_or_else(|| { - tonic::Status::invalid_argument( - "mxc requires template.driver_config.mxc with a non-empty command array", - ) - })?; - let config: MxcSandboxConfig = + .and_then(|template| template.driver_config.as_ref()); + let config = if let Some(config) = config { serde_json::from_value(struct_to_json_value(config)).map_err(|error| { tonic::Status::invalid_argument(format!("invalid mxc driver_config: {error}")) - })?; + })? + } else { + MxcSandboxConfig { + command: legacy.agent_command.clone(), + cwd: if legacy.agent_cwd.is_empty() { + legacy.share_dir.clone() + } else { + legacy.agent_cwd.clone() + }, + } + }; if config.command.is_empty() || config.command[0].is_empty() { return Err(tonic::Status::invalid_argument( "mxc driver_config.command must contain a non-empty executable", @@ -258,24 +378,13 @@ fn sandbox_config(sandbox: &DriverSandbox) -> Result Vec { - // Released wxc-exec ProcessContainer builds start from the explicit - // process environment. Seed only the non-secret Windows bootstrap values; - // copying the gateway's full environment would leak unrelated host secrets - // into untrusted sandbox workloads. - let mut environment = MINIMAL_WINDOWS_BOOTSTRAP_ENV - .iter() - .filter_map(|key| { - std::env::var(key) - .ok() - .map(|value| ((*key).to_string(), value)) - }) - .collect::>(); +fn sandbox_environment(sandbox: &DriverSandbox, legacy: &MxcComputeConfig) -> Vec { + let mut environment = HashMap::new(); + for entry in resolve_agent_env(&legacy.agent_env) { + if let Some((key, value)) = entry.split_once('=') { + environment.insert(key.to_string(), value.to_string()); + } + } if let Some(spec) = sandbox.spec.as_ref() { if let Some(template) = spec.template.as_ref() { environment.extend(template.environment.clone()); @@ -290,6 +399,20 @@ fn sandbox_environment(sandbox: &DriverSandbox) -> Vec { environment } +fn resolve_agent_env(entries: &[String]) -> Vec { + let mut resolved = Vec::with_capacity(entries.len()); + for entry in entries { + if entry.contains('=') { + resolved.push(entry.clone()); + } else if let Ok(value) = std::env::var(entry) { + resolved.push(format!("{entry}={value}")); + } else { + warn!(var = %entry, "agent_env passthrough variable not set; skipping"); + } + } + resolved +} + /// Merge provider-owned child environment values into MXC `process.env`. /// /// Provider entries win case-insensitively, matching Windows environment @@ -371,7 +494,7 @@ fn configured_egress_addr(config: &MxcComputeConfig) -> Result Result String { - args.iter() - .map(|arg| quote_windows_argument(arg)) - .collect::>() - .join(" ") -} - -fn quote_windows_argument(arg: &str) -> String { - if !arg.is_empty() && !arg.chars().any(|ch| ch.is_whitespace() || ch == '"') { - return arg.to_string(); - } +/// Minimum Windows environment variables required just for `CreateProcessW` +/// / `AppContainer`-DACL process creation to succeed at all -- independent of +/// whatever runtime `agent_command` happens to be. Confirmed empirically: +/// without `LOCALAPPDATA` specifically, `CreateProcessW` itself fails with +/// `ERROR_ENVVAR_NOT_FOUND` (Win32 203) under the appcontainer-dacl fallback +/// tier, before the agent binary is ever reached -- a Windows `AppContainer` +/// requirement, not specific to Node.js or any other agent. None of these +/// are secrets, so resolving them from the gateway host is safe; this is +/// the default baseline `agent_env` layers on top of. See `pc_minimal_env` +/// / `pc_inherit_full_env` on `MxcComputeConfig` for the other two tiers. +const MINIMAL_WINDOWS_BOOTSTRAP_ENV: [&str; 5] = + ["SYSTEMROOT", "WINDIR", "PATH", "COMSPEC", "LOCALAPPDATA"]; - let mut quoted = String::from("\""); - let mut backslashes = 0; - for ch in arg.chars() { - match ch { - '\\' => backslashes += 1, - '"' => { - quoted.push_str(&"\\".repeat(backslashes * 2 + 1)); - quoted.push('"'); - backslashes = 0; - } - _ => { - quoted.push_str(&"\\".repeat(backslashes)); - backslashes = 0; - quoted.push(ch); - } - } - } - quoted.push_str(&"\\".repeat(backslashes * 2)); - quoted.push('"'); - quoted -} fn host_proxy_binary_path(config: &MxcSandboxConfig) -> PathBuf { config .command @@ -452,18 +555,19 @@ const TLS_ENV_KEYS: [&str; 6] = [ "GIT_SSL_CAINFO", ]; +/// Replace client trust overrides with the proxy's public CA paths. +/// Curated `ProcessContainers` receive copies staged under the authorized share, +/// rather than paths inside the host proxy's private temporary directory. fn append_tls_env_vars(env: &mut Vec, ca_paths: Option<&(PathBuf, PathBuf)>) { let Some((ca_cert_path, combined_bundle_path)) = ca_paths else { return; }; - env.retain(|entry| { let key = entry.split_once('=').map_or(entry.as_str(), |(key, _)| key); !TLS_ENV_KEYS .iter() .any(|candidate| key.eq_ignore_ascii_case(candidate)) }); - let ca_cert_path = ca_cert_path.display().to_string(); let combined_bundle_path = combined_bundle_path.display().to_string(); env.extend([ @@ -476,6 +580,173 @@ fn append_tls_env_vars(env: &mut Vec, ca_paths: Option<&(PathBuf, PathBu ]); } +fn stage_tls_ca_files( + ca_paths: Option<&(PathBuf, PathBuf)>, + share_dir: &str, + sandbox_id: &str, +) -> std::io::Result> { + let Some((ca_cert_path, combined_bundle_path)) = ca_paths else { + return Ok(None); + }; + if share_dir.trim().is_empty() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "share_dir must be set when staging proxy CA files", + )); + } + if sandbox_id.is_empty() + || !sandbox_id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "sandbox_id must be a non-empty alphanumeric, hyphen or underscore component", + )); + } + let target_dir = PathBuf::from(share_dir) + .join(".openshell-proxy") + .join(sandbox_id); + std::fs::create_dir_all(&target_dir)?; + let staged_ca = target_dir.join("openshell-ca.pem"); + let staged_bundle = target_dir.join("ca-bundle.pem"); + std::fs::copy(ca_cert_path, &staged_ca)?; + std::fs::copy(combined_bundle_path, &staged_bundle)?; + Ok(Some((staged_ca, staged_bundle))) +} + +/// PROTOTYPE (2026-09-10): env-var-based governed egress, as an alternative +/// to MXC's own `network.proxy`/`runtimeConfig.networkProxy` transparent +/// redirect (both confirmed broken for this driver's use case -- see +/// `network_json()` in mxc.rs for the elevation/loopback-block history). +/// `HTTP_PROXY`/`HTTPS_PROXY` are honored voluntarily by well-behaved HTTP +/// clients (curl, most language HTTP libraries, Node fetch, git, etc.), not +/// enforced by the OS -- but paired with the sandbox's own default-deny +/// egress (only 127.0.0.1 allowed, see `network_json()`), that's actually +/// sufficient: compliant agents route through the host CONNECT proxy this +/// way, and anything that ignores these vars and tries to connect directly +/// just hits the WFP deny-by-default wall instead of silently bypassing +/// governance. Lowercase forms included too since some tools (e.g. curl) +/// prefer them, and both are common in the wild. +const PROXY_ENV_KEYS: [&str; 6] = [ + "HTTP_PROXY", + "http_proxy", + "HTTPS_PROXY", + "https_proxy", + "NO_PROXY", + "no_proxy", +]; + +const SANDBOX_PROXY_USERNAME: &str = "openshell"; + +struct SandboxProxyAuth { + password: String, +} + +impl SandboxProxyAuth { + fn generate() -> Self { + let random: [u8; 32] = rand::random(); + Self { + password: base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(random), + } + } + + fn proxy_url(&self, addr: SocketAddr) -> String { + format!("http://{SANDBOX_PROXY_USERNAME}:{}@{addr}", self.password) + } + + fn host_client_auth(&self) -> openshell_supervisor_network::host::HostProxyClientAuth { + openshell_supervisor_network::host::HostProxyClientAuth::basic( + SANDBOX_PROXY_USERNAME, + &self.password, + ) + } +} + +fn append_proxy_env_vars( + env: &mut Vec, + proxy_addr: Option, + proxy_auth: Option<&SandboxProxyAuth>, +) { + let (Some(addr), Some(proxy_auth)) = (proxy_addr, proxy_auth) else { + return; + }; + env.retain(|entry| { + let key = entry.split_once('=').map_or(entry.as_str(), |(key, _)| key); + !PROXY_ENV_KEYS + .iter() + .any(|candidate| key.eq_ignore_ascii_case(candidate)) + }); + let proxy_url = proxy_auth.proxy_url(addr); + env.extend([ + format!("HTTP_PROXY={proxy_url}"), + format!("http_proxy={proxy_url}"), + format!("HTTPS_PROXY={proxy_url}"), + format!("https_proxy={proxy_url}"), + "NO_PROXY=".to_string(), + "no_proxy=".to_string(), + ]); +} + +/// Not called: the release wxc-exec (`BaseContainer` dispatcher) requires +/// write-DAC permission on every path in `readonlyPaths` to set up +/// `AppContainer` ACLs, and adding the TLS CA cert temp directory here +/// causes it to fail with a DACL error (empirically confirmed) -- the CA +/// cert paths are available to the agent via TLS env vars instead (see +/// `append_tls_env_vars`). Kept for a future build where that DACL +/// requirement no longer applies. +#[allow(dead_code)] +fn append_tls_readonly_grant( + readonly_paths: &mut Vec, + ca_paths: Option<&(PathBuf, PathBuf)>, +) { + let Some((ca_cert_path, _)) = ca_paths else { + return; + }; + let Some(dir) = ca_cert_path.parent().map(Path::to_path_buf) else { + return; + }; + let dir = dir.display().to_string(); + if !readonly_paths + .iter() + .any(|existing| existing.eq_ignore_ascii_case(&dir)) + { + readonly_paths.push(dir); + } +} +fn encode_windows_command_line(args: &[String]) -> String { + args.iter() + .map(|arg| quote_windows_argument(arg)) + .collect::>() + .join(" ") +} + +fn quote_windows_argument(arg: &str) -> String { + if !arg.is_empty() && !arg.chars().any(|ch| ch.is_whitespace() || ch == '"') { + return arg.to_string(); + } + + let mut quoted = String::from("\""); + let mut backslashes = 0; + for ch in arg.chars() { + match ch { + '\\' => backslashes += 1, + '"' => { + quoted.push_str(&"\\".repeat(backslashes * 2 + 1)); + quoted.push('"'); + backslashes = 0; + } + _ => { + quoted.push_str(&"\\".repeat(backslashes)); + backslashes = 0; + quoted.push(ch); + } + } + } + quoted.push_str(&"\\".repeat(backslashes * 2)); + quoted.push('"'); + quoted +} fn append_tls_readwrite_grant( readwrite_paths: &mut Vec, ca_paths: Option<&(PathBuf, PathBuf)>, @@ -533,6 +804,18 @@ impl MxcComputeBackend { } } + /// Returns a cheap, cloneable handle exposing MXC's dynamic port-forward + /// capability, so the gateway's `ComputeRuntime` can grab it (before + /// `self` is consumed into `Arc`) and call it directly + /// from `handle_forward_tcp` for sandboxes with no `ConnectSupervisor` + /// session -- MXC has no supervisor at all, so that path is otherwise + /// permanently dead for it. + pub fn forward_sink(&self) -> ForwardSink { + ForwardSink { + registry: self.registry.clone(), + } + } + /// Return the in-process create-time provider credential side channel. pub fn provider_credentials_sink( &self, @@ -563,7 +846,7 @@ impl MxcComputeBackend { } } - fn validate_sandbox_fields(sandbox: &DriverSandbox) -> Result<(), tonic::Status> { + fn validate_sandbox_fields(&self, sandbox: &DriverSandbox) -> Result<(), tonic::Status> { if let Some(spec) = &sandbox.spec { if effective_driver_gpu_count(driver_gpu_requirements( spec.resource_requirements.as_ref(), @@ -583,7 +866,7 @@ impl MxcComputeBackend { )); } } - sandbox_config(sandbox)?; + sandbox_config(sandbox, &self.config)?; Ok(()) } @@ -606,7 +889,7 @@ impl MxcComputeBackend { } pub fn validate_sandbox_create(&self, sandbox: &DriverSandbox) -> Result<(), tonic::Status> { - Self::validate_sandbox_fields(sandbox)?; + self.validate_sandbox_fields(sandbox)?; let policy = sandbox.spec.as_ref().and_then(|spec| spec.policy.as_ref()); let egress_addr = configured_egress_addr(&self.config)?; self.map_sandbox_policy(&sandbox.id, policy, egress_addr)?; @@ -637,8 +920,8 @@ impl MxcComputeBackend { .remove(&sandbox_id); validate_provider_child_env_keys(provider_credentials.as_ref())?; - Self::validate_sandbox_fields(sandbox)?; - let sandbox_config = sandbox_config(sandbox)?; + self.validate_sandbox_fields(sandbox)?; + let sandbox_config = sandbox_config(sandbox, &self.config)?; let (egress_addr, reserved_proxy_listener) = match configured_egress_addr(&self.config)? { Some(configured_addr) => { let (addr, reservation) = allocate_sandbox_proxy_addr(configured_addr).map_err( @@ -712,11 +995,14 @@ impl MxcComputeBackend { isolation_stopped: false, phase_state: PhaseState::Starting, lifecycle_gate, - monitor_cancel: None, - monitor_task: None, - trimmed_policy: None, - proxy_addr: None, + exec_child: None, + shutdown_tx: None, + terminated_rx: None, + signal_file: None, + trimmed_policy: mapped.trimmed_policy.clone(), + proxy_addr: mapped.proxy_addr, host_proxy: None, + control_channel: None, }, ); } @@ -758,8 +1044,19 @@ impl MxcComputeBackend { (entry.sandbox.id.clone(), entry.lifecycle_gate.clone()) }; + // Blocks until any in-flight create_sandbox/run_lifecycle has either + // finished wiring shutdown_tx/control_channel or failed -- closes the + // race where a stop arriving mid-startup would otherwise find both + // `None` and silently no-op (see the `lifecycle_gate` field doc). let _lifecycle_guard = lifecycle_gate.lock().await; - let (iso_id, mut isolation_stopped, cancel, monitor_task) = { + let ( + iso_id, + mut isolation_stopped, + shutdown_tx, + terminated_rx, + control_channel, + host_proxy, + ) = { let mut registry = self.registry.lock().await; let entry = registry.get_mut(&sandbox_id).ok_or_else(|| { tonic::Status::not_found(format!("sandbox {sandbox_name} not found")) @@ -767,25 +1064,76 @@ impl MxcComputeBackend { ( entry.iso_sandbox_id.clone(), entry.isolation_stopped, - entry.monitor_cancel.take(), - entry.monitor_task.take(), + // Only ProcessContainer entries have these; isolation_session + // relies on invoker.stop() below instead. .take() the kill + // signal so a concurrent stop can't double-fire it, but + // .clone() terminated_rx (a watch::Receiver, not a oneshot) + // and the control channel (an Arc) -- both need to survive a + // caller that times out below and retries: a fresh clone of + // the same watch::Receiver still observes the SAME + // underlying completion, whereas .take()-ing it would make a + // retry silently skip the wait (see the matching fix in + // delete_sandbox and MR !98's review thread on this). + entry.shutdown_tx.take(), + entry.terminated_rx.clone(), + entry.control_channel.clone(), + entry.host_proxy.take(), ) }; - if let Some(cancel) = cancel { - let _ = cancel.send(true); - } - if let Some(task) = monitor_task { - task.await.map_err(|error| { - tonic::Status::internal(format!("mxc process monitor failed: {error}")) - })?; - } - if let Some(ref iso_id) = iso_id - && !isolation_stopped - { - self.invoker.stop(iso_id).await.map_err(|error| { - tonic::Status::internal(format!("wxc-exec stop failed: {error}")) - })?; - isolation_stopped = true; + drop(host_proxy); + + if let Some(ref iso_id) = iso_id { + if !isolation_stopped { + self.invoker.stop(iso_id).await.map_err(|error| { + tonic::Status::internal(format!("wxc-exec stop failed: {error}")) + })?; + isolation_stopped = true; + } + } else { + // ProcessContainer has no persistent iso id -- the sandbox IS + // the one-shot wxc-exec process, so without this block stop had + // nothing to act on and just relabeled the sandbox Stopped while + // wxc-exec (and everything inside the AppContainer) kept + // running. Ask nicely first over the control channel (bounded + // by request()'s own 3s timeout, same as delete_sandbox), then + // trigger the shutdown_tx kill backstop. + if let Some(channel) = control_channel { + match channel + .request( + "shutdown", + serde_json::Value::Null, + std::time::Duration::from_secs(3), + ) + .await + { + Ok(_) => { + info!(sandbox = %sandbox_name, "control-channel shutdown acknowledged"); + } + Err(e) => { + warn!(sandbox = %sandbox_name, "control-channel shutdown failed: {e}"); + } + } + } + if let Some(tx) = shutdown_tx { + let _ = tx.send(()); + } + // Await *confirmed* termination via terminated_rx -- not just + // firing the kill signal and reporting success regardless -- + // before this returns Ok. This runs whenever terminated_rx is + // present, independent of whether THIS call sent the kill + // signal above: shutdown_tx is None either because the process + // already exited naturally, or because an earlier (possibly + // timed-out) stop/delete attempt already sent it -- either way, + // this call still needs to observe genuine completion, not + // assume it. + if let Some(mut rx) = terminated_rx + && !wait_for_termination(&mut rx).await + { + warn!(sandbox = %sandbox_name, "timed out waiting for ProcessContainer termination on stop"); + return Err(tonic::Status::deadline_exceeded(format!( + "sandbox {sandbox_name} did not terminate within the stop timeout" + ))); + } } let mut registry = self.registry.lock().await; @@ -828,8 +1176,17 @@ impl MxcComputeBackend { entry.lifecycle_gate.clone() }; + // See stop_sandbox's matching comment on lifecycle_gate. let _lifecycle_guard = lifecycle_gate.lock().await; - let (iso_id, isolation_stopped, cancel, monitor_task) = { + let ( + iso_id, + isolation_stopped, + shutdown_tx, + terminated_rx, + signal_file, + control_channel, + host_proxy, + ) = { let mut registry = self.registry.lock().await; let Some(entry) = registry.get_mut(sandbox_id) else { return Ok(false); @@ -837,18 +1194,17 @@ impl MxcComputeBackend { ( entry.iso_sandbox_id.clone(), entry.isolation_stopped, - entry.monitor_cancel.take(), - entry.monitor_task.take(), + entry.shutdown_tx.take(), + // .clone(), not .take() -- see stop_sandbox's matching + // comment: a watch::Receiver survives a caller that times + // out and retries, unlike a consumed oneshot. + entry.terminated_rx.clone(), + entry.signal_file.take(), + entry.control_channel.take(), + entry.host_proxy.take(), ) }; - if let Some(cancel) = cancel { - let _ = cancel.send(true); - } - if let Some(task) = monitor_task { - task.await.map_err(|error| { - tonic::Status::internal(format!("mxc process monitor failed: {error}")) - })?; - } + drop(host_proxy); if let Some(ref iso_id) = iso_id { if !isolation_stopped { self.invoker.stop(iso_id).await.map_err(|error| { @@ -864,6 +1220,75 @@ impl MxcComputeBackend { self.invoker.deprovision(iso_id).await.map_err(|error| { tonic::Status::internal(format!("wxc-exec deprovision failed: {error}")) })?; + } else { + // Prefer telling the spawner directly over the control channel + // (a "shutdown" request -- see the launch handshake) so it can + // proactively kill its target and exit before the AppContainer + // teardown below, since that teardown alone can leave sandboxed + // processes running well past this call returning. Awaited + // (bounded by request()'s own 3s timeout) rather than + // fire-and-forget: a detached task races the shutdown_tx + // backstop below instead of being superseded by it, so the + // graceful path can lose to its own fallback. The backstop + // still always runs afterward regardless of outcome here -- + // this only orders "ask nicely" before "force it". Only + // present when the driver launched openshell-supervisor-relay + // (spawner wrapping); mxc-ws-agent.rs (no control channel) + // still uses the older signal-file mechanism. + if let Some(channel) = control_channel { + match channel + .request( + "shutdown", + serde_json::Value::Null, + std::time::Duration::from_secs(3), + ) + .await + { + Ok(_) => { + info!(sandbox = %sandbox_name, "control-channel shutdown acknowledged"); + } + Err(e) => { + warn!(sandbox = %sandbox_name, "control-channel shutdown failed: {e}"); + } + } + } + // Write the shutdown signal file so the spawner inside the + // AppContainer detects deletion and exits cleanly, freeing ports + // and child processes even if MXC does not cascade-kill them when + // wxc-exec is terminated. Only set for mxc-ws-agent.rs (no + // control channel) -- see above. + if let Some(ref path) = signal_file + && let Err(e) = std::fs::write(path, b"") + { + warn!(sandbox = %sandbox_name, path = %path.display(), error = %e, + "failed to write ProcessContainer shutdown signal file"); + } + // Signal monitor_exec to kill wxc-exec as a backstop. + if let Some(tx) = shutdown_tx { + let _ = tx.send(()); + } + // Await *confirmed* termination via terminated_rx -- not just + // firing the signal and reporting success regardless -- before + // this removes the registry entry and returns Ok(true). Without + // this, delete_sandbox could report success while the + // ProcessContainer (and whatever it launched) is still alive, + // retaining ports and file locks (see stop_sandbox's matching + // comment). Runs whenever terminated_rx is present, independent + // of whether THIS call sent the kill signal above -- shutdown_tx + // is None either because the process already exited naturally, + // or because an earlier (possibly timed-out) stop/delete attempt + // already sent it. A retry must still confirm genuine + // completion via the persisted watch value rather than assuming + // it, or it can remove the registry entry and report success + // while the process is still alive (MR !98 review thread). + if let Some(mut rx) = terminated_rx + && !wait_for_termination(&mut rx).await + { + warn!(sandbox = %sandbox_name, "timed out waiting for ProcessContainer termination on delete"); + return Err(tonic::Status::deadline_exceeded(format!( + "sandbox {sandbox_name} did not terminate within the delete timeout" + ))); + } } let mut registry = self.registry.lock().await; @@ -924,6 +1349,91 @@ impl MxcComputeBackend { } } +// ── Dynamic port forwarding ─────────────────────────────────────────────────── +// +// Closes the `openshell forward service` gap for MXC: `handle_forward_tcp` +// normally requires a live `ConnectSupervisor` session, which MXC's +// exec-in-driver design never registers (no in-sandbox supervisor process +// exists). This gives the gateway an alternate path straight into a running +// sandbox's control channel instead, bypassing that requirement entirely. + +#[derive(Debug, thiserror::Error)] +pub enum OpenDynamicForwardError { + #[error("sandbox {0} not found")] + SandboxNotFound(String), + #[error( + "sandbox {0} has no control channel (not launched via a relay spawner, or not yet Ready)" + )] + NoControlChannel(String), + #[error("failed to bind ephemeral relay listener: {0}")] + RelayBind(#[source] std::io::Error), + #[error("control channel request failed: {0}")] + ControlChannel(#[from] crate::control_channel::ControlChannelError), + #[error("sandbox rejected forward request: {0}")] + Rejected(String), +} + +/// Cheap, cloneable handle exposing MXC's dynamic port-forward capability — +/// see `MxcComputeBackend::forward_sink`. +#[derive(Clone)] +pub struct ForwardSink { + registry: Arc>>, +} + +impl ForwardSink { + /// Open a new, independent relay bridge to `target_port` inside the + /// given sandbox's `AppContainer`, on demand (not pre-declared in the + /// gateway TOML). Returns the ephemeral relay's address — reachable + /// directly by the gateway process itself, no `AppContainer` boundary on + /// that leg — a per-forward auth nonce the caller MUST send as the first + /// bytes on its own connection to that address (see `relay.rs` module + /// docs: the relay is host-interface-bound, so another reachable process + /// could otherwise race to connect first and hijack the forward), and a + /// [`relay::RelayHandle`] the caller must hold for as long as the + /// forward should stay open, then `.stop()` (or just drop) to tear it + /// down. + /// + /// Target host is always `127.0.0.1` inside the `AppContainer` (matching + /// `TcpRelayTarget`'s existing loopback-only restriction at the gRPC + /// layer), so there's no separate `target_host` parameter to thread + /// through — the sandbox-side `forward` op only ever dials loopback. + pub async fn open_dynamic_forward( + &self, + sandbox_id: &str, + target_port: u16, + ) -> Result<(SocketAddr, [u8; relay::NONCE_LEN], relay::RelayHandle), OpenDynamicForwardError> + { + let (control_channel, sandbox_name) = { + let reg = self.registry.lock().await; + let entry = reg + .get(sandbox_id) + .ok_or_else(|| OpenDynamicForwardError::SandboxNotFound(sandbox_id.to_string()))?; + let channel = entry + .control_channel + .clone() + .ok_or_else(|| OpenDynamicForwardError::NoControlChannel(sandbox_id.to_string()))?; + (channel, entry.sandbox.name.clone()) + }; + + // Fresh per forward -- see relay.rs module docs for why this matters + // on a host-interface listener. + let nonce: [u8; relay::NONCE_LEN] = rand::random(); + + let bind_addr: SocketAddr = "127.0.0.1:0".parse().unwrap(); + let (relay_handle, relay_addr) = relay::start_control_channel_relay( + bind_addr, + sandbox_name, + nonce, + control_channel, + target_port, + ) + .await + .map_err(OpenDynamicForwardError::RelayBind)?; + + Ok((relay_addr, nonce, relay_handle)) + } +} + // ── Lifecycle task ──────────────────────────────────────────────────────────── #[allow(clippy::too_many_arguments)] @@ -938,14 +1448,16 @@ async fn run_lifecycle( mapped: MappedConfig, provider_credentials: Option, mut reserved_proxy_listener: Option, - _startup_guard: tokio::sync::OwnedMutexGuard<()>, + startup_guard: tokio::sync::OwnedMutexGuard<()>, ) { let sandbox_id = sandbox.id.clone(); let sandbox_name = sandbox.name.clone(); - let trimmed_policy = mapped.trimmed_policy.clone(); let proxy_addr = mapped.proxy_addr; + let proxy_auth = proxy_addr.map(|_| SandboxProxyAuth::generate()); + let trimmed_policy = mapped.trimmed_policy.clone(); let host_proxy = if !invoker.is_mock() - && let (Some(addr), Some(proxy_policy)) = (proxy_addr, trimmed_policy.clone()) + && let (Some(addr), Some(proxy_policy), Some(proxy_auth)) = + (proxy_addr, trimmed_policy.clone(), proxy_auth.as_ref()) { drop(reserved_proxy_listener.take()); match openshell_supervisor_network::host::start_host_proxy( @@ -953,6 +1465,7 @@ async fn run_lifecycle( bind_addr: addr, policy: proxy_policy, binary_path: host_proxy_binary_path(&sandbox_config), + client_auth: proxy_auth.host_client_auth(), sandbox_id: Some(sandbox_id.clone()), sandbox_name: Some(sandbox_name.clone()), openshell_endpoint: None, @@ -983,6 +1496,28 @@ async fn run_lifecycle( let host_proxy_ca_paths = host_proxy .as_ref() .and_then(openshell_supervisor_network::host::HostProxyHandle::ca_file_paths); + // A curated ProcessContainer cannot read the host's private temp folder. + // Stage only the public CA material beneath share_dir, whose AppContainer + // DACL is already granted by the policy, so HTTPS clients can authenticate + // the OpenShell inspection proxy without broadening filesystem access. + let agent_proxy_ca_paths = if config.pc_minimal_env && host_proxy_ca_paths.is_some() { + match stage_tls_ca_files(host_proxy_ca_paths.as_ref(), &config.share_dir, &sandbox_id) { + Ok(paths) => paths, + Err(error) => { + set_failed( + ®istry, + &watch_tx, + &sandbox, + &sandbox_id, + &format!("failed to stage MXC egress proxy CA files: {error}"), + ) + .await; + return; + } + } + } else { + host_proxy_ca_paths.clone() + }; drop(reserved_proxy_listener.take()); if let Some(addr) = proxy_addr { { @@ -1000,14 +1535,10 @@ async fn run_lifecycle( )); } - // Released wxc-exec BaseContainer builds cannot provision the generated - // TLS directory as a read-only share because that path fails its WRITE_DAC - // setup. Grant the sandbox-unique directory read-write instead so the - // AppContainer can actually read the injected trust paths. The directory - // contains only public CA certificates; the CA private key remains in the - // host proxy's in-memory TLS state. let mut readwrite_paths = mapped.readwrite_paths; - append_tls_readwrite_grant(&mut readwrite_paths, host_proxy_ca_paths.as_ref()); + if !config.pc_minimal_env { + append_tls_readwrite_grant(&mut readwrite_paths, host_proxy_ca_paths.as_ref()); + } let readonly_paths = mapped.readonly_paths; let ui = mapped.ui; let filesystem = MxcFilesystem { @@ -1018,19 +1549,86 @@ async fn run_lifecycle( denied_paths: Vec::new(), }; let command_line = encode_windows_command_line(&sandbox_config.command); - let mut environment = sandbox_environment(&sandbox); - append_provider_child_env(&mut environment, provider_credentials.as_ref()); - append_tls_env_vars(&mut environment, host_proxy_ca_paths.as_ref()); - info!(sandbox = %sandbox_name, count = environment.len(), "MXC process env vars"); + // ProcessContainer starts with a completely blank environment — no PATH, + // no SystemRoot, nothing. Three tiers of base env, safest first (see + // `pc_minimal_env` / `pc_inherit_full_env` field docs for the full + // rationale) -- then layer sandbox_environment's output (static + // agent_env passthrough merged with any per-request environment from + // the CreateSandbox spec) on top, and finally layer TLS CA vars when an + // egress proxy is active. Skip internal Windows drive-letter variables + // (keys starting with '=') in the full-inherit tier. + let mut env_map: HashMap = if config.pc_minimal_env { + HashMap::new() + } else if config.pc_inherit_full_env { + std::env::vars() + .filter(|(k, _)| !k.is_empty() && !k.starts_with('=')) + .collect() + } else { + MINIMAL_WINDOWS_BOOTSTRAP_ENV + .iter() + .filter_map(|&key| std::env::var(key).ok().map(|v| (key.to_string(), v))) + .collect() + }; + + for entry in sandbox_environment(&sandbox, &config) { + if let Some(pos) = entry.find('=') { + env_map.insert(entry[..pos].to_string(), entry[pos + 1..].to_string()); + } + } + + let mut env: Vec = env_map + .into_iter() + .map(|(k, v)| format!("{k}={v}")) + .collect(); + append_provider_child_env(&mut env, provider_credentials.as_ref()); + // Layer proxy configuration for every env tier. Curated ProcessContainers + // use the staged CA copies above; other tiers use the original paths. + append_tls_env_vars(&mut env, agent_proxy_ca_paths.as_ref()); + append_proxy_env_vars(&mut env, proxy_addr, proxy_auth.as_ref()); + env.sort(); // deterministic order for logging / debugging + info!(sandbox = %sandbox_name, count = env.len(), "MXC process env vars"); + + // When spawner wrapping is configured, launch openshell-supervisor-relay + // instead of agent_command directly. The real command/env are sent over + // the control channel once the spawner announces readiness (see the + // "launch" handshake below) rather than written to share_dir as + // agent-cmd.txt/agent-env.txt -- this keeps command/env (which can carry + // secrets, e.g. OPENCLAW_GATEWAY_TOKEN) off disk entirely and eliminates + // the file-staleness/namespace-mismatch bug class that existed when they + // were file-based. `agent_command`'s target application (e.g. OpenClaw) + // stays entirely unaware of the relay protocol either way. + let spawner_wrapping_active = + !config.pc_relay_spawner_path.is_empty() && config.pc_relay_target_port != 0; + let effective_command_line = if spawner_wrapping_active { + // Quoted: pc_relay_spawner_path is a filesystem path and may contain + // spaces (e.g. under "Program Files"); unquoted, wxc-exec would + // parse the executable path incorrectly and the launch would fail + // before the control-channel handshake ever starts. + format!( + "\"{}\" {}", + config.pc_relay_spawner_path, config.pc_relay_target_port + ) + } else { + command_line.clone() + }; + + // Downstream logging/ETW attribution should reflect what's actually + // launched (openshell-supervisor-relay, when wrapping is active), not + // the original agent_command -- shadow command_line with the effective + // value. + let command_line = effective_command_line; let process = MxcProcess { command_line: command_line.clone(), cwd: sandbox_config.cwd, - env: environment, + // Cloned: the launch handshake below (spawner_wrapping_active case) + // needs its own copy of `env` to send over the control channel. + env: env.clone(), timeout: 0, }; let network = proxy_addr.map(|addr| MxcNetwork { default_policy: "block".into(), proxy: Some(addr), + allow_local_network: false, }); let child = match config.backend { @@ -1093,13 +1691,47 @@ async fn run_lifecycle( least_privilege: config.pc_least_privilege, capabilities: config.pc_capabilities.clone(), }; + // Build the effective network config: + // - egress_proxy: use the proxy-based network (already in `network`) + // - pc_network_allow: inject allow-all (fallback for builds without capability support) + // - pc_allow_local_network: block-default but with allowLocalNetwork=true so + // intra-container loopback works and the spawner can reach the relay on the + // host's route-selected private interface without a full egress proxy. + let effective_network = if network.is_none() + && (config.pc_allow_local_network || config.pc_network_allow) + { + // Both flags apply to the same no-proxy startup case and + // aren't mutually exclusive -- honor both instead of letting + // pc_allow_local_network's branch silently force + // default_policy back to "block" and drop pc_network_allow's + // unrestricted-egress intent. + Some(MxcNetwork { + default_policy: if config.pc_network_allow { + "allow".into() + } else { + "block".into() + }, + proxy: None, + allow_local_network: config.pc_allow_local_network, + }) + } else { + // `network` is Some here (egress_proxy configured). Preserve + // config.pc_allow_local_network instead of unconditionally + // clearing it -- MxcNetwork already carries both `proxy` and + // `allow_local_network` together, so a proxy and local-network + // access aren't mutually exclusive. + network.map(|mut n| { + n.allow_local_network = config.pc_allow_local_network; + n + }) + }; match invoker .run_oneshot( &sandbox_id, filesystem, process_container, process, - network, + effective_network, ui, ) .await @@ -1121,28 +1753,162 @@ async fn run_lifecycle( }; info!(sandbox = %sandbox_name, command = %command_line, backend = ?config.backend, "MXC agent launched"); - let ready_sandbox = make_sandbox_with_condition( - &sandbox, - &DriverCondition { - r#type: "Ready".into(), - status: "True".into(), - reason: "AgentRunning".into(), - message: format!("Agent exec launched: {command_line}"), - transition_time: None, - }, - false, - ); - let (cancel_tx, cancel_rx) = watch::channel(false); - { - // Publish cancellation state before the monitor can observe a fast - // process exit. Holding the registry lock while spawning prevents a - // completed child from being overwritten with AgentRunning. - let mut registry_guard = registry.lock().await; - let Some(entry) = registry_guard.get_mut(&sandbox_id) else { - // The sandbox was deleted between agent launch and readiness. Bail - // without seeding ETW attribution (a stale key would misroute later - // events to a dead sandbox), without reporting Ready, and without - // spawning the exec monitor. `delete` already tore down the process. + // Stream wxc-exec's stdout/stderr into the gateway log as it runs, so the + // agent's live output is visible in the gateway console instead of sitting + // unread in the OS pipe until the process exits. + let mut child = child; + + // Control channel: correlate JSON responses in the stdout stream with + // pending requests sent over stdin (see control_channel.rs). Only + // meaningful when the process on the other end is + // openshell-supervisor-relay (spawner wrapping active) -- an arbitrary + // agent_command target wouldn't understand this protocol, so stdin is + // left untouched (and unpiped expectations unaffected) otherwise. + let control_channel: Option> = if spawner_wrapping_active { + if let Some(stdin) = child.stdin.take() { + Some(Arc::new(ControlChannel::new(stdin))) + } else { + // wxc-exec didn't give us a piped stdin even though spawner + // wrapping was requested. Without a control channel, + // openshell-supervisor-relay would wait forever for a + // "launch" request that can never arrive -- a silent hang, + // not a failure. Fail the sandbox now instead. + let err = "wxc-exec stdin is not piped; control-channel launch cannot proceed"; + set_failed(®istry, &watch_tx, &sandbox, &sandbox_id, err).await; + let _ = child.kill().await; + let _ = child.wait().await; + return; + } + } else { + // No control channel on the direct-agent path. Both spawn_exec and + // run_oneshot now always pipe stdin (needed for the control-channel + // case above), so without this the write end stays open inside + // `child` for the sandbox's full lifetime -- any workload that + // reads stdin until EOF would then block forever, since EOF never + // arrives. Drop it so stdin readers see EOF immediately instead. + drop(child.stdin.take()); + None + }; + let pending_responses = control_channel.as_ref().map(|c| c.pending_handle()); + // Startup-ready signal from the spawner (see control_channel.rs's + // try_route_ready). Fired once, before the "launch" handshake below. + // Carries Err(reason) instead of firing at all when the spawner's + // reported protocol_version doesn't match what this driver requires -- + // see try_route_ready's doc comment. + let (ready_slot, ready_rx) = if spawner_wrapping_active { + let (tx, rx) = oneshot::channel::>(); + (Some(Arc::new(Mutex::new(Some(tx)))), Some(rx)) + } else { + (None, None) + }; + // Target-ready signal from the spawner (see control_channel.rs's + // try_route_target_ready) -- fired once the target is actually running + // and its configured port is accepting connections, distinct from the + // "launch" response below (which only confirms the command/env + // arrived). Awaited after "launch" succeeds and before publishing + // Ready=True, so Ready can't be reported while the target is still + // unreachable. Always Ok(()) when it fires (no version gate on this + // event -- see try_route_target_ready). + let (target_ready_slot, target_ready_rx) = if spawner_wrapping_active { + let (tx, rx) = oneshot::channel::>(); + (Some(Arc::new(Mutex::new(Some(tx)))), Some(rx)) + } else { + (None, None) + }; + + if let Some(stdout) = child.stdout.take() { + let sandbox_name_out = sandbox_name.clone(); + let ready_slot = ready_slot.clone(); + let target_ready_slot = target_ready_slot.clone(); + tokio::spawn(async move { + let mut lines = BufReader::new(stdout).lines(); + loop { + match lines.next_line().await { + Ok(Some(line)) => { + let routed_ready = match &ready_slot { + Some(slot) => ControlChannel::try_route_ready(slot, &line).await, + None => false, + }; + let routed_target_ready = match &target_ready_slot { + Some(slot) => ControlChannel::try_route_target_ready(slot, &line).await, + None => false, + }; + let routed = routed_ready + || routed_target_ready + || match &pending_responses { + Some(pending) => { + ControlChannel::try_route_response(pending, &line).await + } + None => false, + }; + if !routed { + info!(sandbox = %sandbox_name_out, "wxc-exec stdout: {line}"); + } + } + Ok(None) => break, + Err(e) => { + warn!(sandbox = %sandbox_name_out, "wxc-exec stdout read error: {e}"); + break; + } + } + } + // Stdout is gone (EOF or read error): no control-channel response + // will ever arrive again. Fail any still-pending requests now + // instead of leaving them to time out individually. + if let Some(pending) = &pending_responses { + ControlChannel::fail_all_pending(pending).await; + } + }); + } + // Drop this scope's Arc clones now that the stdout task holds its own: + // if the spawner exits before ever sending "ready"/"target_ready", the + // stdout task's clone is the only thing keeping the + // Mutex> alive, so its loop ending (EOF) drops the last + // reference -- which drops the still-`Some` Sender and makes + // ready_rx/target_ready_rx below observe a dropped sender immediately + // instead of waiting out the full timeout. + drop(ready_slot); + drop(target_ready_slot); + if let Some(stderr) = child.stderr.take() { + let sandbox_name_err = sandbox_name.clone(); + tokio::spawn(async move { + let mut lines = BufReader::new(stderr).lines(); + loop { + match lines.next_line().await { + Ok(Some(line)) => warn!(sandbox = %sandbox_name_err, "wxc-exec stderr: {line}"), + Ok(None) => break, + Err(e) => { + warn!(sandbox = %sandbox_name_err, "wxc-exec stderr read error: {e}"); + break; + } + } + } + }); + } + // Publish a cancellable handle (exec_child, and for ProcessContainer + // shutdown_tx/terminated_rx too) and release the startup gate now, + // rather than holding it until the target-readiness wait below (up to + // ~430s worst case: 120s ready + 310s target_ready) completes or times + // out. stop_sandbox/delete_sandbox block on lifecycle_gate before doing + // anything else, so holding it this long meant a stop/delete arriving + // while a target is slow to (or never does) come up had no way to + // interrupt that wait -- it just queued up behind it. See also imp.rs's + // matching fix: openshell-supervisor-relay now races its own + // port-readiness wait against a "shutdown" request instead of only + // observing shutdown once that wait finishes. + let shutdown_rx = { + let mut reg = registry.lock().await; + let Some(entry) = reg.get_mut(&sandbox_id) else { + // The sandbox was deleted between agent launch and now. `delete` + // already tore down the *previous* process entry, but `child` + // here was spawned after that -- it was never registered, so + // nothing else will kill it. `tokio::process::Child` does not + // kill-on-drop, so without this the wxc-exec process (and its + // AppContainer) would keep running past `delete` reporting + // success. + drop(reg); + let _ = child.kill().await; + let _ = child.wait().await; return; }; @@ -1150,8 +1916,8 @@ async fn run_lifecycle( // `delete` cannot remove the sandbox after we register (which would leave // a stale key). The `wxc-exec` pid we just spawned is the collision-proof // anchor that ties the `Sandboxing` provider's events back to this - // `sandbox_id` while the child is alive. Command text is never an - // attribution key. No-op unless the ETW consumer is running. + // `sandbox_id` while the exact process generation is alive. Command + // text is never an attribution key. if config.etw_audit && let Some(pid) = child.id() { @@ -1162,72 +1928,337 @@ async fn run_lifecycle( } } Err(error) => { - warn!( - sandbox = %sandbox_name, - pid, - error, - "failed to obtain wxc-exec process generation key; PID-based ETW attribution disabled for this launch" - ); + warn!(sandbox = %sandbox_name, pid, error, + "failed to obtain wxc-exec process generation key; ETW attribution disabled for this launch"); + } + } + } + + entry.exec_child = Some(child); + entry.control_channel.clone_from(&control_channel); + + // For ProcessContainer, wire a kill channel so stop_sandbox/ + // delete_sandbox can terminate the wxc-exec process and cause the + // AppContainer (and all in-sandbox processes, including long-lived + // servers) to be torn down, as a backstop regardless of how shutdown + // is signaled below. `terminated_rx` is the other half of the pair + // `monitor_exec` uses to report back once the process has actually + // exited, so callers can await confirmed termination instead of + // just firing the kill and hoping. + if matches!(config.backend, MxcBackend::ProcessContainer) { + let (tx, rx) = oneshot::channel::<()>(); + let (done_tx, done_rx) = watch::channel(false); + entry.shutdown_tx = Some(tx); + entry.terminated_rx = Some(done_rx); + // The generic spawner (openshell-supervisor-relay) gets its + // shutdown notice over the control channel (see delete_sandbox's + // "shutdown" request) -- no file needed. Only mxc-ws-agent.rs + // (set directly as agent_command, not spawner-wrapped, no + // control channel) still polls a signal file for it. + if !spawner_wrapping_active && !config.share_dir.is_empty() { + entry.signal_file = + Some(PathBuf::from(&config.share_dir).join("openshell-shutdown.signal")); + } + Some((rx, done_tx)) + } else { + None + } + }; + + // 6. Monitor exec completion in background. + let registry2 = registry.clone(); + let watch_tx2 = watch_tx.clone(); + let sandbox2 = sandbox.clone(); + let sandbox_id2 = sandbox_id.clone(); + tokio::spawn(async move { + monitor_exec( + registry2, + watch_tx2, + attribution, + sandbox2, + sandbox_id2, + shutdown_rx, + ) + .await; + }); + + // A cancellable handle now exists in the registry (exec_child, plus + // shutdown_tx/terminated_rx for ProcessContainer) -- stop_sandbox/ + // delete_sandbox arriving from here on can act immediately instead of + // waiting out the target-readiness wait below. + drop(startup_guard); + + // When spawner wrapping is active, openshell-supervisor-relay.rs hasn't + // spawned the real target yet -- it waits for a "launch" request over + // the control channel instead of reading agent-cmd.txt/agent-env.txt + // from share_dir (see its module docs). Wait for its startup-ready + // event, then send the real command/env directly; this keeps them off + // disk (they can carry secrets, e.g. OPENCLAW_GATEWAY_TOKEN) and also + // proves the correlated request/response path works end to end -- the + // old unconditional "ping" this replaces only logged a warning on + // failure, but failure here is fatal: nothing was ever spawned. + if let (Some(channel), Some(ready_rx), Some(target_ready_rx)) = + (control_channel.clone(), ready_rx, target_ready_rx) + { + // Generous timeout: this fires right after spawn, so it's racing UAC + // elevation + AppContainer creation (observed up to several + // seconds), not just the control channel itself. The `forward` path + // won't have this constraint -- it only runs once the sandbox is + // already Ready, long past this window. + let ready_timeout = std::time::Duration::from_mins(2); + let ready_err = match tokio::time::timeout(ready_timeout, ready_rx).await { + Ok(Ok(Ok(()))) => None, + // Protocol version mismatch (see try_route_ready) -- an + // independently staged, out-of-sync relay binary. Reject fast + // and clearly instead of proceeding into a "launch" handshake + // it may not understand. + Ok(Ok(Err(version_err))) => Some(version_err), + Ok(Err(_)) => Some("spawner exited before sending its ready event".to_string()), + Err(_) => Some(format!( + "timed out after {ready_timeout:?} waiting for spawner ready event" + )), + }; + let launch_err = if let Some(e) = ready_err { + Some(e) + } else { + let launch_data = serde_json::json!({ + "command": sandbox_config.command, + "env": env, + }); + match channel + .request("launch", launch_data, std::time::Duration::from_mins(2)) + .await + { + Ok(resp) if resp.get("ok").and_then(serde_json::Value::as_bool) == Some(true) => { + info!(sandbox = %sandbox_name, "control-channel launch acknowledged"); + // The "launch" response above only confirms the + // command/env reached the spawner -- it still needs + // to spawn the target and confirm its configured + // port is accepting connections + // (openshell-supervisor-relay's own + // wait_for_port_ready, up to ~300s worst case across + // its own retries). Await that distinct + // "target_ready" event before treating launch as + // successful, so a caller acting on Ready=True below + // can never race a target that hasn't bound its port + // yet. + let target_ready_timeout = std::time::Duration::from_secs(310); + match tokio::time::timeout(target_ready_timeout, target_ready_rx).await { + Ok(Ok(Ok(()))) => { + info!(sandbox = %sandbox_name, "control-channel target ready"); + None + } + // No version gate on this event, so this arm + // never actually fires today -- see + // try_route_target_ready -- but match it + // explicitly rather than unreachable!(), in case + // that ever changes. + Ok(Ok(Err(target_err))) => Some(target_err), + Ok(Err(_)) => { + Some("spawner exited before its target became ready".to_string()) + } + Err(_) => Some(format!( + "timed out after {target_ready_timeout:?} waiting for target to become ready" + )), + } + } + Ok(resp) => Some( + resp.get("error") + .and_then(|v| v.as_str()) + .unwrap_or("launch rejected") + .to_string(), + ), + Err(e) => Some(e.to_string()), + } + }; + if let Some(err) = launch_err { + warn!(sandbox = %sandbox_name, "control-channel launch failed: {err}"); + set_failed(®istry, &watch_tx, &sandbox, &sandbox_id, &err).await; + // `child` was already moved into the registry (and possibly + // already claimed by monitor_exec, spawned above) once a + // cancellable handle was published. ProcessContainer has + // shutdown_tx/terminated_rx for exactly this: signal it and + // await confirmed termination, the same way stop_sandbox/ + // delete_sandbox would. Otherwise fall back to reclaiming + // exec_child directly and killing it (a monitor_exec race that + // hasn't claimed exec_child yet -- rare, but possible), or, for + // isolation_session (which has neither shutdown_tx/terminated_rx + // nor -- by this point -- a leftover exec_child, since + // monitor_exec almost always already claimed it), an explicit + // invoker.stop() on the isolation session. + let (shutdown_tx, terminated_rx, leftover_child, iso_id, isolation_stopped) = { + let mut reg = registry.lock().await; + match reg.get_mut(&sandbox_id) { + Some(entry) => ( + entry.shutdown_tx.take(), + // .clone(), not .take(): see stop_sandbox's matching + // comment on terminated_rx. + entry.terminated_rx.clone(), + entry.exec_child.take(), + entry.iso_sandbox_id.clone(), + entry.isolation_stopped, + ), + None => (None, None, None, None, false), } + }; + if let Some(tx) = shutdown_tx { + let _ = tx.send(()); } + if let Some(mut child) = leftover_child { + // We raced monitor_exec for exec_child and this branch's + // earlier `entry.exec_child.take()` won: monitor_exec will + // find the registry slot already empty and return before + // ever reaching its `done_tx.send(true)` (MR !98 review + // thread). Waiting on terminated_rx here would therefore + // wait out the full timeout for a signal that never comes, + // while `child` -- which does not kill-on-drop -- leaks. + // Kill and reap it directly instead. + let _ = child.kill().await; + let _ = child.wait().await; + } else if let Some(mut rx) = terminated_rx { + // leftover_child was None, so monitor_exec already claimed + // exec_child and is the one racing shutdown_tx against + // child.wait(); await its confirmed termination. + if !wait_for_termination(&mut rx).await { + warn!(sandbox = %sandbox_name, "timed out waiting for ProcessContainer termination after launch failure"); + } + } else if let Some(iso_id) = iso_id { + // isolation_session: no shutdown_tx/terminated_rx (those are + // ProcessContainer-only -- see the wiring site above) and no + // leftover_child either at this point, so without this the + // MXC session (and the wxc-exec `exec` child monitor_exec's + // own child.wait() is blocked on) would keep running + // indefinitely after a launch/target-ready failure here + // (MR !98 review thread). Best-effort: log rather than + // propagate, since this cleanup runs inside an + // already-failing path. + if !isolation_stopped { + if let Err(error) = invoker.stop(&iso_id).await { + warn!(sandbox = %sandbox_name, %error, "failed to stop isolation session after launch failure"); + } else { + let mut reg = registry.lock().await; + if let Some(entry) = reg.get_mut(&sandbox_id) { + entry.isolation_stopped = true; + } + } + } + } + return; } + } + // 5. Self-report Ready=True. The cancellable handle (exec_child, and for + // ProcessContainer shutdown_tx/terminated_rx) was already published to + // the registry and monitor_exec already spawned, above -- this only + // updates the sandbox's condition now that the target is confirmed + // reachable. + let ready_sandbox = make_sandbox_with_condition( + &sandbox, + &DriverCondition { + r#type: "Ready".into(), + status: "True".into(), + reason: "AgentRunning".into(), + message: format!("Agent exec launched: {command_line}"), + transition_time: None, + }, + false, + ); + { + let mut reg = registry.lock().await; + let Some(entry) = reg.get_mut(&sandbox_id) else { + return; + }; + // Only Starting -> Running is valid here. This publish runs after + // run_lifecycle released its startup gate (see drop(startup_guard) + // above) specifically so a stop/delete arriving during the + // target-readiness wait isn't blocked behind it -- but that means a + // stop/delete may have already moved this sandbox past Starting by + // the time this runs. Overwriting that final state with a stale + // Running, or emitting a Ready event for an already-Stopped + // sandbox, would be wrong (MR !98 review thread; same reasoning as + // set_failed's matching guard). + if entry.phase_state != PhaseState::Starting { + return; + } entry.sandbox = ready_sandbox.clone(); entry.phase_state = PhaseState::Running; - entry.monitor_cancel = Some(cancel_tx); - entry.monitor_task = Some(tokio::spawn(monitor_exec( - registry.clone(), - watch_tx.clone(), - attribution.clone(), - sandbox.clone(), - sandbox_id.clone(), - cancel_rx, - child, - ))); } let _ = watch_tx.send(sandbox_event(ready_sandbox)); } +async fn wait_for_termination(rx: &mut watch::Receiver) -> bool { + matches!( + tokio::time::timeout( + std::time::Duration::from_secs(10), + rx.wait_for(|done| *done) + ) + .await, + Ok(Ok(_)) + ) +} + async fn monitor_exec( registry: Arc>>, watch_tx: Arc>, attribution: Arc>, sandbox: DriverSandbox, sandbox_id: String, - mut cancel_rx: watch::Receiver, - mut child: tokio::process::Child, + shutdown: Option<(oneshot::Receiver<()>, watch::Sender)>, ) { + let child = { + let mut reg = registry.lock().await; + reg.get_mut(&sandbox_id).and_then(|e| e.exec_child.take()) + }; + let Some(mut child) = child else { + return; + }; let wxc_pid = child.id(); - let status = tokio::select! { - status = child.wait() => Some(status), - changed = cancel_rx.changed() => { - let should_kill = changed.is_ok() && *cancel_rx.borrow_and_update(); - if should_kill { + + // For ProcessContainer sandboxes a stop/delete can arrive while the + // agent is still running. Race child exit against the kill signal so + // that the wxc-exec process — and therefore the entire AppContainer + // (including any long-lived servers bound to ports) — is terminated + // promptly. `done_tx`, when present, is signaled once the process has + // genuinely exited (natural exit or the forced kill below) so + // stop_sandbox/delete_sandbox can await *confirmed* termination instead + // of firing the kill signal and immediately reporting success. + let (wait_result, done_tx) = if let Some((rx, done_tx)) = shutdown { + tokio::select! { + res = child.wait() => (res, Some(done_tx)), + _ = rx => { if let Err(error) = child.kill().await { - warn!(sandbox = %sandbox.name, error = %error, "failed to terminate MXC agent process"); + warn!(sandbox = %sandbox.name, %error, "failed to terminate MXC process"); + return; } - // `kill` waits on current Tokio releases, but an explicit wait is - // harmless and guarantees the OS process handle is reaped. - let _ = child.wait().await; + if let Err(error) = child.wait().await { + warn!(sandbox = %sandbox.name, %error, "failed to confirm MXC process termination"); + return; + } + if let Some(pid) = wxc_pid + && let Ok(mut idx) = attribution.lock() + { + idx.retire_launch(&sandbox_id, pid); + } + info!(sandbox = %sandbox.name, "MXC ProcessContainer terminated for sandbox stop/delete"); + let _ = done_tx.send(true); + return; } - None } + } else { + (child.wait().await, None) }; - - // A Windows PID is authoritative only while the exact driver-owned child is - // alive. Retire it on every monitor exit path, including cancellation, before - // Windows can recycle it while the sandbox remains in the registry. - if let Some(pid) = wxc_pid - && let Ok(mut idx) = attribution.lock() - { - idx.retire_launch(&sandbox_id, pid); + if wait_result.is_ok() { + if let Some(pid) = wxc_pid + && let Ok(mut idx) = attribution.lock() + { + idx.retire_launch(&sandbox_id, pid); + } + if let Some(done_tx) = done_tx { + let _ = done_tx.send(true); + } } - let Some(status) = status else { - return; - }; - - match status { + match wait_result { Ok(status) if status.success() => { info!(sandbox = %sandbox.name, "MXC agent exec completed successfully"); let done = make_sandbox_with_condition( @@ -1303,11 +2334,24 @@ async fn set_failed( false, ); let mut reg = registry.lock().await; - if let Some(entry) = reg.get_mut(sandbox_id) { - entry.host_proxy = None; - entry.sandbox = failed.clone(); - entry.phase_state = PhaseState::Failed(message.to_string()); + let Some(entry) = reg.get_mut(sandbox_id) else { + return; + }; + // Only Starting -> Failed is valid here. Every call site before + // run_lifecycle releases its startup gate is safe by construction + // (phase_state is provably still Starting -- stop_sandbox/delete_sandbox + // can't touch the entry until the gate opens). The one call site after + // the gate is released (the control-channel launch-failure path) is not: + // a concurrent stop/delete may have already moved this sandbox past + // Starting while this lifecycle was still waiting on target-readiness, + // and overwriting that final state with a stale Failed -- or emitting a + // Failed event for an already-Stopped sandbox -- would be wrong + // (MR !98 review thread). + if entry.phase_state != PhaseState::Starting { + return; } + entry.sandbox = failed.clone(); + entry.phase_state = PhaseState::Failed(message.to_string()); drop(reg); let _ = watch_tx.send(sandbox_event(failed)); } @@ -1360,6 +2404,21 @@ mod lifecycle_tests { driver_sandbox_with_command(id, "", vec!["cmd".into(), "/c".into(), "exit 0".into()]) } + #[tokio::test] + async fn termination_confirmation_rejects_closed_unconfirmed_channel() { + let (tx, mut rx) = watch::channel(false); + drop(tx); + assert!(!wait_for_termination(&mut rx).await); + } + + #[tokio::test] + async fn termination_confirmation_accepts_confirmed_exit() { + let (tx, mut rx) = watch::channel(false); + tx.send(true).unwrap(); + drop(tx); + assert!(wait_for_termination(&mut rx).await); + } + #[test] fn ui_policy_capability_tracks_configured_backend() { let process_container = MxcComputeBackend::new_mocked(MxcComputeConfig::default()); @@ -1500,12 +2559,9 @@ mod lifecycle_tests { config.egress_proxy_addr = "127.0.0.1:18080".into(); config.backend = MxcBackend::IsolationSession; - assert!( - configured_egress_addr(&config) - .unwrap_err() - .message() - .contains("MXC M1") - ); + let error = configured_egress_addr(&config).unwrap_err(); + assert_eq!(error.code(), tonic::Code::InvalidArgument); + assert!(error.message().contains("requires process_container")); } #[test] @@ -1685,7 +2741,152 @@ mod lifecycle_tests { } #[test] - fn sandbox_environment_inherits_host_with_spec_precedence() { + fn tls_state_replaces_user_trust_overrides_and_grants_ca_directory_once() { + let tls_dir = std::env::temp_dir().join("openshell-mxc-tls-test"); + let ca_cert = tls_dir.join("openshell-ca.pem"); + let bundle = tls_dir.join("ca-bundle.pem"); + let mut env = vec![ + "FOO=bar".to_string(), + "SSL_CERT_FILE=C:\\old\\bundle.pem".to_string(), + ]; + append_tls_env_vars(&mut env, Some(&(ca_cert.clone(), bundle.clone()))); + assert!(env.contains(&"FOO=bar".to_string())); + assert!( + !env.iter() + .any(|entry| entry == "SSL_CERT_FILE=C:\\old\\bundle.pem") + ); + assert!(env.contains(&format!("SSL_CERT_FILE={}", bundle.display()))); + + let existing = tls_dir.display().to_string().to_ascii_lowercase(); + let mut readonly = vec![existing.clone()]; + append_tls_readonly_grant(&mut readonly, Some(&(ca_cert, bundle))); + assert_eq!(readonly, vec![existing]); + } + + #[test] + fn tls_ca_files_are_staged_under_the_authorized_share() { + let source = tempfile::tempdir().unwrap(); + let share = tempfile::tempdir().unwrap(); + let ca = source.path().join("source-ca.pem"); + let bundle = source.path().join("source-bundle.pem"); + std::fs::write(&ca, b"ca").unwrap(); + std::fs::write(&bundle, b"bundle").unwrap(); + + let staged = stage_tls_ca_files( + Some(&(ca, bundle)), + share.path().to_str().expect("UTF-8 test path"), + "sandbox-a", + ) + .unwrap() + .expect("staged paths"); + + assert_eq!( + staged.0.parent().unwrap(), + share.path().join(".openshell-proxy").join("sandbox-a") + ); + assert_eq!(std::fs::read(staged.0).unwrap(), b"ca"); + assert_eq!(std::fs::read(staged.1).unwrap(), b"bundle"); + } + + #[test] + fn tls_ca_staging_keeps_sandboxes_in_the_same_share_independent() { + let source = tempfile::tempdir().unwrap(); + let share = tempfile::tempdir().unwrap(); + let paths = ( + source.path().join("ca.pem"), + source.path().join("bundle.pem"), + ); + std::fs::write(&paths.0, b"first-ca").unwrap(); + std::fs::write(&paths.1, b"first-bundle").unwrap(); + let first = stage_tls_ca_files(Some(&paths), share.path().to_str().unwrap(), "sandbox-a") + .unwrap() + .unwrap(); + std::fs::write(&paths.0, b"second-ca").unwrap(); + std::fs::write(&paths.1, b"second-bundle").unwrap(); + let second = stage_tls_ca_files(Some(&paths), share.path().to_str().unwrap(), "sandbox-b") + .unwrap() + .unwrap(); + + assert_ne!(first, second); + assert_eq!(std::fs::read(first.0).unwrap(), b"first-ca"); + assert_eq!(std::fs::read(first.1).unwrap(), b"first-bundle"); + assert_eq!(std::fs::read(second.0).unwrap(), b"second-ca"); + assert_eq!(std::fs::read(second.1).unwrap(), b"second-bundle"); + } + + #[test] + fn tls_ca_staging_rejects_empty_shares_and_unsafe_sandbox_components() { + let share = tempfile::tempdir().unwrap(); + let paths = (PathBuf::from("unused-ca"), PathBuf::from("unused-bundle")); + for empty in ["", " ", "\t"] { + assert_eq!( + stage_tls_ca_files(Some(&paths), empty, "sandbox-a") + .unwrap_err() + .kind(), + std::io::ErrorKind::InvalidInput + ); + } + for id in [ + "", + ".", + "..", + "../outside", + "..\\outside", + "C:\\outside", + "file:stream", + ] { + assert_eq!( + stage_tls_ca_files(Some(&paths), share.path().to_str().unwrap(), id) + .unwrap_err() + .kind(), + std::io::ErrorKind::InvalidInput + ); + } + assert_eq!(std::fs::read_dir(share.path()).unwrap().count(), 0); + assert_eq!(stage_tls_ca_files(None, "", "").unwrap(), None); + } + + #[test] + fn proxy_env_replaces_inherited_values_and_clears_bypass_rules() { + let mut env = vec![ + "PATH=C:\\Windows".to_owned(), + "HTTP_PROXY=http://stale.invalid:1".to_owned(), + "https_proxy=http://stale.invalid:2".to_owned(), + "NO_PROXY=example.com".to_owned(), + "no_proxy=example.org".to_owned(), + ]; + + let proxy_auth = SandboxProxyAuth { + password: "sandbox-secret".to_owned(), + }; + append_proxy_env_vars( + &mut env, + Some("127.0.0.1:18080".parse().unwrap()), + Some(&proxy_auth), + ); + + assert!(env.contains(&"PATH=C:\\Windows".to_owned())); + for key in PROXY_ENV_KEYS { + assert_eq!( + env.iter() + .filter(|entry| entry.starts_with(&format!("{key}="))) + .count(), + 1, + "{key} must be emitted exactly once" + ); + } + assert!( + env.contains(&"HTTP_PROXY=http://openshell:sandbox-secret@127.0.0.1:18080".to_owned()) + ); + assert!( + env.contains(&"HTTPS_PROXY=http://openshell:sandbox-secret@127.0.0.1:18080".to_owned()) + ); + assert!(env.contains(&"NO_PROXY=".to_owned())); + assert!(env.contains(&"no_proxy=".to_owned())); + } + + #[test] + fn sandbox_environment_uses_sandbox_scope_with_spec_precedence() { let mut sandbox = driver_sandbox("sb-env"); let spec = sandbox.spec.as_mut().unwrap(); spec.template @@ -1695,17 +2896,12 @@ mod lifecycle_tests { .insert("SHARED".into(), "template".into()); spec.environment.insert("SHARED".into(), "spec".into()); spec.environment.insert("TOKEN".into(), "value".into()); - let environment = sandbox_environment(&sandbox); + let environment = sandbox_environment(&sandbox, &MxcComputeConfig::default()); assert!(environment.contains(&"SHARED=spec".to_string())); assert!(environment.contains(&"TOKEN=value".to_string())); - for key in MINIMAL_WINDOWS_BOOTSTRAP_ENV { - if let Ok(value) = std::env::var(key) { - assert!(environment.contains(&format!("{key}={value}"))); - } - } assert!(environment.iter().all(|entry| { let key = entry.split_once('=').map_or(entry.as_str(), |(key, _)| key); - key == "SHARED" || key == "TOKEN" || MINIMAL_WINDOWS_BOOTSTRAP_ENV.contains(&key) + key == "SHARED" || key == "TOKEN" })); } @@ -1798,7 +2994,7 @@ mod lifecycle_tests { assert!(ready.is_some(), "sandbox should self-report Ready=True"); // Positive proof: the in-policy write materializes the host artifact. - let host_path = std::path::Path::new(tmp.path()).join("hello.txt"); + let host_path = tmp.path().join("hello.txt"); let mut found = false; for _ in 0..100 { if host_path.exists() { @@ -1868,7 +3064,7 @@ mod lifecycle_tests { assert_eq!(recorded["ui"]["clipboard"], "none"); assert_eq!(recorded["ui"]["injection"], false); - let host_path = std::path::Path::new(tmp.path()).join("hello.txt"); + let host_path = tmp.path().join("hello.txt"); let mut found = false; for _ in 0..100 { if host_path.exists() { @@ -1941,23 +3137,16 @@ mod lifecycle_tests { ); let recorded = crate::mxc::mock_recorded_config("sb-egress").expect("mock recorded config"); - assert_eq!(recorded["network"]["defaultPolicy"], "block"); + assert_eq!(recorded["version"], "0.8.0-alpha"); + assert_eq!(recorded["network"]["egress"]["default"], "deny"); + assert_eq!( + recorded["network"]["egress"]["allow"], + serde_json::json!([{"to": [{"cidr": "127.0.0.1/32"}]}]) + ); assert!(recorded["network"].get("allowedHosts").is_none()); assert!(recorded["network"].get("blockedHosts").is_none()); - // MXC 0.6.0-alpha accepts only {"proxy": {"localhost": N}}. - let proxy_port = recorded["network"]["proxy"]["localhost"] - .as_u64() - .expect("proxy localhost port"); - assert!(proxy_port > 0); - assert!(u16::try_from(proxy_port).is_ok()); - assert!( - recorded["network"]["proxy"].get("host").is_none(), - "proxy must not contain 'host' key" - ); - assert!( - recorded["network"]["proxy"].get("port").is_none(), - "proxy must not contain 'port' key" - ); + assert!(recorded["network"].get("proxy").is_none()); + assert!(recorded.get("networkProxy").is_none()); let reg = backend.registry.lock().await; let entry = reg.get("sb-egress").expect("registry entry"); @@ -1966,7 +3155,19 @@ mod lifecycle_tests { entry_proxy_addr.ip(), std::net::IpAddr::from([127, 0, 0, 1]) ); - assert_eq!(u64::from(entry_proxy_addr.port()), proxy_port); + assert_ne!(entry_proxy_addr.port(), 0); + let child_env = recorded["process"]["env"].as_array().expect("child env"); + let proxy_env = child_env + .iter() + .filter_map(serde_json::Value::as_str) + .find_map(|entry| entry.strip_prefix("HTTP_PROXY=")) + .expect("HTTP_PROXY must direct clients to the authenticated proxy"); + let (credentials, address) = proxy_env + .strip_prefix("http://openshell:") + .and_then(|value| value.split_once('@')) + .expect("proxy URL must contain the per-sandbox credential"); + assert!(!credentials.is_empty()); + assert_eq!(address, entry_proxy_addr.to_string()); assert_eq!( entry.trimmed_policy.as_ref().unwrap().network_policies, policy.network_policies @@ -2094,7 +3295,7 @@ mod lifecycle_tests { ); // The out-of-policy artifact must NOT have been written by the mock. - let out_fs = std::path::Path::new(out_tmp.path()).join("hello.txt"); + let out_fs = out_tmp.path().join("hello.txt"); assert!(!out_fs.exists(), "out-of-policy write must be denied"); // And the sandbox surfaces a terminal ExecFailed Ready=False condition. @@ -2139,6 +3340,144 @@ mod lifecycle_tests { assert_eq!(ready_condition(&stopped).unwrap().reason, "Stopped"); } + /// `delete_sandbox` must await *confirmed* `ProcessContainer` termination + /// before removing the registry entry and reporting success -- not just + /// fire the kill signal and report success regardless (which could + /// leave the process retaining ports and file locks past a successful + /// delete). Bounded well under the child's own sleep duration: if + /// delete stopped awaiting `terminated_rx`, this would still return + /// quickly (the bug was reporting success *too early*, not hanging), so + /// the meaningful assertion is that the sandbox is confirmed gone from + /// the registry immediately after -- a second delete finds nothing. + #[tokio::test] + async fn delete_terminates_and_reaps_a_running_process_container() { + let tmp = tempfile::tempdir().unwrap(); + let share = tmp.path().to_string_lossy().replace('\\', "/"); + let command = vec![ + "powershell".into(), + "-NoProfile".into(), + "-Command".into(), + format!("$null = '{share}'; Start-Sleep -Seconds 60"), + ]; + let backend = MxcComputeBackend::new_mocked(MxcComputeConfig::default()); + let policy = fs_policy(&[&share]); + let sandbox = with_policy( + driver_sandbox_with_command("sb-delete", "", command), + policy, + ); + backend + .create_sandbox(&sandbox) + .await + .expect("create accepted"); + wait_for(&backend, "sb-delete", |sandbox| { + ready_condition(sandbox).is_some_and(|condition| condition.reason == "AgentRunning") + }) + .await + .expect("long-running child should start"); + tokio::time::sleep(Duration::from_millis(250)).await; + + let deleted = tokio::time::timeout( + Duration::from_secs(5), + backend.delete_sandbox(&sandbox.id, "sb-delete"), + ) + .await + .expect("delete should not wait for the child sleep") + .expect("delete should terminate and reap the child"); + assert!(deleted, "delete should report the sandbox as removed"); + assert!( + backend.get_sandbox("sb-delete").await.is_none(), + "sandbox should be gone from the registry after delete" + ); + } + + /// `delete_sandbox` must confirm genuine termination via `terminated_rx` + /// even when `shutdown_tx` was already consumed by an earlier attempt (a + /// timed-out delete/stop, or -- as constructed directly here -- any + /// other caller that got to the field first). `terminated_rx` must + /// therefore be `.clone()`d, not `.take()`n, from the registry entry: + /// taking it would make this call's own None-shutdown_tx branch skip + /// the wait entirely and report success (and remove the registry entry) + /// before termination was ever confirmed -- exactly the MR !98 review + /// thread this regression-tests ("delete timeout loses termination + /// state"). + #[tokio::test] + async fn delete_confirms_termination_even_when_shutdown_tx_already_taken() { + let tmp = tempfile::tempdir().unwrap(); + let share = tmp.path().to_string_lossy().replace('\\', "/"); + let command = vec![ + "powershell".into(), + "-NoProfile".into(), + "-Command".into(), + format!("$null = '{share}'; Start-Sleep -Seconds 60"), + ]; + let backend = MxcComputeBackend::new_mocked(MxcComputeConfig::default()); + let policy = fs_policy(&[&share]); + let sandbox = with_policy( + driver_sandbox_with_command("sb-delete-retry", "", command), + policy, + ); + backend + .create_sandbox(&sandbox) + .await + .expect("create accepted"); + wait_for(&backend, "sb-delete-retry", |sandbox| { + ready_condition(sandbox).is_some_and(|condition| condition.reason == "AgentRunning") + }) + .await + .expect("long-running child should start"); + tokio::time::sleep(Duration::from_millis(250)).await; + + // Simulate an earlier delete/stop attempt that already consumed and + // fired shutdown_tx (e.g. one that then timed out before + // confirming termination) -- take a fresh watch subscription first + // so this test can observe the SAME completion delete_sandbox + // itself must wait for. + let terminated = { + let registry = backend.registry.lock().await; + registry + .get(&sandbox.id) + .expect("sandbox should be registered") + .terminated_rx + .clone() + .expect("ProcessContainer entry should have terminated_rx wired") + }; + { + let mut registry = backend.registry.lock().await; + let entry = registry + .get_mut(&sandbox.id) + .expect("sandbox should be registered"); + let tx = entry + .shutdown_tx + .take() + .expect("ProcessContainer entry should have shutdown_tx wired"); + let _ = tx.send(()); + } + + let deleted = tokio::time::timeout( + Duration::from_secs(5), + backend.delete_sandbox(&sandbox.id, "sb-delete-retry"), + ) + .await + .expect("delete should still confirm termination within the timeout, not hang") + .expect("delete should succeed even with shutdown_tx already taken"); + assert!(deleted, "delete should report the sandbox as removed"); + + // The regression this guards against: delete_sandbox used to + // .take() terminated_rx too, so finding shutdown_tx already None + // made it skip waiting entirely and return success immediately -- + // well before the real OS process had actually been killed and + // reaped. If that bug were back, this watch value could still be + // false right here. + assert!( + *terminated.borrow(), + "delete_sandbox must not report success before terminated_rx confirms the process died" + ); + assert!( + backend.get_sandbox("sb-delete-retry").await.is_none(), + "sandbox should be gone from the registry after delete" + ); + } + #[tokio::test] async fn unmappable_network_policy_fails_create_lifecycle() { use openshell_core::proto::{NetworkEndpoint, NetworkPolicyRule}; diff --git a/crates/openshell-driver-mxc/src/lib.rs b/crates/openshell-driver-mxc/src/lib.rs index 4b9d328e9f..dce22b3b49 100644 --- a/crates/openshell-driver-mxc/src/lib.rs +++ b/crates/openshell-driver-mxc/src/lib.rs @@ -15,6 +15,8 @@ #![allow(clippy::result_large_err)] +#[cfg(target_os = "windows")] +mod control_channel; #[cfg(target_os = "windows")] mod driver; #[cfg(target_os = "windows")] @@ -32,11 +34,17 @@ mod policy_map; // Windows-only. #[cfg(target_os = "windows")] mod etw_consumer; +#[cfg(target_os = "windows")] +mod relay; #[cfg(target_os = "windows")] -pub use driver::{MxcBackend, MxcComputeBackend, MxcComputeConfig}; +pub use driver::{ + ForwardSink, MxcBackend, MxcComputeBackend, MxcComputeConfig, OpenDynamicForwardError, +}; #[cfg(target_os = "windows")] pub use grpc::ComputeDriverService; +#[cfg(target_os = "windows")] +pub use relay::RelayHandle; // Re-export the embedded mapper API so the windows-only example and integration // test can reach it without making `policy_map` a public module. #[cfg(target_os = "windows")] diff --git a/crates/openshell-driver-mxc/src/mxc.rs b/crates/openshell-driver-mxc/src/mxc.rs index 1bb2304c1b..eecc1f7155 100644 --- a/crates/openshell-driver-mxc/src/mxc.rs +++ b/crates/openshell-driver-mxc/src/mxc.rs @@ -15,10 +15,11 @@ use std::path::PathBuf; use std::sync::{Mutex, OnceLock}; use thiserror::Error; use tokio::process::Command; -use tracing::debug; +use tracing::{debug, info}; -/// MXC config schema version. -pub const MXC_SCHEMA_VERSION: &str = "0.6.0-alpha"; +/// MXC config schema version. The mapper and one-shot launcher share the +/// MXC 0.8 directional network schema. +pub const MXC_SCHEMA_VERSION: &str = "0.8.0-alpha"; /// Default `configurationId` for isolation session. Never use `"small"` (known OS bug). pub const DEFAULT_CONFIGURATION_ID: &str = "composable"; @@ -69,6 +70,10 @@ pub struct MxcFilesystem { pub struct MxcNetwork { pub default_policy: String, pub proxy: Option, + /// When true, includes `"allowLocalNetwork": true` in the network JSON. + /// Required for node.js to initialize inside a processcontainer — without + /// it, node.exe DLL initialization fails with `STATUS_DLL_INIT_FAILED`. + pub allow_local_network: bool, } /// Directional clipboard access in the MXC top-level `ui` policy. @@ -128,20 +133,74 @@ pub struct MxcProcess { pub timeout: u64, } +/// Redacts `process.env` and `process.commandLine` from a wxc-config JSON +/// before it's ever logged or written to a sandbox-readable path (only +/// under `self.debug`, but debug output still isn't a safe place for it). +/// Both can carry host secrets verbatim -- `env` e.g. the shipped +/// `OpenClaw` example config's `OPENCLAW_GATEWAY_TOKEN`, `commandLine` +/// whenever a secret is passed as a literal CLI argument -- and both debug +/// sinks (gateway logs, and for `run_oneshot` a file inside the sandbox's +/// own readwrite path) are places an attacker or an over-broad log +/// retention policy could read from. Everything else debug tooling might +/// need to compare (filesystem grants, network policy, ...) is left intact. +fn redact_env_for_debug(config: &serde_json::Value) -> serde_json::Value { + let mut redacted = config.clone(); + if let Some(env) = redacted.get_mut("process").and_then(|p| p.get_mut("env")) { + let count = env.as_array().map_or(0, Vec::len); + *env = serde_json::json!(format!("")); + } + if let Some(command_line) = redacted + .get_mut("process") + .and_then(|p| p.get_mut("commandLine")) + { + *command_line = serde_json::json!(""); + } + redacted +} + fn network_json(network: &MxcNetwork) -> serde_json::Value { - // MXC 0.6.0-alpha schema accepts ONLY {"proxy": {"localhost": }}. - // {"host": ..., "port": ...} and every other shape is rejected — verified - // empirically against the real wxc-exec 0.6.0-alpha binary via --dry-run. - // See also docs/reference/mxc-compute-driver-design.mdx §network.proxy. - // The MxcNetwork.proxy field remains SocketAddr so callers keep full - // precision; only the port is serialized into the localhost key. - // Released wxc-exec rejects allowedHosts and blockedHosts as unsupported, - // even when they are empty. The host proxy enforces the L7 allowlist. - let mut value = serde_json::json!({ - "defaultPolicy": network.default_policy.as_str(), - }); - if let Some(proxy) = network.proxy { - value["proxy"] = serde_json::json!({ "localhost": proxy.port() }); + // MXC 0.8.0-alpha schema uses a directional egress/ingress format. + // "block" default_policy maps to egress.default "deny"; "allow" maps to "allow". + let egress_default = if network.default_policy == "block" { + "deny" + } else { + "allow" + }; + let mut value = if network.proxy.is_some() { + // Use direct loopback egress rather than runtimeConfig.networkProxy proxy + // mode. Proxy mode routes all outbound TCP through processmodel.dll's WFP + // redirect, which in practice blocks loopback connects from the relay to + // its target process (127.0.0.1:port) even with networkLoopback capability + // in the PSEC spec. Direct allow for 127.0.0.1/32 (not the broader 127.0.0.0/8 + // range -- openshell-supervisor-relay only ever dials the literal + // 127.0.0.1, see imp.rs) lets the relay reach: + // - the target it spawns (loopback inside AppContainer) + // - the host relay listener (also 127.0.0.1 via egress allow) + // PSEC tier is still selected because requires_psec_networking() returns + // true when egress.allow is non-empty (no NetworkIsolationSetAppContainerConfig + // call needed — no elevation required). + // + // Deliberately no `ports` restriction: `openshell forward service`'s + // dynamic bridge (imp.rs's "forward" control-channel op) connects the + // relay out to a fresh, per-request ephemeral host port chosen at + // forward-call time (data.relay_addr), not a port known when this + // config is generated -- confirmed 2026-09-10 that scoping `ports` to + // just [proxy.port(), relay_target_port] breaks that dynamic forward + // (ws-echo failed with a "forbidden by access permissions" / 10013 + // relay-connect error). Any-port-on-127.0.0.1 is the correct scope + // here, not a narrower static list. + serde_json::json!({ + "egress": { + "default": "deny", + "allow": [{"to": [{"cidr": "127.0.0.1/32"}]}] + }, + "ingress": { "default": "allow", "hostLoopback": "allow" }, + }) + } else { + serde_json::json!({ "egress": { "default": egress_default } }) + }; + if network.proxy.is_none() && network.allow_local_network { + value["ingress"] = serde_json::json!({ "default": "allow", "hostLoopback": "allow" }); } value } @@ -219,6 +278,12 @@ fn oneshot_config_json( if let Some(network) = network { config["network"] = network_json(network); } + // Root-level ui section required by mxc-fixes-env-vars build. Comes from + // the typed SandboxPolicy via `ui` -- EmbeddedPolicyMapper always + // populates this for process_container (with restrictive defaults -- + // disable=true, Win32k syscall lockdown -- when the policy has no + // explicit `ui:` section), so `None` here only happens in tests that + // bypass the mapper; omit the section entirely rather than guess. if let Some(ui) = ui { config["ui"] = ui_json(ui); } @@ -375,7 +440,11 @@ impl WxcExecInvoker { cmd.arg("--debug"); } - debug!(config = %json, "wxc-exec phase"); + // `config` here never carries `process.env` today (provision/start/ + // stop/deprovision have no `process` field at all -- see run_phase's + // doc comment), but redact defensively rather than relying on that + // staying true. + debug!(config = %redact_env_for_debug(config), "wxc-exec phase"); let output = cmd.output().await?; let stdout = String::from_utf8_lossy(&output.stdout).into_owned(); @@ -446,7 +515,13 @@ impl WxcExecInvoker { cmd.arg("--debug"); } - debug!(config = %json, "wxc-exec provision"); + let redacted = redact_env_for_debug(&config); + if self.debug { + let pretty = serde_json::to_string_pretty(&redacted).unwrap_or_else(|_| json.clone()); + info!("generated wxc-config (provision):\n{pretty}"); + } else { + debug!(config = %redacted, "wxc-exec provision"); + } let output = cmd.output().await?; let stdout = String::from_utf8_lossy(&output.stdout).into_owned(); @@ -534,15 +609,26 @@ impl WxcExecInvoker { cmd.arg("--config-base64") .arg(&b64) .arg("--experimental") - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::inherit()) - .stderr(std::process::Stdio::inherit()) - .kill_on_drop(true); + // Piped (not null): mirrors the ProcessContainer one-shot spawn + // below -- with STDIO passthrough, wxc-exec forwards this handle + // down to the exec'd child, giving the driver a control channel + // into the isolation_session sandbox with no network capability + // required. Without this, pc_relay_spawner_path's control channel + // (and therefore dynamic `openshell forward service`) silently + // has nothing to attach to on this backend. + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); if self.debug { cmd.arg("--debug"); } - debug!(sandbox_id = %iso_sandbox_id, command = %process.command_line, "wxc-exec exec spawn"); + if self.debug { + let redacted = redact_env_for_debug(&config); + let pretty = serde_json::to_string_pretty(&redacted).unwrap_or_else(|_| json.clone()); + info!(sandbox_id = %iso_sandbox_id, "generated wxc-config (exec):\n{pretty}"); + } + info!(sandbox_id = %iso_sandbox_id, "wxc-exec exec spawn"); let child = cmd.spawn()?; Ok(child) } @@ -640,20 +726,49 @@ impl WxcExecInvoker { } let json = serde_json::to_string(&config)?; + if self.debug { + // Redacted before either sink: the readwrite path is inside the + // sandbox itself (readable by whatever untrusted code runs + // there), and gateway logs may have broader retention/access + // than the secrets in `process.env` (e.g. OPENCLAW_GATEWAY_TOKEN + // in the shipped OpenClaw example config) should get. + let redacted = redact_env_for_debug(&config); + let redacted_json = serde_json::to_string(&redacted).unwrap_or_else(|_| json.clone()); + // Dump into the first readwrite path for comparison. + if let Some(rw) = config + .get("filesystem") + .and_then(|f| f.get("readwritePaths")) + .and_then(|a| a.as_array()) + .and_then(|a| a.first()) + .and_then(|v| v.as_str()) + { + let _ = std::fs::write( + std::path::Path::new(rw).join("wxc-exec-config-debug.json"), + &redacted_json, + ); + } + let pretty = + serde_json::to_string_pretty(&redacted).unwrap_or_else(|_| redacted_json.clone()); + info!(container_id = %container_id, "generated wxc-config:\n{pretty}"); + } let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); let mut cmd = Command::new(&self.exec_path); cmd.arg("--config-base64") .arg(&b64) - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::inherit()) - .stderr(std::process::Stdio::inherit()) - .kill_on_drop(true); + // Piped (not null): with STDIO passthrough, wxc-exec forwards this + // handle down to the sandboxed child, giving the driver a write + // channel into the AppContainer with no network capability + // required at all -- see openshell-supervisor-relay's stdin/stdout + // JSON control protocol. + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); if self.debug { cmd.arg("--debug"); } - debug!(container_id = %container_id, command = %process.command_line, "wxc-exec one-shot processContainer spawn"); + info!(container_id = %container_id, "wxc-exec one-shot processContainer spawn"); let child = cmd.spawn()?; Ok(child) } @@ -792,7 +907,7 @@ mod tests { } #[test] - fn provision_config_json_includes_network_proxy_when_supplied() { + fn provision_config_json_includes_network_loopback_when_proxy_supplied() { let filesystem = MxcFilesystem { readwrite_paths: vec!["C:\\work\\demo".into()], readonly_paths: Vec::new(), @@ -801,39 +916,47 @@ mod tests { let network = MxcNetwork { default_policy: "block".into(), proxy: Some("127.0.0.1:18080".parse().unwrap()), + allow_local_network: false, }; let config = provision_config_json(DEFAULT_CONFIGURATION_ID, &filesystem, Some(&network)); - assert_eq!(config["network"]["defaultPolicy"], "block"); - assert!(config["network"].get("allowedHosts").is_none()); - assert!(config["network"].get("blockedHosts").is_none()); - // MXC 0.6.0-alpha accepts only {"proxy": {"localhost": N}}. - assert_eq!(config["network"]["proxy"]["localhost"], 18080); - assert!( - config["network"]["proxy"].get("host").is_none(), - "proxy must not contain 'host' key" - ); - assert!( - config["network"]["proxy"].get("port").is_none(), - "proxy must not contain 'port' key" + // With proxy: use direct loopback egress (127.0.0.1/32 allow) instead of + // runtimeConfig.networkProxy proxy mode, so relay can reach its spawned + // target process via loopback without processmodel.dll proxy-redirect WFP + // interference. PSEC tier still selected via requires_psec_networking(). + assert_eq!(config["network"]["egress"]["default"], "deny"); + assert_eq!( + config["network"]["egress"]["allow"][0]["to"][0]["cidr"], + "127.0.0.1/32" ); + assert_eq!(config["network"]["ingress"]["default"], "allow"); + assert_eq!(config["network"]["ingress"]["hostLoopback"], "allow"); + assert!(config["network"].get("defaultPolicy").is_none()); + assert!(config["network"].get("proxy").is_none()); + // No runtimeConfig.networkProxy — using direct loopback egress instead. + assert!(config.get("runtimeConfig").is_none()); } #[test] - fn network_json_emits_localhost_port_shape() { - // MXC 0.6.0-alpha rejects {"host":...,"port":...} and accepts only - // {"proxy": {"localhost": N}} — verified against the real binary via - // --dry-run. This test pins the exact emitted JSON shape. + fn network_json_emits_directional_format() { + // MXC 0.8.0-alpha: egress/ingress replaces the legacy + // defaultPolicy / allowedHosts / proxy.localhost shape. let network = MxcNetwork { default_policy: "block".into(), proxy: Some("127.0.0.1:18080".parse().unwrap()), + allow_local_network: false, }; let value = network_json(&network); - assert!(value.get("allowedHosts").is_none()); - assert!(value.get("blockedHosts").is_none()); - assert_eq!(value["proxy"]["localhost"], 18080); - assert!(value["proxy"].get("host").is_none()); - assert!(value["proxy"].get("port").is_none()); + // Loopback-allow mode: egress.default="deny" with 127.0.0.1/32 allow rule. + // Allows relay to reach both the spawned target (intra-container loopback) + // and the host relay listener (host loopback) without proxy-mode WFP issues. + assert_eq!(value["egress"]["default"], "deny"); + assert_eq!(value["egress"]["allow"][0]["to"][0]["cidr"], "127.0.0.1/32"); + // ingress.hostLoopback="allow" grants networkLoopback PSEC capability. + assert_eq!(value["ingress"]["default"], "allow"); + assert_eq!(value["ingress"]["hostLoopback"], "allow"); + assert!(value.get("proxy").is_none()); + assert!(value.get("defaultPolicy").is_none()); } #[test] diff --git a/crates/openshell-driver-mxc/src/policy_map/map.rs b/crates/openshell-driver-mxc/src/policy_map/map.rs index 08cd3859c7..4af3290715 100644 --- a/crates/openshell-driver-mxc/src/policy_map/map.rs +++ b/crates/openshell-driver-mxc/src/policy_map/map.rs @@ -21,12 +21,14 @@ use super::config::{ add_backend_specific_config, default_enforcement_mode, filesystem_default_deny_message, }; use super::loss::{LossItem, add_loss}; +use crate::mxc::MXC_SCHEMA_VERSION; /// Options controlling the generated MXC config. Fields not relevant to the /// coarse map (e.g. `proxy_redirect`) are reserved for the governed-egress split. #[derive(Clone, Debug)] pub struct MxcMappingOptions { - /// MXC schema version written into `version`. + /// MXC schema version written into coarse-map output. The governed-egress + /// split uses the driver's MXC 0.8 schema. pub mxc_version: String, /// MXC containment backend. pub containment: String, @@ -70,16 +72,15 @@ pub struct MxcMappingResult { pub loss: Vec, } -/// Result of the governed-egress split: the MXC config carries filesystem grants and a -/// proxy redirect; the full network policy is returned unchanged for the -/// `OpenShell` CONNECT proxy to enforce. +/// Result of the lossless split: the MXC config carries filesystem grants and +/// loopback-only network access; the full network policy is returned unchanged +/// for the `OpenShell` CONNECT proxy to enforce. #[derive(Clone, Debug)] pub struct SplitPolicyResult { - /// MXC `ContainerConfig` with filesystem grants and `network.proxy` redirect. + /// MXC `ContainerConfig` with filesystem grants and loopback-only egress. /// - /// Direct egress is blocked at the MXC layer. Unsupported host-list fields - /// are omitted; all outbound connections flow through the proxy, which - /// enforces the full `OpenShell` network policy. + /// Direct Internet egress is denied at the MXC layer. Proxy-aware clients + /// use the host proxy, which enforces the full `OpenShell` network policy. pub mxc_config: Value, /// Full `OpenShell` network policy preserved verbatim for the host CONNECT /// proxy. Only `network_policies` is populated; the proxy does not enforce @@ -100,13 +101,12 @@ pub fn map_to_mxc(policy: &SandboxPolicy, opts: &MxcMappingOptions) -> MxcMappin /// Governed-egress split: map filesystem + containment to MXC, delegate network to the /// `OpenShell` CONNECT proxy. /// -/// The returned [`SplitPolicyResult::mxc_config`] sets `network.proxy` to -/// `opts.proxy_redirect` and omits unsupported host-list fields. Direct egress -/// is blocked at the MXC layer and all outbound connections flow through the -/// proxy. [`SplitPolicyResult::proxy_policy`] carries the original -/// `network_policies` verbatim; no binary-scope, port, protocol, or wildcard -/// loss items are generated for those rules. Network middleware is rejected -/// until the host proxy can receive the gateway middleware service registry. +/// The returned [`SplitPolicyResult::mxc_config`] allows only `127.0.0.1/32` +/// egress and denies direct Internet access at the MXC layer. The driver injects +/// `HTTP_PROXY`/`HTTPS_PROXY` for proxy-aware clients. +/// [`SplitPolicyResult::proxy_policy`] carries the original +/// `network_policies` verbatim. Network middleware remains rejected until the +/// host proxy can receive the gateway middleware service registry. /// /// Returns `None` if `opts.proxy_redirect` is not set. Use [`map_to_mxc`] /// for the standalone coarse path when no proxy is in the loop. @@ -153,11 +153,11 @@ fn build_split_mxc_config( "containment", "error", &format!( - "`network.proxy` is not supported on `{}`; governed egress requires processcontainer until MXC M1 lands.", + "MXC loopback-only proxy access is not supported on `{}`; governed egress requires processcontainer.", opts.containment ), "governed egress proxy redirect", - "The generated MXC config omits network.proxy for this backend.", + "The generated MXC config cannot enable loopback-only proxy access for this backend.", ); } if !policy.network_policies.is_empty() { @@ -187,34 +187,34 @@ fn build_split_mxc_config( ); } - // Direct egress is blocked; all outbound flows through the OpenShell proxy. - // Released wxc-exec rejects allowedHosts and blockedHosts as unsupported, - // even when empty, so the proxy path omits both fields. - // - // MXC 0.6.0-alpha schema accepts ONLY {"proxy": {"localhost": }}. - // {"host": ..., "port": ...} and every other shape is rejected — verified - // empirically against the real wxc-exec 0.6.0-alpha binary via --dry-run. + // Direct Internet egress is denied. Proxy-aware clients can reach only the + // OpenShell proxy (and other host loopback listeners) through 127.0.0.1. if proxy_supported && proxy_addr.ip() != std::net::IpAddr::from([127, 0, 0, 1]) { add_loss( items, - "network.proxy", + "proxy_redirect", "error", &format!( - "MXC schema 0.6.0-alpha can only express a localhost port \ - ({{\"localhost\": N}}); non-127.0.0.1 redirect address \ - {proxy_addr} is not representable." + "MXC governed egress requires the unpackaged OpenShell host \ + proxy to use 127.0.0.1; redirect address {proxy_addr} is not supported." ), "per-sandbox egress attribution", - "The redirect cannot be emitted; use a 127.0.0.1:PORT address.", + "The proxy environment cannot be emitted safely; use a 127.0.0.1:PORT address.", ); } - let mut network = json!({ "defaultPolicy": "block" }); - if proxy_supported && proxy_addr.ip() == std::net::IpAddr::from([127, 0, 0, 1]) { - network["proxy"] = json!({ "localhost": proxy_addr.port() }); - } + let network = json!({ + "egress": { + "default": "deny", + "allow": [{"to": [{"cidr": "127.0.0.1/32"}]}], + }, + "ingress": { + "default": "allow", + "hostLoopback": "allow", + }, + }); let mut config = json!({ - "version": opts.mxc_version, + "version": MXC_SCHEMA_VERSION, "containerId": opts.container_id, "containment": opts.containment, "lifecycle": { diff --git a/crates/openshell-driver-mxc/src/policy_map/mod.rs b/crates/openshell-driver-mxc/src/policy_map/mod.rs index 156cd8ea7a..670e31a617 100644 --- a/crates/openshell-driver-mxc/src/policy_map/mod.rs +++ b/crates/openshell-driver-mxc/src/policy_map/mod.rs @@ -18,8 +18,8 @@ //! and anything MXC cannot express (ports, protocol, L7 rules, binary scope) //! is recorded in the loss report. Use this when MXC enforces network on its //! own, with no `OpenShell` proxy in the loop. -//! - [`split_policy`] — the governed-egress split for the Windows MXC compute -//! driver: MXC handles filesystem + containment + a `network.proxy` redirect, +//! - [`split_policy`] — the *lossless* split for the Windows MXC compute +//! driver: MXC handles filesystem + containment + loopback-only egress, //! while the full `OpenShell` network policy is preserved in a trimmed policy //! enforced by the host CONNECT proxy. //! diff --git a/crates/openshell-driver-mxc/src/relay.rs b/crates/openshell-driver-mxc/src/relay.rs new file mode 100644 index 0000000000..da0a000188 --- /dev/null +++ b/crates/openshell-driver-mxc/src/relay.rs @@ -0,0 +1,686 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! WebSocket relay embedded in the gateway for MXC `ProcessContainer` sandboxes. +//! +//! When `egress_proxy = true` the `AppContainer` has outbound TCP via the +//! `OpenShell` host CONNECT proxy. The driver binds a relay listener on demand +//! (`start_relay`, e.g. from `ForwardSink::open_dynamic_forward`) and tells +//! the in-sandbox spawner its address over the stdin/stdout control channel; +//! the spawner connects outward to it as a WebSocket CLIENT (Phase A). Host +//! clients connect as raw TCP (Phase B); the relay tunnels their bytes +//! through Phase A so the in-sandbox agent can pipe them directly to the +//! target service. Each relay is per-request and short-lived — bound fresh +//! for each `openshell forward service` call, torn down when that forward +//! ends. +//! +//! ```text +//! host TCP client -> relay (gateway, raw TCP accept) +//! | tunnel via Phase A WS +//! sandbox agent -> local service (openclaw:18889) +//! ``` +//! +//! The listener is bound to the host's route-selected IPv4 interface rather +//! than loopback. `AppContainer` fallback does not map its `127.0.0.1` to the +//! host, so a loopback listener is unreachable unless traffic is sent through +//! the CONNECT proxy; that proxy can also capture the bridge's separate +//! sandbox-local target connection. Binding one concrete host interface keeps +//! the target hop on sandbox loopback and lets `allowLocalNetwork` authorize +//! only the host callback. In principle another host or local process could +//! race to connect before the real Phase A/B peer does and +//! hijack or inject traffic into the forward. Both phases are authenticated +//! against a fresh, unguessable per-forward nonce (`ForwardSink:: +//! open_dynamic_forward` generates it) instead of trusting connection order: +//! +//! Phase A (sandbox spawner, WS client) must send `TEXT "AUTH:"` as its first message, before anything else is +//! accepted from that connection -- see openshell-supervisor- +//! relay's `run_relay_bridge`, which sends this immediately +//! after connecting. +//! Phase B (host client, raw TCP -- normally the gateway process itself, +//! connecting right after `open_dynamic_forward` returns) must +//! write the raw nonce bytes as the first bytes on the +//! connection, before any tunneled application data -- see +//! openshell-server's `ForwardTcp` handler. +//! +//! A connection that fails or times out on this check is closed and the +//! relay keeps waiting for the real peer, rather than treating the first +//! comer as authoritative or tearing the whole relay down (a wrong guess +//! shouldn't be a viable way to deny service to the real caller either). +//! +//! Protocol over Phase A (WS connection from sandbox to relay), after auth: +//! TEXT "`SESSION_START`" — relay opened a Phase B TCP connection +//! BINARY — bytes from Phase B TCP stream +//! TEXT "`SESSION_END`" — Phase B TCP connection closed +//! WS Close — relay shutting down (`delete_sandbox` or error) +//! +//! Phase B (host client), after the nonce prefix, is a plain byte stream — +//! no WS handshake — so the client's full byte stream (including any WS +//! upgrade request and frames) is tunneled transparently to the in-sandbox +//! service from that point on. + +use crate::control_channel::ControlChannel; +use base64::Engine; +use futures::{SinkExt, StreamExt}; +use openshell_core::net::set_tcp_nodelay_best_effort; +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::Duration; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; +use tokio::sync::oneshot; +use tokio_tungstenite::{accept_async, tungstenite::Message}; +use tracing::{info, warn}; + +/// Length in bytes of the per-forward auth nonce (see module docs). Must +/// match `NONCE_LEN` in `driver.rs` (which generates it) and +/// `openshell-supervisor-relay`'s copy (which echoes it back on Phase A) -- +/// duplicated rather than shared via a common crate, matching how the rest +/// of this wire protocol (e.g. the "`SESSION_START`"/"`SESSION_END`" literals) +/// is already duplicated across the two sides. +pub const NONCE_LEN: usize = 32; + +/// How long to wait for a freshly-accepted connection to present its auth +/// nonce before giving up on it and going back to waiting for the real peer. +/// Generous: this is a local host round-trip, but a slow/malicious +/// connector shouldn't be able to stall the relay for the legitimate peer +/// for long either. +const AUTH_TIMEOUT: Duration = Duration::from_secs(5); + +/// Select the concrete host IPv4 address used to reach the machine's default +/// route. UDP `connect` performs route selection without sending a packet, so +/// this does not depend on the probe endpoint being reachable. Binding the +/// relay to that exact interface avoids exposing it on every interface while +/// still making it reachable from an `AppContainer` whose loopback is isolated +/// from the host's loopback. +/// Fixed-time byte comparison so a wrong guess doesn't leak how many +/// leading bytes it got right via response timing. The nonce is one-shot +/// (a fresh relay per forward) so this is defense in depth rather than +/// closing a practically exploitable channel, but it's free. +fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { + if a.len() != b.len() { + return false; + } + a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0 +} + +/// Hex-encode `bytes` (lowercase, unpadded). `pub` (crate-visible in +/// practice, since `relay` isn't a `pub mod`) so `ForwardSink:: +/// open_dynamic_forward` in `driver.rs` can use the exact same encoding to +/// build the "forward" control-channel request's `nonce` field that this +/// module expects back from the sandbox on Phase A. +pub fn encode_hex(bytes: &[u8]) -> String { + use std::fmt::Write; + bytes + .iter() + .fold(String::with_capacity(bytes.len() * 2), |mut s, b| { + let _ = write!(s, "{b:02x}"); + s + }) +} + +/// Render the first `n` bytes of `data` as a printable-ASCII preview +/// (non-printable bytes shown as `.`), for hop-by-hop diagnostic logging. +/// Not a general-purpose formatter -- just enough to eyeball whether e.g. an +/// HTTP/WS handshake looks intact versus corrupted or empty. +/// +/// Not called anywhere: forwarded traffic can carry auth headers, cookies, or +/// other sensitive payload, and this relay's own logs are gateway logs, so no +/// byte preview is ever logged, at any level. Kept only so a future opt-in +/// diagnostic mode has a ready-made (still-redaction-worthy) formatter to +/// start from. +#[allow(dead_code)] +fn byte_preview(data: &[u8]) -> String { + const MAX: usize = 120; + let n = data.len().min(MAX); + let mut s: String = data[..n] + .iter() + .map(|&b| { + if b.is_ascii_graphic() || b == b' ' { + b as char + } else { + '.' + } + }) + .collect(); + if data.len() > MAX { + s.push_str("..."); + } + s +} + +// ── Public handle ───────────────────────────────────────────────────────────── + +/// Owned handle returned by [`start_relay`]. Drop or call [`stop`] to +/// shut down the relay task and release the listener port. +pub struct RelayHandle { + shutdown_tx: oneshot::Sender<()>, +} + +impl RelayHandle { + pub fn stop(self) { + let _ = self.shutdown_tx.send(()); + } +} + +// ── Entry point ─────────────────────────────────────────────────────────────── + +/// Bind a TCP listener on `bind_addr` and spawn the relay task. The caller +/// communicates the returned address (and `nonce`) to the sandbox directly +/// (over the control channel) — this doesn't touch the filesystem at all. +/// `nonce` must be freshly generated per call (see module docs) — it's what +/// lets this host-interface relay tell the real Phase A/B peers apart from +/// any other local process that might race to connect first. +pub async fn start_relay( + bind_addr: SocketAddr, + sandbox_name: String, + nonce: [u8; NONCE_LEN], +) -> std::io::Result<(RelayHandle, SocketAddr)> { + let listener = TcpListener::bind(bind_addr).await?; + let actual = listener.local_addr()?; + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + tokio::spawn(relay_task( + listener, + sandbox_name.clone(), + nonce, + shutdown_rx, + )); + info!(sandbox = %sandbox_name, relay = %actual, "MXC relay started"); + Ok((RelayHandle { shutdown_tx }, actual)) +} + +/// Start a host-loopback listener whose sandbox leg is multiplexed over the +/// inherited stdin/stdout control channel. Unlike [`start_relay`], this path +/// never asks the `AppContainer` to connect back to a host network address. +pub async fn start_control_channel_relay( + bind_addr: SocketAddr, + sandbox_name: String, + nonce: [u8; NONCE_LEN], + control_channel: Arc, + target_port: u16, +) -> std::io::Result<(RelayHandle, SocketAddr)> { + let listener = TcpListener::bind(bind_addr).await?; + let actual = listener.local_addr()?; + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + tokio::spawn(control_channel_relay_task( + listener, + sandbox_name.clone(), + nonce, + control_channel, + target_port, + shutdown_rx, + )); + info!(sandbox = %sandbox_name, relay = %actual, "MXC control-channel relay started"); + Ok((RelayHandle { shutdown_tx }, actual)) +} + +async fn control_channel_relay_task( + listener: TcpListener, + sandbox_name: String, + nonce: [u8; NONCE_LEN], + control_channel: Arc, + target_port: u16, + mut shutdown_rx: oneshot::Receiver<()>, +) { + loop { + let mut host_stream = tokio::select! { + result = listener.accept() => match result { + Ok((stream, addr)) => { + info!(sandbox = %sandbox_name, %addr, "MXC control-channel relay: host client connected"); + set_tcp_nodelay_best_effort(&stream); + stream + } + Err(error) => { + warn!(sandbox = %sandbox_name, "MXC control-channel relay listener error: {error}"); + return; + } + }, + _ = &mut shutdown_rx => return, + }; + + let mut auth_buf = [0_u8; NONCE_LEN]; + match tokio::time::timeout(AUTH_TIMEOUT, host_stream.read_exact(&mut auth_buf)).await { + Ok(Ok(_)) if constant_time_eq(&auth_buf, &nonce) => {} + Ok(Ok(_) | Err(_)) | Err(_) => continue, + } + + let session_id = encode_hex(&rand::random::<[u8; NONCE_LEN]>()); + let open = control_channel + .request( + "forward_open", + serde_json::json!({"session_id": session_id, "target_port": target_port}), + Duration::from_secs(10), + ) + .await; + if !control_response_ok(&open) { + warn!(sandbox = %sandbox_name, "MXC control-channel relay: sandbox rejected session open"); + continue; + } + + let (mut host_read, mut host_write) = host_stream.into_split(); + let mut host_buf = vec![0_u8; 8192]; + let mut host_to_sandbox_bytes = 0_u64; + let mut sandbox_to_host_bytes = 0_u64; + let mut shutting_down = false; + loop { + tokio::select! { + result = host_read.read(&mut host_buf) => match result { + Ok(0) | Err(_) => break, + Ok(n) => { + let bytes = base64::engine::general_purpose::STANDARD.encode(&host_buf[..n]); + let response = control_channel.request( + "forward_write", + serde_json::json!({"session_id": session_id, "bytes": bytes}), + Duration::from_secs(10), + ).await; + if !control_response_ok(&response) { + break; + } + host_to_sandbox_bytes += n as u64; + } + }, + response = control_channel.request( + "forward_read", + serde_json::json!({"session_id": session_id}), + Duration::from_secs(10), + ) => { + let Ok(response) = response else { break }; + if response.get("ok").and_then(serde_json::Value::as_bool) != Some(true) { + break; + } + let data = response.get("data").unwrap_or(&serde_json::Value::Null); + if data.get("eof").and_then(serde_json::Value::as_bool) == Some(true) { + break; + } + let Some(encoded) = data.get("bytes").and_then(serde_json::Value::as_str) else { + break; + }; + if !encoded.is_empty() { + let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(encoded) else { + break; + }; + if host_write.write_all(&bytes).await.is_err() { + break; + } + sandbox_to_host_bytes += bytes.len() as u64; + } + }, + _ = &mut shutdown_rx => { + shutting_down = true; + break; + }, + } + } + let _ = control_channel + .request( + "forward_close", + serde_json::json!({"session_id": session_id}), + Duration::from_secs(5), + ) + .await; + if shutting_down { + return; + } + info!( + sandbox = %sandbox_name, + host_to_sandbox_bytes, + sandbox_to_host_bytes, + "MXC control-channel relay: host client disconnected" + ); + } +} + +fn control_response_ok( + response: &Result, +) -> bool { + matches!( + response, + Ok(value) if value.get("ok").and_then(serde_json::Value::as_bool) == Some(true) + ) +} + +async fn relay_task( + listener: TcpListener, + sandbox_name: String, + nonce: [u8; NONCE_LEN], + mut shutdown_rx: oneshot::Receiver<()>, +) { + let expected_auth = format!("AUTH:{}", encode_hex(&nonce)); + + // Phase A: wait for the sandbox agent's outbound WS connection, and + // require it to prove it's the real peer (see module docs) before + // trusting anything else from it. A connection that fails or times out + // on this is closed; the relay keeps waiting rather than accepting the + // first comer or giving up entirely. + let mut sandbox_ws = loop { + tokio::select! { + result = listener.accept() => match result { + Ok((stream, addr)) => { + // Latency-sensitive request/response tunnel, including on + // a same-host interface -- small WS frames can otherwise + // stall behind delayed ACK behavior. Best-effort, before + // the WS upgrade so it applies to the whole connection. + set_tcp_nodelay_best_effort(&stream); + match tokio::time::timeout(AUTH_TIMEOUT, accept_async(stream)).await { + Ok(Ok(mut ws)) => { + match tokio::time::timeout(AUTH_TIMEOUT, ws.next()).await { + Ok(Some(Ok(Message::Text(t)))) + if constant_time_eq(t.as_bytes(), expected_auth.as_bytes()) => + { + info!(sandbox = %sandbox_name, %addr, "MXC relay: sandbox connected (authenticated)"); + break ws; + } + Ok(Some(Ok(_))) => { + warn!(sandbox = %sandbox_name, %addr, + "MXC relay: sandbox WS auth message did not match; closing and continuing to wait"); + let _ = ws.close(None).await; + } + Ok(_) => { + warn!(sandbox = %sandbox_name, %addr, + "MXC relay: sandbox WS closed/errored before authenticating; continuing to wait"); + } + Err(_) => { + warn!(sandbox = %sandbox_name, %addr, + "MXC relay: sandbox WS auth timed out; closing and continuing to wait"); + let _ = ws.close(None).await; + } + } + } + Ok(Err(e)) => warn!(sandbox = %sandbox_name, %addr, + "MXC relay: sandbox WS handshake failed: {e}"), + Err(_) => warn!(sandbox = %sandbox_name, %addr, + "MXC relay: sandbox WS handshake timed out; continuing to wait"), + } + } + Err(e) => { + warn!(sandbox = %sandbox_name, "MXC relay: listener error: {e}"); + return; + } + }, + _ = &mut shutdown_rx => { + info!(sandbox = %sandbox_name, "MXC relay: shutdown before sandbox connected"); + return; + } + } + }; + + // Phase B: accept raw TCP host clients one at a time and tunnel their + // byte stream through the Phase A WS connection. + loop { + let mut host_stream = tokio::select! { + result = listener.accept() => match result { + Ok((stream, addr)) => { + info!(sandbox = %sandbox_name, %addr, "MXC relay: host client connected"); + // See the matching comment on the Phase A accept above. + set_tcp_nodelay_best_effort(&stream); + stream + } + Err(e) => { + warn!(sandbox = %sandbox_name, "MXC relay: listener error: {e}"); + return; + } + }, + _ = &mut shutdown_rx => { + info!(sandbox = %sandbox_name, "MXC relay: shutdown"); + // Send a proper WS Close frame instead of just dropping the + // connection -- otherwise the sandbox sees an abrupt TCP + // reset ("Connection reset without closing handshake") + // instead of a clean close, even though nothing actually + // went wrong. + let _ = sandbox_ws.close(None).await; + return; + } + }; + + // Authenticate before treating this as the real host client (see + // module docs): the raw nonce bytes must arrive first, ahead of any + // tunneled application data. A mismatch or timeout closes this + // connection and goes back to waiting for the next accept -- it + // must never fall through to tunneling a stranger's traffic. + let mut auth_buf = [0u8; NONCE_LEN]; + match tokio::time::timeout(AUTH_TIMEOUT, host_stream.read_exact(&mut auth_buf)).await { + Ok(Ok(_)) if constant_time_eq(&auth_buf, &nonce) => {} + Ok(Ok(_)) => { + warn!(sandbox = %sandbox_name, + "MXC relay: host client auth bytes did not match; closing and continuing to wait"); + continue; + } + Ok(Err(e)) => { + warn!(sandbox = %sandbox_name, + "MXC relay: host client closed/errored before authenticating: {e}"); + continue; + } + Err(_) => { + warn!(sandbox = %sandbox_name, + "MXC relay: host client auth timed out; closing and continuing to wait"); + continue; + } + } + + let (mut host_read, mut host_write) = host_stream.into_split(); + + // Notify in-sandbox agent that a new session is starting. + if sandbox_ws + .send(Message::Text("SESSION_START".into())) + .await + .is_err() + { + info!(sandbox = %sandbox_name, "MXC relay: sandbox gone at session start"); + return; + } + + // Bridge until Phase B or Phase A closes. Byte counters + first-chunk + // size events exist purely for diagnosing WHERE in the hop chain + // (host <-> this relay <-> Phase A WS <-> sandbox <-> target) bytes + // stop flowing, since a silent drop anywhere looks identical from the + // outside (client just times out) without this instrumentation. No + // payload content is ever logged -- see the module-level note on + // `byte_preview`. + let mut buf = vec![0u8; 8192]; + let mut host_to_sandbox_bytes: u64 = 0; + let mut sandbox_to_host_bytes: u64 = 0; + let mut host_to_sandbox_chunks: u64 = 0; + let mut sandbox_to_host_chunks: u64 = 0; + let sandbox_gone = loop { + tokio::select! { + // Phase B → Phase A: TCP bytes wrapped as WS Binary. + result = host_read.read(&mut buf) => match result { + Ok(0) => break false, // Phase B EOF + Ok(n) => { + host_to_sandbox_chunks += 1; + host_to_sandbox_bytes += n as u64; + if host_to_sandbox_chunks == 1 { + info!(sandbox = %sandbox_name, bytes = n, "MXC relay: first host->sandbox chunk"); + } + if sandbox_ws + .send(Message::Binary(buf[..n].to_vec().into())) + .await + .is_err() + { + break true; // Phase A gone + } + } + Err(e) => { + warn!(sandbox = %sandbox_name, "MXC relay: host read error: {e}"); + break false; + } + }, + // Phase A → Phase B: WS Binary bytes written to TCP. + msg = sandbox_ws.next() => match msg { + Some(Ok(Message::Binary(b))) => { + sandbox_to_host_chunks += 1; + sandbox_to_host_bytes += b.len() as u64; + if sandbox_to_host_chunks == 1 { + info!(sandbox = %sandbox_name, bytes = b.len(), "MXC relay: first sandbox->host chunk"); + } + if host_write.write_all(&b).await.is_err() { + break false; // Phase B gone + } + } + Some(Ok(Message::Text(t))) => { + if let Some(reason) = t.strip_prefix("SESSION_FAILED:") { + // Sandbox couldn't reach the target port. Close + // Phase B now instead of leaving the host client + // hanging until its own timeout. + warn!(sandbox = %sandbox_name, reason, + "MXC relay: sandbox failed to connect to target"); + break false; + } else if t == "SESSION_END" { + // Target closed its end (EOF) -- see + // openshell-supervisor-relay's matching send on + // Ok(0). Close Phase B now instead of leaving + // the host client waiting for bytes that will + // never arrive until its own timeout. + info!(sandbox = %sandbox_name, "MXC relay: target closed its end of the session"); + break false; + } + // otherwise ignore (shouldn't arrive from sandbox) + } + Some(Ok(Message::Close(_))) | None => { + info!(sandbox = %sandbox_name, "MXC relay: sandbox WS closed"); + break true; + } + Some(Ok(_)) => {} // ping/pong handled by tungstenite + Some(Err(e)) => { + warn!(sandbox = %sandbox_name, "MXC relay: sandbox read error: {e}"); + break true; + } + }, + _ = &mut shutdown_rx => { + let _ = sandbox_ws.close(None).await; + return; + } + } + }; + + // Signal session end to the in-sandbox agent (if Phase A still alive). + if !sandbox_gone { + let _ = sandbox_ws.send(Message::Text("SESSION_END".into())).await; + } + + info!(sandbox = %sandbox_name, + host_to_sandbox_bytes, host_to_sandbox_chunks, + sandbox_to_host_bytes, sandbox_to_host_chunks, + "MXC relay: host client disconnected"); + if sandbox_gone { + info!(sandbox = %sandbox_name, "MXC relay: sandbox gone, stopping relay"); + return; + } + // Phase A still alive — wait for the next Phase B client. + } +} + +#[cfg(test)] +mod control_relay_cleanup_tests { + use super::*; + use std::process::Stdio; + use tokio::io::{AsyncBufReadExt, BufReader}; + use tokio::net::TcpStream; + + /// Exercise the real control-channel writer and relay with a child that + /// echoes request lines. The test supplies the sandbox's matching replies. + async fn check_forward_cleanup(interrupt_active: bool) { + let mut child = tokio::process::Command::new("powershell.exe") + .args([ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + "while ($null -ne ($line = [Console]::ReadLine())) { [Console]::WriteLine($line) }", + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .kill_on_drop(true) + .spawn() + .unwrap(); + let control = Arc::new(ControlChannel::new(child.stdin.take().unwrap())); + let pending = control.pending_handle(); + let mut lines = BufReader::new(child.stdout.take().unwrap()).lines(); + let (observed_tx, mut observed_rx) = tokio::sync::mpsc::unbounded_channel(); + let responder = tokio::spawn(async move { + while let Some(line) = lines.next_line().await.unwrap() { + let request: serde_json::Value = serde_json::from_str(&line).unwrap(); + let op = request["op"].as_str().unwrap().to_string(); + let response = serde_json::json!({ + "id": request["id"], "ok": true, + "data": {"bytes": "", "eof": false}, + }); + ControlChannel::try_route_response(&pending, &response.to_string()).await; + observed_tx + .send((op.clone(), request["data"]["session_id"].clone())) + .unwrap(); + if op == "forward_close" { + break; + } + tokio::time::sleep(Duration::from_millis(5)).await; + } + }); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + let nonce = [7; NONCE_LEN]; + let relay = tokio::spawn(control_channel_relay_task( + listener, + "cleanup-test".into(), + nonce, + control, + 12345, + shutdown_rx, + )); + let mut client = TcpStream::connect(address).await.unwrap(); + client.write_all(&nonce).await.unwrap(); + let opened = tokio::time::timeout(Duration::from_secs(20), observed_rx.recv()) + .await + .unwrap() + .unwrap(); + assert_eq!(opened.0, "forward_open"); + let mut shutdown_tx = Some(shutdown_tx); + if interrupt_active { + shutdown_tx.take().unwrap().send(()).unwrap(); + } else { + client.shutdown().await.unwrap(); + } + let closed_id = tokio::time::timeout(Duration::from_secs(10), async { + while let Some((op, id)) = observed_rx.recv().await { + if op == "forward_close" { + return id; + } + } + panic!("control channel ended without closing the forward"); + }) + .await + .expect("active forward must close on shutdown or EOF"); + assert_eq!(closed_id, opened.1); + if let Some(shutdown_tx) = shutdown_tx { + shutdown_tx.send(()).unwrap(); + } + tokio::time::timeout(Duration::from_secs(10), relay) + .await + .unwrap() + .unwrap(); + let mut byte = [0]; + assert_eq!( + tokio::time::timeout(Duration::from_secs(2), client.read(&mut byte)) + .await + .unwrap() + .unwrap(), + 0 + ); + responder.await.unwrap(); + child.kill().await.unwrap(); + child.wait().await.unwrap(); + } + + #[tokio::test] + async fn active_forward_is_closed_before_relay_shutdown_returns() { + check_forward_cleanup(true).await; + } + + #[tokio::test] + async fn ordinary_client_eof_still_closes_the_forward() { + check_forward_cleanup(false).await; + } +} + +// ── Relay task ──────────────────────────────────────────────────────────────── diff --git a/crates/openshell-driver-mxc/tests/openclaw_appcontainer_compat.rs b/crates/openshell-driver-mxc/tests/openclaw_appcontainer_compat.rs new file mode 100644 index 0000000000..3a3a16af59 --- /dev/null +++ b/crates/openshell-driver-mxc/tests/openclaw_appcontainer_compat.rs @@ -0,0 +1,58 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +const CAPTURE: &str = include_str!("../examples/openclaw-capture.mjs"); +const RUNNER: &str = include_str!("../examples/run-openclaw-forward-test.ps1"); + +#[cfg(windows)] +#[test] +fn runner_restores_openclaw_environment_after_success_and_failure() { + let directory = tempfile::tempdir().unwrap(); + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + let output = std::process::Command::new("powershell.exe") + .args(["-NoLogo", "-NoProfile", "-NonInteractive", "-File"]) + .arg(root.join("tests/openclaw_environment_cleanup.ps1")) + .arg("-RunnerPath") + .arg(root.join("examples/run-openclaw-forward-test.ps1")) + .arg("-TestDirectory") + .arg(directory.path()) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert!( + String::from_utf8_lossy(&output.stdout).contains("PASS: 4 environment restoration cases") + ); +} + +#[test] +fn capture_preloads_appcontainer_safe_realpath_before_openclaw() { + let patch = CAPTURE + .find("fs.promises.realpath = promisify(fs.realpath)") + .expect("capture must install the callback realpath compatibility binding"); + let import = CAPTURE + .find("await import(pathToFileURL(entry).href)") + .expect("capture must import the OpenClaw entry point"); + + assert!( + patch < import, + "realpath compatibility must be installed before OpenClaw loads" + ); + assert!(CAPTURE.contains("syncBuiltinESMExports()")); +} + +#[test] +fn runner_limits_package_group_dacl_grants_to_writable_data_directories() { + assert!(RUNNER.contains("*S-1-15-2-1:(OI)(CI)(M)")); + assert!(RUNNER.contains("*S-1-15-2-2:(OI)(CI)(M)")); + assert!( + RUNNER.contains("Grant-AppContainerWritableDirectory (Join-Path $shareDirNorm \"home\")") + ); + assert!( + RUNNER.contains("Grant-AppContainerWritableDirectory (Join-Path $shareDirNorm \"temp\")") + ); + assert!(!RUNNER.contains("Grant-AppContainerWritableDirectory $shareDirNorm")); +} diff --git a/crates/openshell-driver-mxc/tests/openclaw_environment_cleanup.ps1 b/crates/openshell-driver-mxc/tests/openclaw_environment_cleanup.ps1 new file mode 100644 index 0000000000..987ea5aed7 --- /dev/null +++ b/crates/openshell-driver-mxc/tests/openclaw_environment_cleanup.ps1 @@ -0,0 +1,68 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +param([string]$RunnerPath, [string]$TestDirectory) +$ErrorActionPreference = 'Stop' + +# Execute the actual runner's override scope, not a copy of its cleanup logic. +$tokens = $null +$parseErrors = $null +$source = [System.IO.File]::ReadAllText($RunnerPath) +$ast = [System.Management.Automation.Language.Parser]::ParseInput($source, [ref]$tokens, [ref]$parseErrors) +if ($parseErrors.Count) { throw 'Runner must parse without errors' } +$scope = $ast.Find({ param($node) + $node -is [System.Management.Automation.Language.TryStatementAst] -and + $node.Finally -and $node.Finally.Extent.Text.Contains('$savedOpenClawConfigPath') +}, $true) +if (!$scope) { throw 'OpenClaw environment restoration needs a finally scope' } +$save = $ast.Find({ param($node) + $node -is [System.Management.Automation.Language.AssignmentStatementAst] -and + $node.Left.Extent.Text -eq '$savedOpenClawConfigPath' +}, $true) +$exercise = [scriptblock]::Create($source.Substring($save.Extent.StartOffset, $scope.Extent.EndOffset - $save.Extent.StartOffset)) + +function Step { param($Message) } +function Info { param($Message) } +function Ok { param($Message) } +function Bad { param($Message) throw $Message } +$ShareDir = $TestDirectory +$resultDir = $TestDirectory +$expectedState = Join-Path $ShareDir 'home\.openclaw' +$expectedConfig = Join-Path $expectedState 'openclaw.json' +$healthArgs = @() +$proof = '[egress-proof] {"proxyConfigured":true,"allowedViaProxy":{"connected":true},"deniedViaProxy":{"connected":false},"directInternetBypass":{"connected":false},"unrelatedHostLoopback":{"connected":true}}' +[System.IO.File]::WriteAllText((Join-Path $ShareDir 'openclaw-capture.log'), $proof) +$originalConfig = $env:OPENCLAW_CONFIG_PATH +$originalState = $env:OPENCLAW_STATE_DIR +$cases = 0 +try { + foreach ($initial in @($null, 'original-value')) { + foreach ($injectFailure in @($false, $true)) { + $env:OPENCLAW_CONFIG_PATH = $initial + $env:OPENCLAW_STATE_DIR = $initial + $passed = $false + $NodeExePath = { + if ($env:OPENCLAW_CONFIG_PATH -ne $expectedConfig -or $env:OPENCLAW_STATE_DIR -ne $expectedState) { + throw 'Client did not receive isolated OpenClaw paths' + } + if ($injectFailure) { throw 'injected-client-failure' } + '{"ok":true}' + } + $caught = $false + try { . $exercise } catch { + if (!$injectFailure -or $_.Exception.Message -ne 'injected-client-failure') { throw } + $caught = $true + } + if ($caught -ne $injectFailure) { throw 'Unexpected execution outcome' } + if (!$injectFailure -and !$passed) { throw 'Successful health path did not pass' } + if ($env:OPENCLAW_CONFIG_PATH -cne $initial -or $env:OPENCLAW_STATE_DIR -cne $initial) { + throw 'Environment was not restored after the client scope' + } + $cases++ + } + } +} finally { + $env:OPENCLAW_CONFIG_PATH = $originalConfig + $env:OPENCLAW_STATE_DIR = $originalState +} +Write-Output "PASS: $cases environment restoration cases" diff --git a/crates/openshell-driver-mxc/tests/policy_mapper_examples.rs b/crates/openshell-driver-mxc/tests/policy_mapper_examples.rs index d8d6a37904..c7382afece 100644 --- a/crates/openshell-driver-mxc/tests/policy_mapper_examples.rs +++ b/crates/openshell-driver-mxc/tests/policy_mapper_examples.rs @@ -210,12 +210,15 @@ fn all_example_policies_split_with_expected_invariants() { assert!(str_list(&cfg["filesystem"]["readonlyPaths"]).is_empty()); } - assert_eq!(cfg["network"]["defaultPolicy"], "block"); - assert!(str_list(&cfg["network"]["allowedHosts"]).is_empty()); - // MXC 0.6.0-alpha accepts only {"proxy": {"localhost": N}}. - assert_eq!(cfg["network"]["proxy"]["localhost"], 18080); - assert!(cfg["network"]["proxy"].get("host").is_none()); - assert!(cfg["network"]["proxy"].get("port").is_none()); + assert_eq!(cfg["version"], "0.8.0-alpha"); + assert_eq!(cfg["network"]["egress"]["default"], "deny"); + assert_eq!( + cfg["network"]["egress"]["allow"][0]["to"][0]["cidr"], + "127.0.0.1/32" + ); + assert_eq!(cfg["network"]["ingress"]["hostLoopback"], "allow"); + assert!(cfg.get("runtimeConfig").is_none()); + assert!(cfg["network"].get("proxy").is_none()); let errors: Vec<_> = result .loss .iter() @@ -295,29 +298,24 @@ fn split_policy_routes_network_to_proxy() { let result = split_policy(&policy, &opts).expect("split_policy returns Some when addr is set"); let cfg = &result.mxc_config; - // Proxy redirect is emitted. 127.0.0.2 is not the loopback 127.0.0.1 so - // the mapper records an error loss and omits the proxy block entirely. - // (MXC 0.6.0-alpha can only encode {"localhost": N}; non-127.0.0.1 is - // not representable.) + // 127.0.0.2 is not the supported 127.0.0.1 host-proxy address, so the + // mapper records an error loss and keeps the MXC config fail-closed. assert!( - cfg["network"].get("proxy").is_none() || cfg["network"]["proxy"].is_null(), - "non-127.0.0.1 redirect must NOT produce a proxy block: {:?}", - cfg["network"].get("proxy") + cfg.get("runtimeConfig").is_none() || cfg["runtimeConfig"].is_null(), + "non-127.0.0.1 redirect must NOT produce runtimeConfig: {:?}", + cfg.get("runtimeConfig") ); let has_proxy_loss = result .loss .iter() - .any(|i| i.path == "network.proxy" && i.severity == "error"); + .any(|i| i.path == "proxy_redirect" && i.severity == "error"); assert!( has_proxy_loss, "non-127.0.0.1 redirect must produce an error loss item" ); - // Direct egress is blocked; unsupported host-list fields are omitted and - // the proxy enforces the full list. - assert_eq!(cfg["network"]["defaultPolicy"], "block"); - assert!(cfg["network"].get("allowedHosts").is_none()); - assert!(cfg["network"].get("blockedHosts").is_none()); + // Direct egress is denied; the host proxy enforces the list. + assert_eq!(cfg["network"]["egress"]["default"], "deny"); // Filesystem grants are preserved unchanged. assert_eq!( @@ -401,7 +399,7 @@ fn split_policy_rejects_proxy_redirect_on_isolation_session() { "expected one containment error: {errors:?}" ); assert_eq!(errors[0].path, "containment"); - assert!(errors[0].message.contains("MXC M1")); + assert!(errors[0].message.contains("processcontainer")); assert!(result.mxc_config["network"].get("proxy").is_none()); } @@ -425,9 +423,7 @@ fn network_only_policy_has_empty_filesystem() { // ── New tests: proxy JSON shape and non-127.0.0.1 guard ────────────────────── #[test] -fn split_with_loopback_addr_emits_localhost_port_shape() { - // MXC 0.6.0-alpha accepts ONLY {"proxy": {"localhost": N}}. - // Verified against the real wxc-exec 0.6.0-alpha binary via --dry-run. +fn split_with_loopback_addr_emits_loopback_only_08_shape() { let path = examples_root().join("sandbox-policy-quickstart/policy.yaml"); let yaml = std::fs::read_to_string(&path).expect("read quickstart"); let policy = parse_sandbox_policy(&yaml).expect("parse quickstart"); @@ -440,18 +436,15 @@ fn split_with_loopback_addr_emits_localhost_port_shape() { let result = split_policy(&policy, &opts).expect("split returns Some"); let cfg = &result.mxc_config; + assert_eq!(cfg["version"], "0.8.0-alpha"); + assert_eq!(cfg["network"]["egress"]["default"], "deny"); assert_eq!( - cfg["network"]["proxy"]["localhost"], 18080, - "proxy must use {{\"localhost\": N}} shape" - ); - assert!( - cfg["network"]["proxy"].get("host").is_none(), - "proxy must not contain 'host' key" - ); - assert!( - cfg["network"]["proxy"].get("port").is_none(), - "proxy must not contain 'port' key" + cfg["network"]["egress"]["allow"][0]["to"][0]["cidr"], + "127.0.0.1/32" ); + assert_eq!(cfg["network"]["ingress"]["hostLoopback"], "allow"); + assert!(cfg.get("runtimeConfig").is_none()); + assert!(cfg["network"].get("proxy").is_none()); // No error losses — 127.0.0.1 is representable. assert!( result.loss.iter().all(|i| i.severity != "error"), @@ -465,9 +458,7 @@ fn split_with_loopback_addr_emits_localhost_port_shape() { } #[test] -fn split_with_non_loopback_addr_emits_error_loss_and_no_proxy_block() { - // Non-127.0.0.1 redirect addresses are not representable in MXC 0.6.0-alpha. - // The mapper must record an error loss and omit the proxy block. +fn split_with_non_loopback_addr_emits_error_loss_and_no_runtime_proxy() { let path = examples_root().join("sandbox-policy-quickstart/policy.yaml"); let yaml = std::fs::read_to_string(&path).expect("read quickstart"); let policy = parse_sandbox_policy(&yaml).expect("parse quickstart"); @@ -480,28 +471,29 @@ fn split_with_non_loopback_addr_emits_error_loss_and_no_proxy_block() { let result = split_policy(&policy, &opts).expect("split returns Some"); let cfg = &result.mxc_config; - // Proxy block must be absent. + // A runtime proxy block is never emitted; proxy-aware clients receive + // environment variables from the driver after this mapping step. assert!( - cfg["network"].get("proxy").is_none() || cfg["network"]["proxy"].is_null(), - "non-127.0.0.1 redirect must not produce a proxy block: {:?}", - cfg["network"].get("proxy") + cfg.get("runtimeConfig").is_none() || cfg["runtimeConfig"].is_null(), + "non-127.0.0.1 redirect must not produce runtimeConfig: {:?}", + cfg.get("runtimeConfig") ); - // An error loss for "network.proxy" must be present. + // An error loss for the unusable redirect must be present. let proxy_loss = result .loss .iter() - .find(|i| i.path == "network.proxy" && i.severity == "error"); + .find(|i| i.path == "proxy_redirect" && i.severity == "error"); assert!( proxy_loss.is_some(), - "non-127.0.0.1 redirect must produce an error loss item on network.proxy: {:?}", + "non-127.0.0.1 redirect must produce a proxy_redirect error loss item: {:?}", result.loss ); let loss = proxy_loss.unwrap(); assert_eq!(loss.openshell_feature, "per-sandbox egress attribution"); assert!( - loss.message.contains("localhost"), - "loss message should mention 'localhost': {}", + loss.message.contains("127.0.0.1"), + "loss message should mention '127.0.0.1': {}", loss.message ); } diff --git a/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs b/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs index 82bfe96428..3d2b8647f8 100644 --- a/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs +++ b/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs @@ -390,18 +390,18 @@ fn a_split_network_verbatim_and_version_preserved() { ); } -/// split path: mxc_config["network"]["proxy"]["localhost"] == port (new schema). +/// Split path emits MXC 0.8 loopback-only governed-egress fields. #[test] -fn a_split_proxy_localhost_port() { +fn a_split_proxy_uses_loopback_only_08_fields() { let policy = SandboxPolicy::default(); let result = split_policy(&policy, &pc_split_opts()).expect("split must return Some"); + assert!(result.mxc_config.get("runtimeConfig").is_none()); + assert_eq!(result.mxc_config["version"], "0.8.0-alpha"); + assert_eq!(result.mxc_config["network"]["egress"]["default"], "deny"); assert_eq!( - result.mxc_config["network"]["proxy"]["localhost"], 18080, - "split must emit network.proxy.localhost == port" + result.mxc_config["network"]["egress"]["allow"][0]["to"][0]["cidr"], + "127.0.0.1/32" ); - // Released wxc-exec rejects host-list fields, even when empty. - assert!(result.mxc_config["network"].get("allowedHosts").is_none()); - assert!(result.mxc_config["network"].get("blockedHosts").is_none()); } // ─── QUADRANT B: OpenShell features MXC cannot express ────────────────────── @@ -1030,13 +1030,16 @@ fn c_split_empty_allowed_hosts_with_network_rules() { }, ); let result = split_policy(&policy, &pc_split_opts()).expect("split must return Some"); - assert!(result.mxc_config["network"].get("allowedHosts").is_none()); - assert!(result.mxc_config["network"].get("blockedHosts").is_none()); - // But proxy redirect is present. assert_eq!( - result.mxc_config["network"]["proxy"]["localhost"], 18080, - "split must emit network.proxy.localhost" + result.mxc_config["network"]["egress"]["default"], "deny", + "split path must deny direct egress even with network rules" ); + // The only MXC-level egress allowance is host loopback. + assert_eq!( + result.mxc_config["network"]["egress"]["allow"][0]["to"][0]["cidr"], + "127.0.0.1/32" + ); + assert!(result.mxc_config.get("runtimeConfig").is_none()); } #[test] diff --git a/crates/openshell-driver-mxc/tests/wxc_exec_real.rs b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs index 396673768d..d748f35fcd 100644 --- a/crates/openshell-driver-mxc/tests/wxc_exec_real.rs +++ b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs @@ -68,7 +68,7 @@ fn wxc_path() -> Option { /// Create a real, user-owned Windows directory for MXC filesystem grants. /// /// MXC config values are literal paths: it does not expand `%TEMP%`. A unique -/// directory also keeps AppContainer+DACL fallback mutations scoped to test +/// directory also keeps `AppContainer`+DACL fallback mutations scoped to test /// data the current user owns. fn temp_fixture() -> (tempfile::TempDir, String) { let dir = tempfile::tempdir().expect("create MXC temp fixture"); @@ -404,7 +404,8 @@ fn dryrun_rejects_unknown_containment() { } /// The most important dry-run test: build a typed Windows policy, run -/// `split_policy` (`proxy_redirect` 127.0.0.1:18080, containment +/// `split_policy` (MXC 0.8 loopback-only proxy access at 127.0.0.1:18080, +/// containment /// "processcontainer"), and verify the resulting config with `--dry-run`. /// /// This proves that the mapper's emitted JSON is accepted by the real binary — @@ -453,6 +454,13 @@ fn dryrun_accepts_split_policy_output() { } let mxc_config = result.mxc_config; + assert_eq!(mxc_config["version"], "0.8.0-alpha"); + assert_eq!(mxc_config["network"]["egress"]["default"], "deny"); + assert_eq!( + mxc_config["network"]["egress"]["allow"][0]["to"][0]["cidr"], + "127.0.0.1/32" + ); + assert!(mxc_config.get("runtimeConfig").is_none()); let (code, stdout, stderr) = dry_run(&wxc, &mxc_config); assert_eq!( diff --git a/crates/openshell-gateway/src/lib.rs b/crates/openshell-gateway/src/lib.rs index eec328c537..30e1c83727 100644 --- a/crates/openshell-gateway/src/lib.rs +++ b/crates/openshell-gateway/src/lib.rs @@ -159,6 +159,7 @@ impl openshell_server::ComputeDriverFactory for MxcFactory { ) -> openshell_core::Result { let config: openshell_driver_mxc::MxcComputeConfig = context.driver_config()?; let backend = openshell_driver_mxc::MxcComputeBackend::new(config); + context.set_forward_sink(std::sync::Arc::new(MxcForwardSink(backend.forward_sink()))); let provider_credentials_sink = backend.provider_credentials_sink(); let driver = openshell_driver_mxc::ComputeDriverService::new(backend); Ok( @@ -170,6 +171,30 @@ impl openshell_server::ComputeDriverFactory for MxcFactory { } } +/// Adapts MXC's dynamic port-forward side channel (`handle_forward_tcp`'s +/// fallback for sandboxes with no in-sandbox supervisor) to the generic +/// `ComputeDriverForwardSink` capability the server crate consumes, so +/// `openshell-server` never has to depend on `openshell-driver-mxc` directly. +#[cfg(all(target_os = "windows", feature = "compute-driver-mxc"))] +struct MxcForwardSink(openshell_driver_mxc::ForwardSink); + +#[cfg(all(target_os = "windows", feature = "compute-driver-mxc"))] +#[async_trait::async_trait] +impl openshell_server::ComputeDriverForwardSink for MxcForwardSink { + async fn open_dynamic_forward( + &self, + sandbox_id: &str, + target_port: u16, + ) -> Result<(std::net::SocketAddr, Vec, Box), String> { + let (addr, nonce, handle) = self + .0 + .open_dynamic_forward(sandbox_id, target_port) + .await + .map_err(|error| error.to_string())?; + Ok((addr, nonce.to_vec(), Box::new(handle))) + } +} + #[cfg(all( not(target_os = "windows"), any( diff --git a/crates/openshell-sdk/src/client.rs b/crates/openshell-sdk/src/client.rs index 47f30dfe67..5dd9aaca48 100644 --- a/crates/openshell-sdk/src/client.rs +++ b/crates/openshell-sdk/src/client.rs @@ -357,6 +357,7 @@ impl OpenShellClient { allow_missing: opts.allow_missing, name: name.to_string(), workspace_scope: Some(proto::workspace_selector("default")), + ..Default::default() }; async move { grpc.delete_sandbox(request).await } }) @@ -947,6 +948,7 @@ impl WorkspaceScopedClient { allow_missing: opts.allow_missing, name: name.to_string(), workspace_scope: Some(proto::workspace_selector(&self.workspace)), + ..Default::default() }; async move { grpc.delete_sandbox(request).await } }) diff --git a/crates/openshell-server/src/compute/mod.rs b/crates/openshell-server/src/compute/mod.rs index 10323edc82..f140189894 100644 --- a/crates/openshell-server/src/compute/mod.rs +++ b/crates/openshell-server/src/compute/mod.rs @@ -328,6 +328,16 @@ struct SandboxDeleteTarget { sandbox_name: String, } +/// Optional caller-owned preconditions for an identity-safe sandbox delete. +/// +/// These values are validated again while holding the sandbox lifecycle and +/// gateway-global locks, immediately before the durable `Deleting` mutation. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct SandboxDeletePreconditions { + pub expected_sandbox_id: Option, + pub expected_resource_version: Option, +} + /// Identity and driver result for a completed delete request. #[derive(Debug, Eq, PartialEq)] pub struct DeleteSandboxResult { @@ -688,6 +698,9 @@ pub struct ComputeRuntime { lifecycle_gates: Arc, gateway_listener_requirements: Vec, replica_id: String, + /// Dynamic TCP forward capability contributed by the active in-process + /// driver, if it has one. See `forward_sink`. + forward_sink: Option>, /// Gateway-issued staging slots for rootfs tar archives. Shared across /// clones: `ServerState` holds `ComputeRuntime` by value, so a per-clone /// table would make a token minted on one clone invisible to another. @@ -823,6 +836,7 @@ impl ComputeRuntime { lifecycle_gates: Arc::new(LifecycleGateRegistry::default()), gateway_listener_requirements, replica_id: lease::replica_id(), + forward_sink: None, rootfs_tar_staging, }) } @@ -875,6 +889,22 @@ impl ComputeRuntime { .await } + /// Contributes a driver's dynamic TCP forward capability, if it has one. + /// Called at most once, right after `from_driver`, by the generic + /// `build_compute_runtime` construction path. + pub(crate) fn set_forward_sink(&mut self, sink: Arc) { + self.forward_sink = Some(sink); + } + + /// A driver-owned dynamic TCP forward capability, when the active driver + /// has one. `handle_forward_tcp` uses this as a fallback path for + /// sandboxes with no live `ConnectSupervisor` session (e.g. MXC, which + /// has no in-sandbox supervisor at all). `None` for every other driver. + #[must_use] + pub fn forward_sink(&self) -> Option<&Arc> { + self.forward_sink.as_ref() + } + #[must_use] pub fn default_image(&self) -> &str { &self.default_image @@ -1741,6 +1771,43 @@ impl ComputeRuntime { workspace: &str, name: &str, allow_missing: bool, + ) -> Result { + self.delete_sandbox_with_options( + workspace, + name, + allow_missing, + SandboxDeletePreconditions::default(), + ) + .await + } + + pub(crate) async fn delete_sandbox_with_preconditions( + &self, + workspace: &str, + name: &str, + preconditions: SandboxDeletePreconditions, + ) -> Result { + self.delete_sandbox_with_options(workspace, name, false, preconditions) + .await + } + + pub(crate) async fn delete_sandbox_allow_missing_with_preconditions( + &self, + workspace: &str, + name: &str, + allow_missing: bool, + preconditions: SandboxDeletePreconditions, + ) -> Result { + self.delete_sandbox_with_options(workspace, name, allow_missing, preconditions) + .await + } + + async fn delete_sandbox_with_options( + &self, + workspace: &str, + name: &str, + allow_missing: bool, + preconditions: SandboxDeletePreconditions, ) -> Result { // Resolve and acquire both request-side locks before spawning the // owned worker. Cancellation while any of these awaits is pending is @@ -1759,6 +1826,15 @@ impl ComputeRuntime { } return Err(Status::not_found("sandbox not found")); }; + if preconditions + .expected_sandbox_id + .as_deref() + .is_some_and(|expected| expected != candidate.object_id()) + { + return Err(Status::aborted( + "sandbox identity does not match expected_sandbox_id", + )); + } let target = SandboxDeleteTarget { sandbox_id: candidate.object_id().to_string(), sandbox_name: candidate.object_name().to_string(), @@ -1776,7 +1852,7 @@ impl ComputeRuntime { tokio::spawn( async move { runtime - .delete_sandbox_inner(target, delete_guard, global_guard) + .delete_sandbox_inner(target, preconditions, delete_guard, global_guard) .await } .instrument(request_span), @@ -1792,6 +1868,7 @@ impl ComputeRuntime { async fn delete_sandbox_inner( &self, target: SandboxDeleteTarget, + preconditions: SandboxDeletePreconditions, delete_guard: SandboxLifecycleGuard, guard: tokio::sync::OwnedMutexGuard<()>, ) -> Result { @@ -1815,6 +1892,23 @@ impl ComputeRuntime { "sandbox name changed while the delete request was waiting; retry explicitly", )); } + if preconditions + .expected_sandbox_id + .as_deref() + .is_some_and(|expected| expected != current.object_id()) + { + return Err(Status::aborted( + "sandbox identity changed before delete mutation", + )); + } + if preconditions + .expected_resource_version + .is_some_and(|expected| expected != sandbox_resource_version(¤t)) + { + return Err(Status::aborted( + "sandbox resource version changed before delete mutation", + )); + } // `Started` carries both sides of the CAS transition: the durable // `Deleting` row used to fence recovery, and the prior row used only @@ -5379,6 +5473,7 @@ 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()), } } @@ -6317,6 +6412,7 @@ 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()), } } @@ -9324,6 +9420,133 @@ mod tests { ); } + #[tokio::test] + async fn identity_guarded_delete_rejects_a_different_sandbox_before_mutation() { + let driver = ControlledDriver::new(); + let runtime = test_runtime(driver.clone()).await; + let sandbox = sandbox_record("sb-current", "sandbox-a", SandboxPhase::Ready); + runtime.store.put_message(&sandbox).await.unwrap(); + + let error = runtime + .delete_sandbox_with_preconditions( + "default", + "sandbox-a", + SandboxDeletePreconditions { + expected_sandbox_id: Some("sb-stale".to_string()), + expected_resource_version: None, + }, + ) + .await + .unwrap_err(); + + assert_eq!(error.code(), Code::Aborted); + assert_eq!(driver.delete_calls(), 0); + let current = runtime + .store + .get_message::("sb-current") + .await + .unwrap() + .unwrap(); + assert_eq!( + SandboxPhase::try_from(current.phase()).unwrap(), + SandboxPhase::Ready + ); + } + + #[tokio::test] + async fn identity_guarded_delete_revalidates_resource_version_under_lifecycle_lock() { + let driver = ControlledDriver::new(); + let runtime = test_runtime(driver.clone()).await; + let sandbox = sandbox_record("sb-1", "sandbox-a", SandboxPhase::Ready); + runtime.store.put_message(&sandbox).await.unwrap(); + let current = runtime + .store + .get_message::(sandbox.object_id()) + .await + .unwrap() + .unwrap(); + let expected_resource_version = sandbox_resource_version(¤t); + + let delete_gate = runtime.lifecycle_gates.gate_for(sandbox.object_id()); + let delete_guard = delete_gate.lock().await; + let delete_runtime = runtime.clone(); + let delete = tokio::spawn(async move { + delete_runtime + .delete_sandbox_with_preconditions( + "default", + "sandbox-a", + SandboxDeletePreconditions { + expected_sandbox_id: Some("sb-1".to_string()), + expected_resource_version: Some(expected_resource_version), + }, + ) + .await + }); + tokio::time::timeout(Duration::from_secs(1), async { + while Arc::strong_count(&delete_gate) < 2 { + tokio::task::yield_now().await; + } + }) + .await + .expect("guarded delete did not start waiting on the sandbox gate"); + + runtime + .store + .update_message_cas::( + sandbox.object_id(), + expected_resource_version, + |sandbox| sandbox.set_current_policy_version(9), + ) + .await + .unwrap(); + drop(delete_guard); + + let error = delete.await.unwrap().unwrap_err(); + assert_eq!(error.code(), Code::Aborted); + assert_eq!(driver.delete_calls(), 0); + let current = runtime + .store + .get_message::(sandbox.object_id()) + .await + .unwrap() + .unwrap(); + assert_eq!(current.current_policy_version(), 9); + assert_eq!( + SandboxPhase::try_from(current.phase()).unwrap(), + SandboxPhase::Ready + ); + } + + #[tokio::test] + async fn identity_guarded_delete_accepts_the_exact_current_identity() { + let driver = ControlledDriver::new(); + let runtime = test_runtime(driver.clone()).await; + let sandbox = sandbox_record("sb-1", "sandbox-a", SandboxPhase::Ready); + runtime.store.put_message(&sandbox).await.unwrap(); + let current = runtime + .store + .get_message::(sandbox.object_id()) + .await + .unwrap() + .unwrap(); + + let result = runtime + .delete_sandbox_with_preconditions( + "default", + "sandbox-a", + SandboxDeletePreconditions { + expected_sandbox_id: Some(current.object_id().to_string()), + expected_resource_version: Some(sandbox_resource_version(¤t)), + }, + ) + .await + .unwrap(); + + assert!(result.deleted); + assert_eq!(result.sandbox_id, "sb-1"); + assert_eq!(driver.delete_calls(), 1); + } + #[tokio::test] async fn request_cancellation_does_not_cancel_the_delete_worker() { let driver = ControlledDriver::new(); diff --git a/crates/openshell-server/src/grpc/sandbox.rs b/crates/openshell-server/src/grpc/sandbox.rs index 16eeb58de8..c810eb05bb 100644 --- a/crates/openshell-server/src/grpc/sandbox.rs +++ b/crates/openshell-server/src/grpc/sandbox.rs @@ -14,12 +14,13 @@ use crate::auth::workspace_authz::{ AuthorizedWorkspaceScope, MinWorkspaceRole, authorize_list_workspace_selector, authorize_sandbox_workspace, authorize_workspace_selector, }; +use crate::compute::SandboxDeletePreconditions; use crate::pagination::Pagination; use crate::persistence::{ ObjectLabels, ObjectListQuery, ObjectType, WriteCondition, generate_name, }; use futures::future; -use openshell_core::net::set_tcp_nodelay_best_effort; +use openshell_core::net::{connect_tcp_nodelay_best_effort, set_tcp_nodelay_best_effort}; use openshell_core::proto::datamodel::v1::ObjectMeta; use openshell_core::proto::{ AttachSandboxProviderRequest, AttachSandboxProviderResponse, CreateSandboxRequest, @@ -1352,10 +1353,26 @@ async fn handle_delete_sandbox_inner( let workspace = super::workspace::resolve_workspace(state.store.as_ref(), &authz.workspace) .await? .name; + if req.expected_resource_version != 0 && req.expected_sandbox_id.is_empty() { + return Err(Status::invalid_argument( + "expected_resource_version requires expected_sandbox_id", + )); + } + let preconditions = SandboxDeletePreconditions { + expected_sandbox_id: (!req.expected_sandbox_id.is_empty()) + .then_some(req.expected_sandbox_id), + expected_resource_version: (req.expected_resource_version != 0) + .then_some(req.expected_resource_version), + }; let result = state .compute - .delete_sandbox_allow_missing(&workspace, &name, req.allow_missing) + .delete_sandbox_allow_missing_with_preconditions( + &workspace, + &name, + req.allow_missing, + preconditions, + ) .await?; if !result.sandbox_id.is_empty() { state.telemetry.end_sandbox_session(&result.sandbox_id); @@ -2023,6 +2040,69 @@ pub(super) async fn handle_forward_tcp( } let connection_guard = acquire_forward_connection_guard(state, &init, &sandbox).await?; + let sandbox_id = sandbox.object_id().to_string(); + + // Drivers with no in-sandbox supervisor at all (MXC) never have a live + // ConnectSupervisor session -- `open_relay_with_target` below would just + // burn its 15s timeout and fail. When the active driver contributes a + // dynamic-forward capability, bridge through that instead (see + // `ComputeDriverForwardSink::open_dynamic_forward`). + if let Some(forward_sink) = state.compute.forward_sink() { + let target_port = match &target { + relay_open::Target::Tcp(t) => u16::try_from(t.port) + .map_err(|_| Status::invalid_argument("tcp target port out of range"))?, + relay_open::Target::Ssh(_) => { + return Err(Status::unimplemented( + "this driver has no SSH server to forward to", + )); + } + }; + + let (relay_addr, nonce, relay_handle) = forward_sink + .open_dynamic_forward(&sandbox_id, target_port) + .await + .map_err(|e| Status::unavailable(format!("driver dynamic forward failed: {e}")))?; + + // This is a latency-sensitive request/response tunnel, including on + // loopback -- small agent-protocol/WS frames can otherwise stall + // behind delayed ACK behavior, so disable Nagle on this leg too. + let mut relay_stream = connect_tcp_nodelay_best_effort(&[relay_addr]) + .await + .map_err(|e| { + Status::unavailable(format!( + "failed to connect to MXC relay at {relay_addr}: {e}" + )) + })?; + // Prove to the relay this is the real Phase B peer before any + // tunneled application data -- see openshell-driver-mxc's relay.rs + // module docs (the relay listens on loopback, so without this any + // other local process racing to connect first could otherwise + // hijack the forward). + tokio::io::AsyncWriteExt::write_all(&mut relay_stream, &nonce) + .await + .map_err(|e| { + Status::unavailable(format!("failed to authenticate to MXC relay: {e}")) + })?; + + let (tx, rx) = mpsc::channel::>(256); + let sandbox_id_bridge = sandbox_id.clone(); + tokio::spawn(async move { + let _connection_guard = connection_guard; + // Held for the bridge's lifetime; dropping it (bridge exits, + // this task ends) stops the ephemeral relay listener and closes + // Phase A, which is what tells the sandbox's dynamic bridge to + // stop too -- no separate teardown message needed. + let _relay_handle = relay_handle; + bridge_forward_tcp_stream(inbound, relay_stream, tx, &sandbox_id_bridge, "mxc-dynamic") + .await; + }); + + let stream: Pin< + Box> + Send + 'static>, + > = Box::pin(ReceiverStream::new(rx)); + return Ok(Response::new(stream)); + } + let (channel_id, relay_rx) = state .supervisor_sessions .open_relay_with_target( @@ -2034,7 +2114,6 @@ pub(super) async fn handle_forward_tcp( .await .map_err(|e| Status::unavailable(format!("supervisor relay failed: {e}")))?; - let sandbox_id = sandbox.object_id().to_string(); let (tx, rx) = mpsc::channel::>(256); tokio::spawn(async move { let _connection_guard = connection_guard; @@ -2222,13 +2301,15 @@ fn validate_tcp_target_parts(host: &str, _port: u32) -> Result { } } -async fn bridge_forward_tcp_stream( +async fn bridge_forward_tcp_stream( mut inbound: tonic::Streaming, - relay_stream: tokio::io::DuplexStream, + relay_stream: S, tx: mpsc::Sender>, sandbox_id: &str, channel_id: &str, -) { +) where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Send + 'static, +{ let (mut relay_read, mut relay_write) = tokio::io::split(relay_stream); let sandbox_id_in = sandbox_id.to_string(); @@ -3767,6 +3848,7 @@ mod tests { workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), )), + ..Default::default() }), ) .await @@ -3808,6 +3890,66 @@ mod tests { ); } + #[tokio::test] + async fn delete_handler_rejects_expected_identity_drift_before_mutation() { + let state = test_server_state().await; + let mut sandbox = test_sandbox("guarded-delete", Vec::new()); + sandbox.metadata.as_mut().unwrap().id = "sb-current".to_string(); + state.store.put_message(&sandbox).await.unwrap(); + + let error = handle_delete_sandbox_inner( + &state, + authed_request(DeleteSandboxRequest { + name: "guarded-delete".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), + expected_sandbox_id: "sb-stale".to_string(), + expected_resource_version: 0, + }), + ) + .await + .unwrap_err(); + + assert_eq!(error.code(), tonic::Code::Aborted); + assert!( + state + .store + .get_message::("sb-current") + .await + .unwrap() + .is_some() + ); + } + + #[tokio::test] + async fn delete_handler_rejects_resource_version_without_immutable_identity() { + let state = test_server_state().await; + let mut sandbox = test_sandbox("guarded-delete", Vec::new()); + sandbox.metadata.as_mut().unwrap().id = "sb-current".to_string(); + state.store.put_message(&sandbox).await.unwrap(); + + let error = handle_delete_sandbox_inner( + &state, + authed_request(DeleteSandboxRequest { + name: "guarded-delete".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), + expected_sandbox_id: String::new(), + expected_resource_version: 17, + }), + ) + .await + .unwrap_err(); + + assert_eq!(error.code(), tonic::Code::InvalidArgument); + assert!( + state + .store + .get_message::("sb-current") + .await + .unwrap() + .is_some() + ); + } + #[tokio::test] async fn attach_sandbox_provider_persists_current_provider_list() { let state = test_server_state().await; @@ -6820,6 +6962,7 @@ mod tests { allow_missing: false, workspace_scope: Some(openshell_core::proto::workspace_selector("no-such-ws")), name: "any".into(), + ..Default::default() }), ) .await diff --git a/crates/openshell-server/src/lib.rs b/crates/openshell-server/src/lib.rs index 6a7cb1daf9..540676c185 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -1115,6 +1115,28 @@ 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, Box), String>; +} + /// Factory for a compute driver linked into a gateway binary. #[async_trait::async_trait] pub trait ComputeDriverFactory: Send + Sync { @@ -1433,6 +1455,7 @@ impl ComputeDriverConfigContext<'_> { pub struct ComputeDriverBuildContext<'a> { config: ComputeDriverConfigContext<'a>, shutdown_rx: watch::Receiver, + forward_sink: Arc>>>, } impl ComputeDriverBuildContext<'_> { @@ -1500,6 +1523,13 @@ impl ComputeDriverBuildContext<'_> { .file .and_then(|file| file.openshell.gateway.otlp.as_ref()) } + + /// Contributes this driver's dynamic TCP forward capability, if it has + /// one, so `ComputeRuntime::forward_sink` can bridge `ForwardTcp` for + /// sandboxes with no live `ConnectSupervisor` session. + pub fn set_forward_sink(&self, sink: Arc) { + *self.forward_sink.lock().unwrap() = Some(sink); + } } #[allow(clippy::too_many_arguments)] @@ -1539,6 +1569,8 @@ async fn build_compute_runtime( let runtime = match driver { ConfiguredComputeDriver::Registered(registration) => { + let forward_sink: Arc>>> = + Arc::new(Mutex::new(None)); let build_context = ComputeDriverBuildContext { config: ComputeDriverConfigContext { driver_name: ®istration.name, @@ -1548,42 +1580,57 @@ async fn build_compute_runtime( driver_startup, }, shutdown_rx, + forward_sink: forward_sink.clone(), }; let instance = registration.factory.build(build_context).await?; match instance { - ComputeDriverInstance::InProcess(driver) => ComputeRuntime::from_driver( - registration.name, - 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::InProcess(driver) => { + let mut runtime = ComputeRuntime::from_driver( + registration.name, + driver, + None, + None, + store, + sandbox_index, + sandbox_watch_bus, + tracing_log_bus, + supervisor_sessions, + ) + .await + .map_err(|error| { + Error::execution(format!("failed to create compute runtime: {error}")) + })?; + let sink = forward_sink.lock().unwrap().take(); + if let Some(sink) = sink { + runtime.set_forward_sink(sink); + } + runtime + } ComputeDriverInstance::InProcessWithProviderCredentials { driver, provider_credentials_sink, - } => 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 mut runtime = ComputeRuntime::from_driver( + registration.name, + driver, + None, + Some(provider_credentials_sink), + store, + sandbox_index, + sandbox_watch_bus, + tracing_log_bus, + supervisor_sessions, + ) + .await + .map_err(|error| { + Error::execution(format!("failed to create compute runtime: {error}")) + })?; + let sink = forward_sink.lock().unwrap().take(); + if let Some(sink) = sink { + runtime.set_forward_sink(sink); + } + runtime + } ComputeDriverInstance::ManagedRemote(mut endpoint) => { endpoint.name = registration.name; ComputeRuntime::new_remote_driver( diff --git a/crates/openshell-server/src/storage_proto.rs b/crates/openshell-server/src/storage_proto.rs index e15eed5c4d..589a513b9f 100644 --- a/crates/openshell-server/src/storage_proto.rs +++ b/crates/openshell-server/src/storage_proto.rs @@ -620,6 +620,7 @@ mod tests { PolicyRevisionPayload::decode(legacy_bytes(V0_0_116_POLICY_PAYLOAD).as_slice()) .expect("legacy policy payload must decode"); assert!(policy_payload.policy.is_some()); + assert!(policy_payload.policy.as_ref().unwrap().ui.is_none()); assert_eq!(policy_payload.hash, "sha256"); assert_eq!(policy_payload.load_error, "none"); assert_eq!(policy_payload.loaded_at_ms, 300); diff --git a/crates/openshell-supervisor-network/src/host.rs b/crates/openshell-supervisor-network/src/host.rs index f52ec29fb8..9ccc25cfe3 100644 --- a/crates/openshell-supervisor-network/src/host.rs +++ b/crates/openshell-supervisor-network/src/host.rs @@ -14,6 +14,7 @@ use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::AtomicU32; +use base64::Engine as _; use miette::Result; use openshell_core::activity::ActivitySender; use openshell_core::denial::DenialEvent; @@ -36,6 +37,27 @@ use crate::opa::OpaEngine; use crate::policy_local::PolicyLocalContext; use crate::proxy::ProxyHandle; +/// Ephemeral credential required from a sandbox before the host proxy will +/// evaluate or forward its request. +/// +/// The expected header value is intentionally private and this type does not +/// implement `Debug`, preventing accidental credential disclosure in logs. +#[derive(Clone)] +pub struct HostProxyClientAuth { + expected_proxy_authorization: Arc, +} + +impl HostProxyClientAuth { + #[must_use] + pub fn basic(username: &str, password: &str) -> Self { + let encoded = base64::engine::general_purpose::STANDARD + .encode(format!("{username}:{password}").as_bytes()); + Self { + expected_proxy_authorization: Arc::from(format!("Basic {encoded}")), + } + } +} + /// Configuration for a host-side `OpenShell` CONNECT proxy. pub struct HostProxyConfig { /// Exact socket the compute driver will redirect sandbox egress to. @@ -46,6 +68,9 @@ pub struct HostProxyConfig { /// socket-owning sandbox process. Policy binaries must match this path for /// L4/L7 allow rules to pass. pub binary_path: PathBuf, + /// Per-sandbox client authentication. Host-side MXC proxies must set this + /// so another sandbox cannot borrow this proxy's identity and policy. + pub client_auth: HostProxyClientAuth, pub sandbox_id: Option, pub sandbox_name: Option, pub openshell_endpoint: Option, @@ -220,6 +245,7 @@ pub async fn start_host_proxy(config: HostProxyConfig) -> Result String { + let mut request = String::from( + "GET http://policy.local/v1/policy/current HTTP/1.1\r\nHost: policy.local\r\n", + ); + for header in headers { + request.push_str(header); + request.push_str("\r\n"); + } + request.push_str("Connection: close\r\n\r\n"); + + let mut client = TcpStream::connect(addr).await.unwrap(); + client.write_all(request.as_bytes()).await.unwrap(); + let mut response = Vec::new(); + tokio::time::timeout(Duration::from_secs(2), client.read_to_end(&mut response)) + .await + .unwrap() + .unwrap(); + String::from_utf8(response).unwrap() + } + + async fn proxy_connect_request(addr: SocketAddr, headers: &[&str]) -> String { + let mut request = + String::from("CONNECT example.invalid:443 HTTP/1.1\r\nHost: example.invalid:443\r\n"); + for header in headers { + request.push_str(header); + request.push_str("\r\n"); + } + request.push_str("Connection: close\r\n\r\n"); + + let mut client = TcpStream::connect(addr).await.unwrap(); + client.write_all(request.as_bytes()).await.unwrap(); + let mut response = Vec::new(); + tokio::time::timeout(Duration::from_secs(2), client.read_to_end(&mut response)) + .await + .unwrap() + .unwrap(); + String::from_utf8(response).unwrap() + } + #[tokio::test] async fn rejects_non_loopback_bind_addr() { let result = start_host_proxy(test_config( @@ -340,24 +406,9 @@ mod tests { .contains("BEGIN CERTIFICATE") ); - let mut client = TcpStream::connect(addr).await.unwrap(); - client - .write_all( - b"GET http://policy.local/v1/policy/current HTTP/1.1\r\n\ - Host: policy.local\r\n\ - Connection: close\r\n\ - \r\n", - ) - .await - .unwrap(); - - let mut response = Vec::new(); - tokio::time::timeout(Duration::from_secs(2), client.read_to_end(&mut response)) - .await - .unwrap() - .unwrap(); - - let response = String::from_utf8(response).unwrap(); + let auth = HostProxyClientAuth::basic("openshell", "test-secret"); + let header = format!("Proxy-Authorization: {}", auth.expected_proxy_authorization); + let response = proxy_request(addr, &[&header]).await; assert!( response.starts_with("HTTP/1.1 200 OK"), "unexpected response: {response}" @@ -373,4 +424,68 @@ mod tests { "unexpected policy payload: {body}" ); } + + #[tokio::test] + async fn per_sandbox_credentials_reject_missing_wrong_cross_and_duplicate_auth() { + let binary = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(binary.path(), b"agent").unwrap(); + + let auth_a = HostProxyClientAuth::basic("openshell", "sandbox-a-secret"); + let auth_b = HostProxyClientAuth::basic("openshell", "sandbox-b-secret"); + // Node's EnvHttpProxyAgent currently emits the field name in lower + // case; HTTP field names are case-insensitive. + let header_a = format!( + "proxy-authorization: {}", + auth_a.expected_proxy_authorization + ); + let header_b = format!( + "Proxy-Authorization: {}", + auth_b.expected_proxy_authorization + ); + + let mut config_a = test_config(([127, 0, 0, 1], 0).into(), binary.path().to_path_buf()); + config_a.client_auth = auth_a; + let proxy_a = start_host_proxy(config_a).await.unwrap(); + + let mut config_b = test_config(([127, 0, 0, 1], 0).into(), binary.path().to_path_buf()); + config_b.client_auth = auth_b; + let proxy_b = start_host_proxy(config_b).await.unwrap(); + + let addr_a = proxy_a.http_addr().unwrap(); + let addr_b = proxy_b.http_addr().unwrap(); + assert!(proxy_request(addr_a, &[]).await.starts_with("HTTP/1.1 407")); + assert!( + proxy_request(addr_a, &[&header_b]) + .await + .starts_with("HTTP/1.1 407"), + "sandbox B credential must not authenticate to sandbox A proxy" + ); + assert!( + proxy_request(addr_a, &[&header_a, &header_a]) + .await + .starts_with("HTTP/1.1 407"), + "duplicate credentials must fail closed" + ); + assert!( + proxy_request(addr_a, &[&header_a]) + .await + .starts_with("HTTP/1.1 200") + ); + assert!( + proxy_request(addr_b, &[&header_b]) + .await + .starts_with("HTTP/1.1 200") + ); + assert!( + proxy_connect_request(addr_a, &[&header_b]) + .await + .starts_with("HTTP/1.1 407") + ); + assert!( + proxy_connect_request(addr_a, &[&header_a]) + .await + .starts_with("HTTP/1.1 403"), + "valid credentials must pass the auth gate and reach deny-by-default policy evaluation" + ); + } } diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index 46c8f69cea..9771afb875 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -222,6 +222,7 @@ impl ProxyHandle { network_mediation_source: Option>, policy_dns_store: Option>, direct_listener_identity: Option, + required_proxy_authorization: Option>, ) -> Result { // Use override bind_addr, fall back to policy http_addr, then default // to loopback:3128. The default allows the proxy to function when no @@ -238,6 +239,11 @@ impl ProxyHandle { } let source_backed = network_mediation_source.is_some(); + if source_backed && required_proxy_authorization.is_some() { + return Err(miette::miette!( + "proxy authorization cannot be required for a network mediation source" + )); + } let listener = if source_backed { None } else { @@ -450,6 +456,7 @@ impl ProxyHandle { let dtx = denial_tx.clone(); let atx = activity_tx.clone(); let endpoint_observations = endpoint_observation_tx.clone(); + let required_authorization = required_proxy_authorization.clone(); tokio::spawn(async move { #[allow(clippy::large_futures)] if let Err(err) = handle_mediated_connection( @@ -473,6 +480,7 @@ impl ProxyHandle { dtx, atx, endpoint_observations, + required_authorization, ) .await { @@ -1274,6 +1282,8 @@ enum AcceptAction { }, } +// The resource-pressure counter is used only by the Unix errno classifier. +#[cfg_attr(not(unix), allow(clippy::needless_pass_by_ref_mut))] fn classify_accept_error( err: &std::io::Error, consecutive_resource_errors: &mut u32, @@ -1281,7 +1291,6 @@ fn classify_accept_error( ) -> AcceptAction { #[cfg(not(unix))] let _ = (err, &mut *consecutive_resource_errors); - #[cfg(unix)] if matches!( err.raw_os_error(), @@ -2024,6 +2033,40 @@ where .await } +fn constant_time_bytes_eq(left: &[u8], right: &[u8]) -> bool { + let max_len = left.len().max(right.len()); + let mut difference = left.len() ^ right.len(); + for index in 0..max_len { + let left_byte = left.get(index).copied().unwrap_or_default(); + let right_byte = right.get(index).copied().unwrap_or_default(); + difference |= usize::from(left_byte ^ right_byte); + } + difference == 0 +} + +fn has_valid_proxy_authorization(request: &str, expected: &str) -> bool { + let mut provided = None; + for line in request.split("\r\n").skip(1) { + if line.is_empty() { + break; + } + let Some((name, value)) = line.split_once(':') else { + return false; + }; + if name.eq_ignore_ascii_case("proxy-authorization") { + // Reject duplicates even when both values are correct. Accepting + // ambiguous credentials can produce parser differentials between + // this proxy and downstream HTTP implementations. + if provided.is_some() { + return false; + } + provided = Some(value.trim()); + } + } + + provided.is_some_and(|value| constant_time_bytes_eq(value.as_bytes(), expected.as_bytes())) +} + // Many distinct, non-related context parameters are required for a CONNECT // dispatch; bundling them into a struct would just shift the noise into call // sites. @@ -2076,6 +2119,7 @@ async fn handle_tcp_connection( denial_tx, activity_tx, endpoint_observation_tx, + None, )) .await } @@ -2147,6 +2191,7 @@ async fn handle_mediated_connection( denial_tx: Option>, activity_tx: Option, endpoint_observation_tx: Option, + required_proxy_authorization: Option>, ) -> Result<()> { // Bind observations to the policy/provider inventory active when this // connection was accepted, even if configuration changes while it runs. @@ -2209,6 +2254,20 @@ async fn handle_mediated_connection( respond(&mut client, b"HTTP/1.1 400 Bad Request\r\n\r\n").await?; return Ok(()); } + if let Some(expected) = required_proxy_authorization.as_deref() + && !has_valid_proxy_authorization(request, expected) + { + warn!("Rejected host proxy request with missing or invalid per-sandbox credentials"); + respond( + &mut client, + b"HTTP/1.1 407 Proxy Authentication Required\r\n\ + Proxy-Authenticate: Basic realm=\"OpenShell\"\r\n\ + Content-Length: 0\r\n\ + Connection: close\r\n\r\n", + ) + .await?; + return Ok(()); + } let mut lines = request.split("\r\n"); let request_line = lines.next().unwrap_or(""); let mut parts = request_line.split_whitespace(); @@ -6853,6 +6912,7 @@ network_policies: {} Some(Arc::new(FailedMediationSource)), None, None, + None, ) .await .expect("proxy starts before source accept"); diff --git a/crates/openshell-supervisor-network/src/run.rs b/crates/openshell-supervisor-network/src/run.rs index 024185d1ac..b3b62c9ffb 100644 --- a/crates/openshell-supervisor-network/src/run.rs +++ b/crates/openshell-supervisor-network/src/run.rs @@ -494,6 +494,7 @@ pub async fn run_networking( .as_ref() .map(|runtime| runtime.store.clone()), None, + None, ) .await?; Some(proxy_handle) diff --git a/crates/openshell-supervisor-relay/Cargo.toml b/crates/openshell-supervisor-relay/Cargo.toml new file mode 100644 index 0000000000..678f08acb4 --- /dev/null +++ b/crates/openshell-supervisor-relay/Cargo.toml @@ -0,0 +1,42 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "openshell-supervisor-relay" +description = "Generic process spawner + WebSocket relay bridge for OpenShell MXC ProcessContainer sandboxes" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[[bin]] +name = "openshell-supervisor-relay" +path = "src/main.rs" + +# This is an MXC/AppContainer helper: it only does anything useful on +# Windows. The implementation is technically portable (no Windows-specific +# APIs), but per the openshell-driver-mxc platform pattern, its real deps +# stay Windows-only so `cargo build --workspace` on Linux/macOS doesn't pull +# in the full relay implementation for a binary those platforms never run — +# see src/main.rs for the corresponding cfg(target_os = "windows") gating. +[target.'cfg(target_os = "windows")'.dependencies] +tokio = { workspace = true } +futures = { workspace = true } +tokio-tungstenite = { workspace = true } +serde_json = { workspace = true } +base64 = { workspace = true } +anyhow = { workspace = true } + +# Black-box integration tests (tests/control_channel_contract.rs) spawn the +# compiled binary above and drive it over real stdio/TCP/WS -- Windows-only, +# same as the binary itself (the whole test file is `#![cfg(windows)]`), so +# these stay out of the dependency graph everywhere else too. +[target.'cfg(target_os = "windows")'.dev-dependencies] +tokio = { workspace = true, features = ["test-util"] } +futures = { workspace = true } +tokio-tungstenite = { workspace = true } +serde_json = { workspace = true } + +[lints] +workspace = true diff --git a/crates/openshell-supervisor-relay/src/imp.rs b/crates/openshell-supervisor-relay/src/imp.rs new file mode 100644 index 0000000000..ad09f7dcb8 --- /dev/null +++ b/crates/openshell-supervisor-relay/src/imp.rs @@ -0,0 +1,1090 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Generic process spawner + WebSocket relay bridge for `OpenShell` MXC +//! `ProcessContainer` sandboxes. +//! +//! Unlike `mxc-ws-agent.rs` (a self-contained echo-server test harness), this +//! binary has exactly one job: launch an arbitrary command inside the +//! `AppContainer` and, on request, expose one of its TCP ports to the host +//! through a gateway relay, without requiring any changes to the launched +//! command itself. It is the `agent_command` the mxc driver spawns in place +//! of the target application directly, whenever a sandbox config asks for +//! relay bridging (see `driver.rs`'s handling of `pc_relay_spawner_path` / +//! `pc_relay_target_port`). +//! +//! There is no bridge at startup — relay bridging is entirely on-demand, +//! driven by `forward` requests over the control channel below (used for +//! `openshell forward service`). Each bridge is independent, short-lived, and +//! parameterized per-request (relay address + target port supplied in the +//! request), not declared anywhere in the gateway TOML. +//! +//! ```text +//! Host TCP client (via `openshell forward service`) +//! | +//! v +//! Gateway relay (127.0.0.1:) <-- driver binds one per forward +//! ^ request, tells us the address +//! | outbound WS (via egress_proxy) over the control channel +//! | +//! openshell-supervisor-relay <-- dials out on "forward", proxies traffic +//! | for the lifetime of that one request +//! | loopback WS (AppContainer-internal) +//! v +//! <-- launched from the "launch" control-channel +//! request (see below), no relay knowledge required +//! ``` +//! +//! Usage: `openshell-supervisor-relay.exe ` -- `` +//! is the TCP port the launched command is expected to bind (an early +//! liveness check, `wait_for_port_ready`: if the target never binds it +//! within 60s, this process exits with an error instead of sitting around +//! with a target that will never work). This binary uses no `share_dir` files +//! at all -- command/env and shutdown both travel over the control channel. +//! +//! Shutdown: driver sends a `"shutdown"` request over the control channel +//! (see below) and separately kills the wxc-exec process (`AppContainer` +//! teardown) as a backstop regardless of whether that message gets through. +//! This binary's `run_lifecycle` reacts to the request by killing the +//! target cleanly if it's still running. +//! +//! ## Control channel (this process's own stdin/stdout) +//! +//! `wxc-exec` runs this process with STDIO passthrough, which forwards its +//! own stdin/stdout down from the driver -- the driver pipes them (see +//! `mxc.rs`'s `run_oneshot`) instead of the usual `null`/`piped`-for-logging +//! split, giving the gateway a write channel straight into the `AppContainer`. +//! This needs **no `AppContainer` network capability at all**: it's inherited +//! process handles, not network traffic, so none of `egress_proxy` / +//! `network.proxy` / `privateNetworkClientServer` are involved. +//! +//! Protocol: newline-delimited JSON, mirroring MXC's own `pipe_server` tool: +//! Request: `{"id": , "op": "", "data": }` +//! Response: `{"id": , "ok": true, "data": }` +//! or `{"id": , "ok": false, "error": ""}` +//! Event (unsolicited, no id): `{"event": ""}` +//! +//! Startup handshake: before spawning anything, this process emits +//! `{"event":"ready","protocol_version":N}` on stdout (`N` = `PROTOCOL_VERSION` +//! below -- the driver rejects a mismatched/missing version immediately, +//! so an independently staged, out-of-sync binary fails fast instead of +//! hanging or misbehaving later), then blocks waiting for a `"launch"` +//! request carrying `data: {"command": [...], "env": [...]}` (one arg per +//! `command` element, first is the executable; `env` is `"KEY=VALUE"` +//! strings, replacing the inherited environment entirely when non-empty -- +//! lets runtimes that choke on an unrecognized host env, e.g. node.js +//! `STATUS_DLL_INIT_FAILED`, get a curated one instead). The driver sends +//! this once its stdout-reader observes the ready event (see driver.rs). +//! Command/env travel over this channel rather than as `agent-cmd.txt`/ +//! `agent-env.txt` files in `share_dir` -- keeps them (which can carry +//! secrets) off disk, and avoids the file ever going stale. +//! +//! Ops: `launch` (see above), `shutdown` (no data; acked, then wakes +//! `run_lifecycle` to kill the target and exit -- see Shutdown above), +//! `ping`, `echo`, and `forward` -- `forward` opens a new, independent relay +//! bridge for the target port and relay address given in the request (see +//! `handle_control_request`'s doc comment for the full shape). +//! +//! Because this channel owns our stdout exclusively, the target process's own +//! stdout/stderr are piped (not inherited) and forwarded to *our* stderr with +//! a `[target stdout]`/`[target stderr]` tag instead, so they stay visible in +//! the gateway log without colliding with control-channel responses. + +use base64::Engine; +use futures::{SinkExt, StreamExt}; +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::oneshot; +use tokio_tungstenite::tungstenite::Message; + +/// Wire protocol version reported in the startup `"ready"` event (see +/// `run_control_channel`). Must match `openshell-driver-mxc`'s +/// `REQUIRED_SUPERVISOR_RELAY_PROTOCOL_VERSION` constant -- duplicated +/// rather than shared via a common crate, matching how the rest of this +/// wire protocol is already duplicated across the two sides. Bump both +/// together whenever the control-channel protocol changes in a way an +/// out-of-sync peer can't safely ignore, so an independently staged, stale +/// binary on either side fails fast with a clear version-mismatch error +/// instead of hanging or misbehaving against a field/event it predates. +const PROTOCOL_VERSION: u64 = 2; + +struct ForwardSession { + reader: tokio::sync::Mutex, + writer: tokio::sync::Mutex, +} + +type ForwardSessions = tokio::sync::Mutex>>; + +pub async fn run() -> anyhow::Result<()> { + let port: u16 = std::env::args() + .nth(1) + .ok_or_else(|| anyhow::anyhow!("usage: openshell-supervisor-relay "))? + .parse() + .map_err(|e| anyhow::anyhow!("invalid : {e}"))?; + + // Wait for the driver's "launch" request (see module docs' startup + // handshake) before spawning anything -- command/env arrive over the + // control channel, not as files read from share_dir. Shutdown notice + // arrives the same way (a later "shutdown" request) -- no share_dir + // files are used by this process at all. + let (launch_tx, launch_rx) = oneshot::channel::<(Vec, Vec)>(); + let launch_slot = Arc::new(tokio::sync::Mutex::new(Some(launch_tx))); + let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); + let shutdown_slot = Arc::new(tokio::sync::Mutex::new(Some(shutdown_tx))); + let forward_sessions = Arc::new(ForwardSessions::new(HashMap::new())); + // Lets main() ask run_control_channel's task to announce "target_ready" + // on stdout once the target is actually up (see below) -- routed + // through that task rather than a second independent stdout handle + // here, since concurrent writers to tokio's stdout can interleave. + let (target_ready_tx, target_ready_rx) = oneshot::channel::<()>(); + tokio::spawn(run_control_channel( + launch_slot, + shutdown_slot, + forward_sessions, + target_ready_rx, + )); + + eprintln!("[openshell-supervisor-relay] waiting for launch request from driver..."); + let (command, env) = launch_rx + .await + .map_err(|_| anyhow::anyhow!("control channel closed before a launch request arrived"))?; + + let mut child = spawn_target(command, env)?; + + eprintln!("[openshell-supervisor-relay] waiting for target on 127.0.0.1:{port} ..."); + // Race the (up to ~300s worst case) port-readiness wait against a + // "shutdown" control-channel request, rather than only observing + // shutdown once run_lifecycle's select starts below. Without this, a + // shutdown arriving while the target is still coming up (or never + // binds) gets acknowledged immediately by run_control_channel -- which + // just fires this oneshot, nothing more -- but nothing actually acts on + // it until wait_for_port_ready returns on its own, leaving this process + // (and the target it spawned) alive for up to the full port-readiness + // budget after a caller was told shutdown succeeded. + let mut shutdown_rx = shutdown_rx; + tokio::select! { + result = wait_for_port_ready(&mut child, port, Duration::from_mins(1)) => { + result?; + } + _ = &mut shutdown_rx => { + eprintln!( + "[openshell-supervisor-relay] shutdown request -- stopping before target became ready" + ); + let _ = child.kill().await; + let _ = child.wait().await; + eprintln!("[openshell-supervisor-relay] done"); + // Not `return Ok(())`: run_control_channel loops on + // stdin.next_line() for this process's entire lifetime and + // only sees EOF once the driver closes its end, which it has + // no reason to do before observing this process actually exit + // -- returning normally here would leave that task (and so + // this whole process) alive indefinitely, exactly the + // "acknowledged but still alive" symptom this fix exists to + // close. std::process::exit terminates unconditionally, + // matching run_lifecycle's own shutdown branch below. + std::process::exit(0); + } + } + eprintln!("[openshell-supervisor-relay] target is up on port {port}"); + // Unsolicited event, distinct from the "launch" control-channel + // response (which only confirmed the command/env arrived, not that the + // target is actually reachable) -- driver.rs awaits this before + // publishing the sandbox Ready=True. A send failure just means the + // control-channel task already exited; nothing to do about that here. + let _ = target_ready_tx.send(()); + + // No bridge at startup -- relay bridging is entirely on-demand via the + // control channel's "forward" op (see module docs). Just run the target + // process's lifecycle from here. + run_lifecycle(child, shutdown_rx).await; + + Ok(()) +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/// Render the first `n` bytes of `data` as a printable-ASCII preview +/// (non-printable bytes shown as `.`), for hop-by-hop diagnostic logging. +/// Not a general-purpose formatter -- just enough to eyeball whether e.g. an +/// HTTP/WS handshake looks intact versus corrupted or empty. +/// +/// Not called anywhere: forwarded traffic can carry auth headers, cookies, or +/// other sensitive payload, and helper stderr is forwarded into gateway logs, +/// so no byte preview is ever logged. Kept only so a future opt-in diagnostic +/// mode has a ready-made (still-redaction-worthy) formatter to start from. +#[allow(dead_code)] +fn byte_preview(data: &[u8]) -> String { + const MAX: usize = 120; + let n = data.len().min(MAX); + let mut s: String = data[..n] + .iter() + .map(|&b| { + if b.is_ascii_graphic() || b == b' ' { + b as char + } else { + '.' + } + }) + .collect(); + if data.len() > MAX { + s.push_str("..."); + } + s +} + +// ── Target process ──────────────────────────────────────────────────────────── + +/// Spawn `command` (first element is the executable, rest are args) with +/// `env` (`"KEY=VALUE"` strings) as its entire environment when non-empty -- +/// both arrive over the control channel's `"launch"` request (see module +/// docs), not read from `share_dir` files. The child's stdout/stderr are piped +/// and forwarded (tagged) to our own stderr — not inherited directly — +/// because our stdout is reserved exclusively for the control-channel +/// protocol with the driver. The child's stdin is closed; it isn't part of +/// this channel. +fn spawn_target(command: Vec, env: Vec) -> anyhow::Result { + if command.is_empty() { + anyhow::bail!("launch command must not be empty"); + } + + let mut cmd = tokio::process::Command::new(&command[0]); + cmd.args(&command[1..]); + cmd.stdin(std::process::Stdio::null()); + cmd.stdout(std::process::Stdio::piped()); + cmd.stderr(std::process::Stdio::piped()); + + if !env.is_empty() { + let child_env: Vec<(String, String)> = env + .iter() + .filter(|l| l.contains('=')) + .filter_map(|l| { + let pos = l.find('=')?; + Some((l[..pos].to_string(), l[pos + 1..].to_string())) + }) + .collect(); + eprintln!( + "[openshell-supervisor-relay] using {} child env vars from launch request", + child_env.len() + ); + cmd.env_clear().envs(child_env); + } + + // Not the full command line: it's a control-channel payload (see the + // "launch" handshake in this module's docs) and can carry secrets in + // its arguments (e.g. a token passed via CLI flag) -- log only the + // executable and an argument count, matching how the driver side + // avoids writing agent_command/env to disk for the same reason. + eprintln!( + "[openshell-supervisor-relay] starting program {:?} with {} arg(s)", + command[0], + command.len().saturating_sub(1) + ); + let mut child = cmd.spawn()?; + + if let Some(stdout) = child.stdout.take() { + tokio::spawn(forward_tagged_lines(stdout, "target stdout")); + } + if let Some(stderr) = child.stderr.take() { + tokio::spawn(forward_tagged_lines(stderr, "target stderr")); + } + + Ok(child) +} + +/// Read lines from `reader` and re-emit them on our own stderr, tagged, so +/// the target's output stays visible in the gateway log without touching our +/// stdout (reserved for the control channel). +async fn forward_tagged_lines(reader: impl tokio::io::AsyncRead + Unpin, label: &'static str) { + use tokio::io::{AsyncBufReadExt, BufReader}; + let mut lines = BufReader::new(reader).lines(); + while let Ok(Some(line)) = lines.next_line().await { + eprintln!("[{label}] {line}"); + } +} + +// ── Control channel ─────────────────────────────────────────────────────────── +// +// See module docs for the protocol and why this is safe to run with no +// AppContainer network capability. Runs for the lifetime of the process, +// independent of target/relay state. + +/// Holds the one-shot sender the `"launch"` op fires, carrying `(command, +/// env)` to `main()`. `None` after the first successful launch (or if +/// `main()` already gave up on it) -- a second `"launch"` is rejected. +type LaunchSlot = tokio::sync::Mutex, Vec)>>>; +/// Holds the one-shot sender the `"shutdown"` op fires, waking +/// `run_lifecycle`'s select so it can kill the target and exit. `None` +/// after the first shutdown request -- a second one is a no-op ack. +type ShutdownSlot = tokio::sync::Mutex>>; + +async fn run_control_channel( + launch: Arc, + shutdown: Arc, + forward_sessions: Arc, + target_ready_rx: oneshot::Receiver<()>, +) { + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + + let stdin = tokio::io::stdin(); + let mut lines = BufReader::new(stdin).lines(); + let mut stdout = tokio::io::stdout(); + + // Announce readiness before entering the request loop: this is the + // signal driver.rs's stdout-reader waits for to know it's safe to send + // "launch" (see module docs' startup handshake). Unsolicited -- no + // correlation id, since it isn't a reply to anything the driver sent. + let ready = serde_json::json!({"event": "ready", "protocol_version": PROTOCOL_VERSION}) + .to_string() + + "\n"; + if stdout.write_all(ready.as_bytes()).await.is_err() || stdout.flush().await.is_err() { + eprintln!("[openshell-supervisor-relay] control channel: failed to announce ready"); + return; + } + eprintln!("[openshell-supervisor-relay] control channel ready (stdin/stdout)"); + + // `None` once fired (or once main()'s sender is dropped without firing, + // e.g. wait_for_port_ready failed) -- the `if` guard below then disables + // that select arm instead of it firing repeatedly on every subsequent + // poll of an already-resolved oneshot. + let mut target_ready_rx = Some(target_ready_rx); + + loop { + tokio::select! { + line_result = lines.next_line() => { + let line = match line_result { + Ok(Some(l)) => l, + Ok(None) => { + eprintln!("[openshell-supervisor-relay] control channel: stdin closed"); + break; + } + Err(e) => { + eprintln!("[openshell-supervisor-relay] control channel read error: {e}"); + break; + } + }; + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + + // Log only the operation and correlation id, never the raw request -- + // "launch" carries the target's complete environment (e.g. + // OPENCLAW_GATEWAY_TOKEN) and this process's stderr is forwarded + // verbatim into the gateway's own logs, so printing `trimmed` here + // would expose it even when MXC's own --debug flag is off. + eprintln!( + "[openshell-supervisor-relay] control request: {}", + describe_control_request(trimmed) + ); + let response = handle_control_request( + trimmed, + &launch, + &shutdown, + &forward_sessions, + ) + .await; + let mut out = response.to_string(); + out.push('\n'); + if stdout.write_all(out.as_bytes()).await.is_err() || stdout.flush().await.is_err() { + eprintln!("[openshell-supervisor-relay] control channel write failed"); + break; + } + } + // See module docs' startup handshake -- distinct from the + // "launch" response, which only confirms the command/env + // arrived. Unsolicited, like "ready" above. + result = async { target_ready_rx.as_mut().unwrap().await }, if target_ready_rx.is_some() => { + target_ready_rx = None; + if result.is_ok() { + let event = serde_json::json!({"event": "target_ready"}).to_string() + "\n"; + if stdout.write_all(event.as_bytes()).await.is_err() || stdout.flush().await.is_err() { + eprintln!("[openshell-supervisor-relay] control channel: failed to announce target_ready"); + } + } + // A dropped sender (main() bailed before the target ever + // came up, e.g. wait_for_port_ready's own error) means + // there's nothing to announce -- driver.rs's timeout on the + // corresponding event will surface that as a launch failure + // on its own. + } + } + } +} + +/// `forward` opens a new, independent relay bridge for a target port, e.g. +/// for `openshell forward service`. +/// +/// `data`: `{"relay_addr": "", "target_port": }` -- the +/// caller (the driver) has already started a fresh relay listener on the +/// gateway side for this one request and tells us its address here; we dial +/// out to it (Phase A). Replies once Phase A actually connects (or on +/// failure/timeout), so the caller knows whether the bridge is really usable +/// before it starts sending Phase B clients at the relay address it created. +/// +/// No explicit "stop" for this bridge: it runs until Phase A closes, which +/// happens when the caller drops its relay listener (the gRPC forward +/// stream ending) -- see the driver-side `ForwardSink::open_dynamic_forward`. +/// Summarizes an inbound control-channel request for logging as `op=... id=...` +/// -- deliberately never includes `data`, since `launch` (and, in principle, +/// `echo`) can carry secrets. Falls back to a fixed placeholder rather than +/// printing anything from `line` if it doesn't even parse, so a malformed +/// request can't smuggle sensitive-looking text into the log via a JSON +/// parse failure either. +fn describe_control_request(line: &str) -> String { + serde_json::from_str::(line).map_or_else( + |_| "".to_string(), + |v| { + let op = v.get("op").and_then(|x| x.as_str()).unwrap_or(""); + let id = v.get("id").cloned().unwrap_or(serde_json::Value::Null); + format!("op={op} id={id}") + }, + ) +} + +async fn handle_control_request( + line: &str, + launch: &LaunchSlot, + shutdown: &ShutdownSlot, + forward_sessions: &ForwardSessions, +) -> serde_json::Value { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let req: serde_json::Value = match serde_json::from_str(line) { + Ok(v) => v, + Err(e) => { + return serde_json::json!({"id": null, "ok": false, "error": format!("parse error: {e}")}); + } + }; + let id = req.get("id").cloned().unwrap_or(serde_json::Value::Null); + let op = req.get("op").and_then(|v| v.as_str()).unwrap_or(""); + + match op { + // Driver sends this on sandbox delete instead of writing a + // openshell-shutdown.signal file -- wakes run_lifecycle's select so + // it can kill the target and exit. Acked even on a repeat (the + // slot's already empty by then), since the driver's request has a + // short timeout and shouldn't be left hanging either way. + "shutdown" => { + let slot = shutdown.lock().await.take(); + if let Some(tx) = slot { + let _ = tx.send(()); + } + serde_json::json!({"id": id, "ok": true}) + } + // See module docs' startup handshake: sent once, right after the + // "ready" event, carrying the real command/env instead of them + // being written to share_dir as agent-cmd.txt/agent-env.txt. + "launch" => { + let data = req.get("data").cloned().unwrap_or(serde_json::Value::Null); + let command: Vec = data + .get("command") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default(); + if command.is_empty() { + return serde_json::json!({ + "id": id, "ok": false, + "error": "launch requires data.command (non-empty array of strings)" + }); + } + let env: Vec = data + .get("env") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default(); + + let slot = launch.lock().await.take(); + slot.map_or_else( + || serde_json::json!({"id": id, "ok": false, "error": "launch already requested"}), + |tx| { + let _ = tx.send((command, env)); + serde_json::json!({"id": id, "ok": true}) + }, + ) + } + "ping" => serde_json::json!({"id": id, "ok": true, "data": "pong"}), + "echo" => { + let data = req.get("data").cloned().unwrap_or(serde_json::Value::Null); + serde_json::json!({"id": id, "ok": true, "data": data}) + } + "forward_open" => { + let data = req.get("data").cloned().unwrap_or(serde_json::Value::Null); + let session_id = data + .get("session_id") + .and_then(|v| v.as_str()) + .filter(|v| v.len() == 64 && v.bytes().all(|b| b.is_ascii_hexdigit())) + .map(str::to_string); + let target_port = data + .get("target_port") + .and_then(serde_json::Value::as_u64) + .and_then(|n| u16::try_from(n).ok()); + let (Some(session_id), Some(target_port)) = (session_id, target_port) else { + return serde_json::json!({ + "id": id, "ok": false, + "error": "forward_open requires a 64-character hexadecimal session_id and target_port (u16)" + }); + }; + if forward_sessions.lock().await.contains_key(&session_id) { + return serde_json::json!({ + "id": id, "ok": false, "error": "forward session already exists" + }); + } + let stream = match connect_forward_target(|| { + tokio::net::TcpStream::connect(("127.0.0.1", target_port)) + }) + .await + { + Ok(stream) => stream, + Err(error) => { + return serde_json::json!({ + "id": id, "ok": false, + "error": format!("target connect failed: {error}") + }); + } + }; + if let Err(error) = stream.set_nodelay(true) { + eprintln!( + "[openshell-supervisor-relay] failed to set TCP_NODELAY on target connection: {error}" + ); + } + let (reader, writer) = stream.into_split(); + forward_sessions.lock().await.insert( + session_id, + Arc::new(ForwardSession { + reader: tokio::sync::Mutex::new(reader), + writer: tokio::sync::Mutex::new(writer), + }), + ); + serde_json::json!({"id": id, "ok": true}) + } + "forward_write" => { + let data = req.get("data").cloned().unwrap_or(serde_json::Value::Null); + let session_id = data.get("session_id").and_then(|v| v.as_str()); + let encoded = data.get("bytes").and_then(|v| v.as_str()); + let (Some(session_id), Some(encoded)) = (session_id, encoded) else { + return serde_json::json!({ + "id": id, "ok": false, + "error": "forward_write requires session_id and base64 bytes" + }); + }; + if encoded.len() > 16_384 { + return serde_json::json!({"id": id, "ok": false, "error": "forward_write chunk is too large"}); + } + let bytes = match base64::engine::general_purpose::STANDARD.decode(encoded) { + Ok(bytes) => bytes, + Err(error) => { + return serde_json::json!({ + "id": id, "ok": false, "error": format!("invalid forward bytes: {error}") + }); + } + }; + let session = forward_sessions.lock().await.get(session_id).cloned(); + let Some(session) = session else { + return serde_json::json!({"id": id, "ok": false, "error": "forward session not found"}); + }; + match session.writer.lock().await.write_all(&bytes).await { + Ok(()) => serde_json::json!({"id": id, "ok": true}), + Err(error) => { + serde_json::json!({"id": id, "ok": false, "error": format!("target write failed: {error}")}) + } + } + } + "forward_read" => { + let data = req.get("data").cloned().unwrap_or(serde_json::Value::Null); + let Some(session_id) = data.get("session_id").and_then(|v| v.as_str()) else { + return serde_json::json!({"id": id, "ok": false, "error": "forward_read requires session_id"}); + }; + let session = forward_sessions.lock().await.get(session_id).cloned(); + let Some(session) = session else { + return serde_json::json!({"id": id, "ok": false, "error": "forward session not found"}); + }; + let mut bytes = vec![0_u8; 8192]; + match tokio::time::timeout( + Duration::from_millis(100), + session.reader.lock().await.read(&mut bytes), + ) + .await + { + Ok(Ok(0)) => { + serde_json::json!({"id": id, "ok": true, "data": {"bytes": "", "eof": true}}) + } + Ok(Ok(n)) => { + let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes[..n]); + serde_json::json!({"id": id, "ok": true, "data": {"bytes": encoded, "eof": false}}) + } + Ok(Err(error)) => { + serde_json::json!({"id": id, "ok": false, "error": format!("target read failed: {error}")}) + } + Err(_) => { + serde_json::json!({"id": id, "ok": true, "data": {"bytes": "", "eof": false}}) + } + } + } + "forward_close" => { + let data = req.get("data").cloned().unwrap_or(serde_json::Value::Null); + let Some(session_id) = data.get("session_id").and_then(|v| v.as_str()) else { + return serde_json::json!({"id": id, "ok": false, "error": "forward_close requires session_id"}); + }; + forward_sessions.lock().await.remove(session_id); + serde_json::json!({"id": id, "ok": true}) + } + "forward" => { + let data = req.get("data").cloned().unwrap_or(serde_json::Value::Null); + let relay_addr = data + .get("relay_addr") + .and_then(|v| v.as_str()) + .map(str::to_string); + let target_port = data + .get("target_port") + .and_then(serde_json::Value::as_u64) + .and_then(|n| u16::try_from(n).ok()); + // Proves to the relay (a loopback-bound listener any other local + // process could otherwise race to connect to first) that this + // is the real Phase A peer -- see openshell-driver-mxc's + // relay.rs module docs. Sent verbatim as the WS auth message + // below, not decoded here; the driver and the relay agree on + // the hex encoding independently. + let nonce = data + .get("nonce") + .and_then(|v| v.as_str()) + .map(str::to_string); + + let (Some(relay_addr), Some(target_port), Some(nonce)) = + (relay_addr, target_port, nonce) + else { + return serde_json::json!({ + "id": id, "ok": false, + "error": "forward requires data.relay_addr (string), data.target_port (u16), and data.nonce (string)" + }); + }; + + let (ready_tx, ready_rx) = oneshot::channel::>(); + + eprintln!( + "[openshell-supervisor-relay] forward: dynamic bridge ws://{relay_addr} <-> 127.0.0.1:{target_port}" + ); + tokio::spawn(run_relay_bridge(relay_addr, target_port, nonce, ready_tx)); + + match tokio::time::timeout(Duration::from_secs(5), ready_rx).await { + Ok(Ok(Ok(()))) => { + serde_json::json!({"id": id, "ok": true, "data": {"target_port": target_port}}) + } + Ok(Ok(Err(e))) => serde_json::json!({"id": id, "ok": false, "error": e}), + Ok(Err(_)) => { + serde_json::json!({"id": id, "ok": false, "error": "relay bridge task dropped"}) + } + Err(_) => serde_json::json!({ + "id": id, "ok": false, "error": "timed out waiting for relay connection" + }), + } + } + _ => serde_json::json!({"id": id, "ok": false, "error": format!("unknown op: {op}")}), + } +} + +/// Bound session opening below the driver's 10-second control-request timeout. +/// Five one-second attempts plus four 300ms retry delays take at most 6.2s +/// of timer budget. A stuck connect is cancelled before another is attempted. +async fn connect_forward_target(mut connect: F) -> std::io::Result +where + F: FnMut() -> Fut, + Fut: Future>, +{ + let mut last_error = std::io::Error::from(std::io::ErrorKind::TimedOut); + for attempt in 1..=5 { + match tokio::time::timeout(Duration::from_secs(1), connect()).await { + Ok(Ok(stream)) => return Ok(stream), + Ok(Err(error)) => last_error = error, + Err(_) => last_error = std::io::Error::from(std::io::ErrorKind::TimedOut), + } + if attempt < 5 { + tokio::time::sleep(Duration::from_millis(300)).await; + } + } + Err(last_error) +} + +/// Number of full-budget tries `wait_for_port_ready` makes -- each try gets +/// its own complete `per_try_timeout` window, not a slice of it. Worst case +/// total wait is `max_tries * per_try_timeout` (3 * 60s = 180s today). +const PORT_READY_MAX_TRIES: u32 = 3; + +/// Poll for the target port accepting TCP connections, bailing out early +/// (rather than waiting out all tries) if the child process exits first — a +/// dead child will never open the port, so there's no reason to wait. Makes +/// up to `PORT_READY_MAX_TRIES` tries, each given the full `per_try_timeout` +/// budget, logging the start of every try so a long cold-start wait (Node.js +/// first-run JIT/module resolution, first-touch AV scan of freshly staged +/// binaries, etc.) is visible rather than silent until success or final +/// timeout. +async fn wait_for_port_ready( + child: &mut tokio::process::Child, + port: u16, + per_try_timeout: Duration, +) -> anyhow::Result<()> { + // Bound each individual connect attempt: on at least one observed + // wxc-exec build, a connect() against a not-yet-listening loopback port + // inside the AppContainer never resolved at all (no fast ECONNREFUSED, + // no error) instead of failing quickly like an ordinary closed-port + // connect. Without this, a single early attempt can hang forever and + // this function -- and the whole readiness wait -- never returns even + // after the target's port genuinely opens, since nothing ever retries. + const ATTEMPT_TIMEOUT: Duration = Duration::from_secs(2); + + let overall_start = tokio::time::Instant::now(); + for try_num in 1..=PORT_READY_MAX_TRIES { + eprintln!( + "[openshell-supervisor-relay] port readiness try {try_num}/{PORT_READY_MAX_TRIES} (up to {per_try_timeout:?}) for port {port}" + ); + let try_deadline = tokio::time::Instant::now() + per_try_timeout; + loop { + let attempt = tokio::time::timeout( + ATTEMPT_TIMEOUT, + tokio::net::TcpStream::connect(("127.0.0.1", port)), + ) + .await; + if let Ok(Ok(_)) = attempt { + eprintln!( + "[openshell-supervisor-relay] port {port} ready after {:?} (try {try_num}/{PORT_READY_MAX_TRIES})", + overall_start.elapsed() + ); + return Ok(()); + } + if let Ok(Some(status)) = child.try_wait() { + anyhow::bail!("target process exited before port {port} came up: {status}"); + } + if tokio::time::Instant::now() >= try_deadline { + eprintln!( + "[openshell-supervisor-relay] port readiness try {try_num}/{PORT_READY_MAX_TRIES} timed out after {per_try_timeout:?} (elapsed {:?} total)", + overall_start.elapsed() + ); + break; + } + tokio::time::sleep(Duration::from_millis(300)).await; + } + } + anyhow::bail!( + "timed out after {PORT_READY_MAX_TRIES} tries ({:?} total) waiting for port {port}", + overall_start.elapsed() + ); +} + +// ── Relay bridge ────────────────────────────────────────────────────────────── +// +// Implements the sandbox side of relay.rs's Phase A protocol exactly: +// +// TEXT "SESSION_START" — relay opened a new Phase B TCP connection; open a +// FRESH raw TCP connection to the target and start +// forwarding its bytes back as BINARY frames. +// BINARY — bytes from the Phase B TCP stream; write them +// as-is to the active target connection. +// TEXT "SESSION_END" — Phase B TCP connection closed; drop the target +// connection. +// WS Close — relay shutting down. +// +// This is a raw byte tunnel, not a WS-to-WS message bridge: each session gets +// its own genuine TCP connection to the target with bytes passed through +// untouched, so the host's own protocol (e.g. a real WS handshake it performs +// against the tunnel) reaches the target exactly as sent. A single persistent +// connection re-used across sessions, forwarding opaque message payloads, +// would not preserve that — the target would never see a valid handshake. + +async fn run_relay_bridge( + relay_addr: String, + port: u16, + nonce: String, + ready_tx: oneshot::Sender>, +) { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let relay_url = format!("ws://{relay_addr}"); + + // The gateway already bound this listener before sending us the + // "forward" request, so it's expected to be immediately reachable -- + // single attempt, no retry needed here. + // + // Connect the raw TCP socket ourselves rather than letting + // tokio_tungstenite::connect_async do it internally, so TCP_NODELAY can + // be set before the WS handshake: this is a latency-sensitive + // request/response tunnel, including on loopback, and small WS frames + // can otherwise stall behind delayed ACK behavior. Best-effort -- + // failure to set it doesn't fail the connection, just costs a bit of + // latency. + let tcp = match tokio::net::TcpStream::connect(&relay_addr).await { + Ok(stream) => { + if let Err(e) = stream.set_nodelay(true) { + eprintln!( + "[openshell-supervisor-relay] failed to set TCP_NODELAY on relay connection: {e}" + ); + } + stream + } + Err(e) => { + let msg = format!("relay connect failed: {e}"); + eprintln!("[openshell-supervisor-relay] {msg}"); + let _ = ready_tx.send(Err(msg)); + return; + } + }; + let relay_ws = match tokio_tungstenite::client_async(&relay_url, tcp).await { + Ok((ws, _)) => { + eprintln!("[openshell-supervisor-relay] relay connected: {relay_url}"); + ws + } + Err(e) => { + let msg = format!("relay handshake failed: {e}"); + eprintln!("[openshell-supervisor-relay] {msg}"); + let _ = ready_tx.send(Err(msg)); + return; + } + }; + let (mut relay_write, mut relay_read) = relay_ws.split(); + + // Must be the very first message: the relay won't trust anything else + // from this connection (including SESSION_START/BINARY frames) until + // this matches -- see relay.rs module docs. + if let Err(e) = relay_write + .send(Message::Text(format!("AUTH:{nonce}").into())) + .await + { + let msg = format!("relay auth send failed: {e}"); + eprintln!("[openshell-supervisor-relay] {msg}"); + let _ = ready_tx.send(Err(msg)); + return; + } + + eprintln!("[openshell-supervisor-relay] relay bridge active"); + let _ = ready_tx.send(Ok(())); + + let mut session: Option = None; + let mut read_buf = vec![0u8; 8192]; + // Byte counters, reset per session -- mirror the instrumentation in + // relay.rs. Together the two sides let a hung request be localized to a + // specific hop instead of just "the client timed out": relay.rs's + // host_to_sandbox_* should match this session's phase_a_to_target_* + // (same bytes, different name each side of the WS tunnel), and + // target_to_phase_a_* should match relay.rs's sandbox_to_host_*. A + // mismatch or a stuck-at-zero counter on one side pinpoints exactly + // where bytes stop moving. No payload content is ever logged -- see the + // module-level note on `byte_preview`. + let mut phase_a_to_target_bytes: u64 = 0; + let mut phase_a_to_target_chunks: u64 = 0; + let mut target_to_phase_a_bytes: u64 = 0; + let mut target_to_phase_a_chunks: u64 = 0; + + loop { + let session_read = + futures::future::OptionFuture::from(session.as_mut().map(|s| s.read(&mut read_buf))); + + tokio::select! { + msg = relay_read.next() => match msg { + Some(Ok(Message::Text(t))) => { + if t == "SESSION_START" { + // Retry briefly: even though wait_for_port_ready() already + // confirmed a raw TCP accept succeeds once, that doesn't + // guarantee the target's listener stays continuously + // accept-ready under a freshly-started process (observed as + // a genuine, reproducible ~500ms startup race elsewhere in + // this codebase -- see mxc-ws-agent.rs's local-connect + // retry). A session-open failure here would otherwise + // silently drop the host's connection attempt. + match connect_forward_target(|| tokio::net::TcpStream::connect(("127.0.0.1", port))).await { + Ok(s) => { + // Latency-sensitive request/response tunnel -- + // see the matching comment on the relay + // connection above. Best-effort. + if let Err(e) = s.set_nodelay(true) { + eprintln!("[openshell-supervisor-relay] failed to set TCP_NODELAY on target connection: {e}"); + } + eprintln!("[openshell-supervisor-relay] session start -- connected to target 127.0.0.1:{port}"); + session = Some(s); + phase_a_to_target_bytes = 0; + phase_a_to_target_chunks = 0; + target_to_phase_a_bytes = 0; + target_to_phase_a_chunks = 0; + } + Err(last_err) => { + eprintln!("[openshell-supervisor-relay] session start -- target connect failed: {last_err}"); + // Tell the relay so it can close the host's + // TCP connection promptly instead of leaving + // it hanging until the client's own timeout. + let _ = relay_write + .send(Message::Text(format!("SESSION_FAILED:{last_err}").into())) + .await; + } + } + } else if t == "SESSION_END" { + eprintln!("[openshell-supervisor-relay] session end phase_a_to_target_bytes={phase_a_to_target_bytes} phase_a_to_target_chunks={phase_a_to_target_chunks} target_to_phase_a_bytes={target_to_phase_a_bytes} target_to_phase_a_chunks={target_to_phase_a_chunks}"); + session = None; + } + } + Some(Ok(Message::Binary(b))) => { + if let Some(s) = session.as_mut() { + phase_a_to_target_chunks += 1; + phase_a_to_target_bytes += b.len() as u64; + if phase_a_to_target_chunks == 1 { + eprintln!("[openshell-supervisor-relay] first phase-A->target chunk: {} bytes", b.len()); + } + if s.write_all(&b).await.is_err() { + eprintln!("[openshell-supervisor-relay] target write failed"); + session = None; + } + } else { + eprintln!("[openshell-supervisor-relay] BINARY with no active session (dropped {} bytes)", b.len()); + } + } + Some(Ok(Message::Close(_))) | None => { + eprintln!("[openshell-supervisor-relay] relay closed"); + break; + } + Some(Ok(_)) => {} // ping/pong handled by tungstenite + Some(Err(e)) => { + eprintln!("[openshell-supervisor-relay] relay read error: {e}"); + break; + } + }, + Some(result) = session_read => { + match result { + Ok(0) => { + eprintln!("[openshell-supervisor-relay] target connection closed phase_a_to_target_bytes={phase_a_to_target_bytes} phase_a_to_target_chunks={phase_a_to_target_chunks} target_to_phase_a_bytes={target_to_phase_a_bytes} target_to_phase_a_chunks={target_to_phase_a_chunks}"); + session = None; + // Tell the relay so it can close the host's Phase B + // TCP connection promptly (see relay.rs's handling + // of this message) instead of leaving the host + // client waiting for more bytes until its own + // timeout -- the target won't send any more. + let _ = relay_write.send(Message::Text("SESSION_END".into())).await; + } + Ok(n) => { + target_to_phase_a_chunks += 1; + target_to_phase_a_bytes += n as u64; + if target_to_phase_a_chunks == 1 { + eprintln!("[openshell-supervisor-relay] first target->phase-A chunk: {n} bytes"); + } + if relay_write.send(Message::Binary(read_buf[..n].to_vec().into())).await.is_err() { + break; + } + } + Err(e) => { + eprintln!("[openshell-supervisor-relay] target read error: {e}"); + session = None; + } + } + } + } + } + eprintln!( + "[openshell-supervisor-relay] relay bridge stopped phase_a_to_target_bytes={phase_a_to_target_bytes} phase_a_to_target_chunks={phase_a_to_target_chunks} target_to_phase_a_bytes={target_to_phase_a_bytes} target_to_phase_a_chunks={target_to_phase_a_chunks}" + ); +} + +// ── Lifecycle ───────────────────────────────────────────────────────────────── + +/// Wait for the target process to exit or a "shutdown" control-channel +/// request to arrive (see `handle_control_request`), whichever comes first. +/// Any active dynamic relay bridges are tokio tasks in this same process, so +/// `std::process::exit` below tears them down too -- no separate stop signal +/// needed. +async fn run_lifecycle(mut child: tokio::process::Child, shutdown_rx: oneshot::Receiver<()>) { + tokio::select! { + status = child.wait() => { + let code = status.map_or(1, |s| s.code().unwrap_or(1)); + eprintln!("[openshell-supervisor-relay] target exited with code {code}"); + std::process::exit(code); + } + _ = shutdown_rx => { + eprintln!("[openshell-supervisor-relay] shutdown request -- stopping"); + let _ = child.kill().await; + let _ = child.wait().await; + eprintln!("[openshell-supervisor-relay] done"); + std::process::exit(0); + } + } +} + +#[cfg(test)] +mod forward_connect_tests { + use super::connect_forward_target; + use std::io::{Error, ErrorKind}; + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::time::Duration; + + #[tokio::test(start_paused = true)] + async fn forward_connect_returns_success_without_retry_delay() { + let start = tokio::time::Instant::now(); + let result = connect_forward_target(|| std::future::ready(Ok(42))).await; + assert_eq!(result.unwrap(), 42); + assert_eq!(start.elapsed(), Duration::ZERO); + } + + #[tokio::test(start_paused = true)] + async fn forward_connect_retries_refused_connections_and_preserves_last_error() { + let mut attempts = 0; + let start = tokio::time::Instant::now(); + let result = connect_forward_target(|| { + attempts += 1; + std::future::ready(Err::<(), _>(Error::new( + ErrorKind::ConnectionRefused, + attempts.to_string(), + ))) + }) + .await; + assert_eq!(attempts, 5); + assert_eq!(result.unwrap_err().kind(), ErrorKind::ConnectionRefused); + assert_eq!(start.elapsed(), Duration::from_millis(1200)); + } + + #[tokio::test(start_paused = true)] + async fn forward_connect_cancels_stuck_attempts_before_driver_timeout() { + struct Cancelled(Arc); + impl Drop for Cancelled { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::SeqCst); + } + } + let cancelled = Arc::new(AtomicUsize::new(0)); + let start = tokio::time::Instant::now(); + let result = connect_forward_target(|| { + let guard = Cancelled(cancelled.clone()); + async move { + let _guard = guard; + std::future::pending::>().await + } + }) + .await; + assert_eq!(result.unwrap_err().kind(), ErrorKind::TimedOut); + assert_eq!(cancelled.load(Ordering::SeqCst), 5); + assert_eq!(start.elapsed(), Duration::from_millis(6200)); + assert!(start.elapsed() < Duration::from_secs(10)); + } + + #[tokio::test(start_paused = true)] + async fn forward_connect_recovers_after_a_stuck_attempt() { + let mut attempts = 0; + let start = tokio::time::Instant::now(); + let result = connect_forward_target(|| { + attempts += 1; + let attempt = attempts; + async move { + if attempt == 1 { + std::future::pending::<()>().await; + } + Ok(42) + } + }) + .await; + assert_eq!(result.unwrap(), 42); + assert_eq!(attempts, 2); + assert_eq!(start.elapsed(), Duration::from_millis(1300)); + } +} diff --git a/crates/openshell-supervisor-relay/src/main.rs b/crates/openshell-supervisor-relay/src/main.rs new file mode 100644 index 0000000000..c1117b248b --- /dev/null +++ b/crates/openshell-supervisor-relay/src/main.rs @@ -0,0 +1,32 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Entry point for `openshell-supervisor-relay`. +//! +//! This is an MXC/AppContainer helper -- it only does anything useful on +//! Windows (see `imp.rs` for the real implementation and its module docs). +//! The implementation is technically portable (no Windows-specific APIs), +//! but per the `openshell-driver-mxc` platform pattern, it's gated behind +//! `cfg(target_os = "windows")` so a generic `cargo build --workspace` on +//! Linux/macOS doesn't compile the full relay implementation (and its +//! tokio/tungstenite dependency tree) for a binary those platforms never +//! run. Non-Windows builds get this minimal stub instead, purely so +//! workspace membership (`members = ["crates/*"]`) keeps working everywhere. + +#[cfg(target_os = "windows")] +mod imp; + +#[cfg(target_os = "windows")] +#[tokio::main] +async fn main() -> anyhow::Result<()> { + imp::run().await +} + +#[cfg(not(target_os = "windows"))] +fn main() { + eprintln!( + "openshell-supervisor-relay is a Windows-only MXC ProcessContainer/AppContainer helper; \ + it is not usable on this platform." + ); + std::process::exit(1); +} diff --git a/crates/openshell-supervisor-relay/tests/control_channel_contract.rs b/crates/openshell-supervisor-relay/tests/control_channel_contract.rs new file mode 100644 index 0000000000..1f3fb51464 --- /dev/null +++ b/crates/openshell-supervisor-relay/tests/control_channel_contract.rs @@ -0,0 +1,666 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Black-box integration coverage for `openshell-supervisor-relay`'s +//! control-channel wire contract (see `src/imp.rs`'s module docs for the +//! protocol itself). +//! +//! Each test spawns the real compiled `openshell-supervisor-relay.exe` +//! (via `CARGO_BIN_EXE_...`) as an ordinary child process -- no `wxc-exec`, +//! no `AppContainer`, no MXC involved -- and drives it over its actual +//! stdin/stdout JSON protocol, exactly as `openshell-driver-mxc`'s +//! `driver.rs` and `control_channel.rs` do in production. This exercises +//! the real launch handshake, target-ready ordering, shutdown semantics, +//! and the relay-auth/forward bridging protocol end to end, without +//! requiring a live Windows `AppContainer` host. +//! +//! What this file deliberately does NOT cover: the `ProcessContainer` +//! stop/delete lifecycle as driven by `openshell-driver-mxc`'s +//! `driver.rs` (that needs a real `wxc-exec`/`AppContainer`, or a much +//! larger mock of the whole MXC invoker -- exercised today by +//! `run-openclaw-forward-test.ps1` / `run-ws-agent-test.ps1` against real +//! hardware instead) and the relay-listener half of the auth handshake +//! (`openshell-driver-mxc/src/relay.rs`'s `relay_task`, which has its own +//! unit-testable pieces but isn't exercised here). This file only tests +//! `openshell-supervisor-relay`'s side of the contract, standing in for +//! the relay listener with a small hand-rolled WS server per test. +//! +//! Windows-only, like the binary under test: gated on the whole file via +//! `#![cfg(windows)]` so nothing here (including the dev-dependencies +//! pulled in for it) affects non-Windows builds at all. + +#![cfg(windows)] + +use base64::Engine; +use futures::{SinkExt, StreamExt}; +use serde_json::{Value, json}; +use std::collections::HashSet; +use std::process::Stdio; +use std::time::Duration; +use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader, Lines}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::process::{Child, ChildStdin, ChildStdout, Command}; +use tokio_tungstenite::WebSocketStream; +use tokio_tungstenite::tungstenite::Message; + +const TIMEOUT: Duration = Duration::from_secs(10); + +/// A running `openshell-supervisor-relay.exe`, with its stdin/stdout wired +/// up as a JSON control channel the same way the driver uses them. +struct RelayProcess { + child: Child, + stdin: ChildStdin, + lines: Lines>, + // `None` for `spawn()` (stderr is discarded there -- see its doc + // comment). `spawn_capturing_stderr()` populates this so a test can + // deterministically wait for a specific diagnostic line instead of + // guessing a sleep duration. + stderr_lines: Option>, +} + +impl RelayProcess { + /// Spawn the real binary. `target_port` is the CLI arg the binary + /// expects (its own liveness-check port for whatever `launch` later + /// starts) -- irrelevant to tests that never send `launch`. + async fn spawn(target_port: u16) -> Self { + let mut child = Command::new(env!("CARGO_BIN_EXE_openshell-supervisor-relay")) + .arg(target_port.to_string()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + // Not read by these tests -- null rather than piped-and-ignored + // so the child can never block on a full stderr pipe buffer. + .stderr(Stdio::null()) + .kill_on_drop(true) + .spawn() + .expect("spawn openshell-supervisor-relay.exe (build it first: cargo build -p openshell-supervisor-relay)"); + let stdin = child.stdin.take().expect("piped stdin"); + let stdout = child.stdout.take().expect("piped stdout"); + Self { + child, + stdin, + lines: BufReader::new(stdout).lines(), + stderr_lines: None, + } + } + + /// Like `spawn`, but pipes stderr instead of discarding it, so a test + /// can wait for a specific diagnostic line via `wait_for_stderr_line`. + /// A background task drains it continuously for the process's entire + /// lifetime (forwarding every line over an unbounded channel) -- reading + /// only until the sought-after line arrives and then stopping (as an + /// earlier version of this helper did) leaves the pipe unread from then + /// on; this process's own ongoing diagnostic output (plus anything the + /// launched target itself prints, forwarded through it) then fills the + /// OS pipe buffer and makes its *next* `eprintln!` block synchronously + /// forever -- including ones on the exact shutdown path a test wants to + /// observe. Confirmed by hand: without continuous draining, this + /// deadlocks the relay process itself, not just this test. + async fn spawn_capturing_stderr(target_port: u16) -> Self { + let mut child = Command::new(env!("CARGO_BIN_EXE_openshell-supervisor-relay")) + .arg(target_port.to_string()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .expect("spawn openshell-supervisor-relay.exe (build it first: cargo build -p openshell-supervisor-relay)"); + let stdin = child.stdin.take().expect("piped stdin"); + let stdout = child.stdout.take().expect("piped stdout"); + let stderr = child.stderr.take().expect("piped stderr"); + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + tokio::spawn(async move { + let mut lines = BufReader::new(stderr).lines(); + while let Ok(Some(line)) = lines.next_line().await { + if tx.send(line).is_err() { + break; + } + } + }); + Self { + child, + stdin, + lines: BufReader::new(stdout).lines(), + stderr_lines: Some(rx), + } + } + + /// Consume forwarded stderr lines (discarding non-matching ones) until + /// one contains `pattern`, or `timeout` elapses. `timeout` is + /// deliberately a caller argument, not the shared `TIMEOUT` constant -- + /// spawning a real OS process for this to wait on can occasionally take + /// far longer than this file's other, purely protocol-level waits + /// (observed on this host: real-time AV scanning stalling + /// `CreateProcess` well past 10s, unrelated to anything this binary + /// controls). + async fn wait_for_stderr_line(&mut self, pattern: &str, timeout: Duration) { + let rx = self + .stderr_lines + .as_mut() + .expect("wait_for_stderr_line requires spawn_capturing_stderr"); + tokio::time::timeout(timeout, async { + loop { + match rx.recv().await { + Some(line) if line.contains(pattern) => return, + Some(_) => {} + None => { + panic!("relay stderr closed before printing a line containing {pattern:?}") + } + } + } + }) + .await + .unwrap_or_else(|_| { + panic!("timed out after {timeout:?} waiting for a stderr line containing {pattern:?}") + }); + } + + async fn next_line(&mut self) -> String { + tokio::time::timeout(TIMEOUT, self.lines.next_line()) + .await + .expect("timed out waiting for a control-channel line") + .expect("stdout read error") + .expect("relay exited before producing the expected line") + } + + async fn next_json(&mut self) -> Value { + let line = self.next_line().await; + serde_json::from_str(&line) + .unwrap_or_else(|e| panic!("non-JSON control-channel line {line:?}: {e}")) + } + + async fn send(&mut self, value: Value) { + let mut line = serde_json::to_string(&value).expect("serialize request"); + line.push('\n'); + self.stdin + .write_all(line.as_bytes()) + .await + .expect("write control-channel request"); + self.stdin + .flush() + .await + .expect("flush control-channel request"); + } + + /// Consume and validate the startup handshake event -- see + /// `control_channel::try_route_ready` on the driver side, which this + /// mirrors. + async fn expect_ready(&mut self) { + let v = self.next_json().await; + assert_eq!(v["event"], "ready"); + assert_eq!(v["protocol_version"], 2); + } + + async fn launch(&mut self, id: u64, command: &[&str]) -> Value { + self.send(json!({ + "id": id, + "op": "launch", + "data": {"command": command, "env": []}, + })) + .await; + self.next_json().await + } +} + +/// Spawn an in-process TCP echo server (a stand-in for whatever real +/// `agent_command` target would be bound to a port in production) and +/// return the port it bound. Handles concurrent connections -- each +/// accepted connection gets its own task -- so it doubles as the shared +/// target for the concurrent-forwards test. +async fn spawn_echo_target() -> u16 { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + tokio::spawn(async move { + loop { + let Ok((mut sock, _)) = listener.accept().await else { + break; + }; + tokio::spawn(async move { + let mut buf = [0u8; 4096]; + loop { + match sock.read(&mut buf).await { + Ok(0) | Err(_) => break, + Ok(n) => { + if sock.write_all(&buf[..n]).await.is_err() { + break; + } + } + } + } + }); + } + }); + port +} + +/// Stand-in for `openshell-driver-mxc/src/relay.rs`'s Phase A listener: +/// accept one TCP connection, complete the WS upgrade, and require the +/// first message to be exactly `AUTH:` -- matching what +/// `run_relay_bridge` in `imp.rs` sends. Panics (failing the test) if +/// anything else arrives first, same as a real relay would just silently +/// distrust and drop the connection. +async fn accept_and_authenticate( + listener: &TcpListener, + expected_nonce: &str, +) -> WebSocketStream { + let (stream, _addr) = tokio::time::timeout(TIMEOUT, listener.accept()) + .await + .expect("timed out waiting for the relay's Phase A connection") + .expect("accept failed"); + let mut ws = tokio_tungstenite::accept_async(stream) + .await + .expect("WS upgrade failed"); + let msg = tokio::time::timeout(TIMEOUT, ws.next()) + .await + .expect("timed out waiting for the AUTH message") + .expect("relay closed before sending AUTH") + .expect("WS read error"); + let expected = format!("AUTH:{expected_nonce}"); + match msg { + Message::Text(t) if t == expected => {} + other => panic!("expected {expected:?} as the first message, got {other:?}"), + } + ws +} + +// ── Startup handshake ──────────────────────────────────────────────────── + +#[tokio::test(flavor = "multi_thread")] +async fn ready_event_reports_protocol_version() { + let mut relay = RelayProcess::spawn(0).await; + relay.expect_ready().await; +} + +// ── ping / echo (protocol sanity, no launch required) ────────────────────── + +#[tokio::test(flavor = "multi_thread")] +async fn ping_and_echo_round_trip() { + let mut relay = RelayProcess::spawn(0).await; + relay.expect_ready().await; + + relay.send(json!({"id": 1, "op": "ping"})).await; + assert_eq!( + relay.next_json().await, + json!({"id": 1, "ok": true, "data": "pong"}) + ); + + relay + .send(json!({"id": 2, "op": "echo", "data": {"x": 1, "y": "two"}})) + .await; + let resp = relay.next_json().await; + assert_eq!(resp["id"], 2); + assert_eq!(resp["ok"], true); + assert_eq!(resp["data"], json!({"x": 1, "y": "two"})); +} + +// ── launch: success and failure ───────────────────────────────────────── + +#[tokio::test(flavor = "multi_thread")] +async fn launch_fails_fast_when_command_is_empty() { + let mut relay = RelayProcess::spawn(0).await; + relay.expect_ready().await; + + let resp = relay.launch(1, &[]).await; + assert_eq!(resp["id"], 1); + assert_eq!(resp["ok"], false); + assert!( + resp["error"].as_str().unwrap().contains("non-empty array"), + "unexpected error message: {resp}" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn launch_success_then_target_ready_ordering() { + // Reserve a free port, then launch a target that binds exactly it -- + // small a-priori race (something else could steal the port between the + // bind-and-drop below and the launch), acceptable for a test. + let port = { + let l = TcpListener::bind("127.0.0.1:0").await.unwrap(); + l.local_addr().unwrap().port() + }; + let script = format!( + "$l=[System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback,{port}); \ + $l.Start(); Start-Sleep -Seconds 30" + ); + + let mut relay = RelayProcess::spawn(port).await; + relay.expect_ready().await; + + let ack = relay + .launch( + 1, + &[ + "powershell", + "-NoProfile", + "-NonInteractive", + "-Command", + &script, + ], + ) + .await; + assert_eq!(ack["id"], 1); + assert_eq!(ack["ok"], true, "launch ack: {ack}"); + + // The "launch" response only confirms the command/env arrived -- the + // unsolicited "target_ready" event (no correlation id) is the actual + // liveness confirmation once the port readiness poll succeeds, and it + // must not have been sent already (it can't have been: nothing before + // this point in the protocol lets the spawner know the port bound). + // Reading it as the very next line asserts the ordering directly. + let target_ready = relay.next_json().await; + assert_eq!(target_ready["event"], "target_ready"); + assert!( + target_ready.get("id").is_none(), + "target_ready must not carry a correlation id" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn shutdown_is_acked_and_the_process_exits() { + let port = { + let l = TcpListener::bind("127.0.0.1:0").await.unwrap(); + l.local_addr().unwrap().port() + }; + let script = format!( + "$l=[System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback,{port}); \ + $l.Start(); Start-Sleep -Seconds 30" + ); + + let mut relay = RelayProcess::spawn(port).await; + relay.expect_ready().await; + let ack = relay + .launch( + 1, + &[ + "powershell", + "-NoProfile", + "-NonInteractive", + "-Command", + &script, + ], + ) + .await; + assert_eq!(ack["ok"], true); + let target_ready = relay.next_json().await; + assert_eq!(target_ready["event"], "target_ready"); + + relay.send(json!({"id": 2, "op": "shutdown"})).await; + let ack = relay.next_json().await; + assert_eq!(ack, json!({"id": 2, "ok": true})); + + let status = tokio::time::timeout(TIMEOUT, relay.child.wait()) + .await + .expect("relay did not exit within the timeout after shutdown") + .expect("wait() failed"); + assert!(status.success(), "expected a clean exit, got {status:?}"); +} + +/// Reproduces the reported race: a "shutdown" request arriving while the +/// target is still coming up (here, one that never binds the port at all) +/// must stop this process promptly, not leave it waiting out the full +/// port-readiness budget (~300s worst case, see `wait_for_port_ready`) +/// before ever observing the shutdown that `run_control_channel` already +/// acknowledged. Without racing that wait against shutdown, the final +/// `child.wait()` below would time out instead of completing within +/// `SHUTDOWN_TIMEOUT`. +/// +/// Waits for the relay's own "waiting for target on ..." stderr line +/// before sending shutdown, rather than sending it right after the launch +/// ack -- the ack fires as soon as the request is parsed, before this +/// process's own `spawn_target()` (a plain `CreateProcess` call) has +/// necessarily completed, and *that* call is a separate, pre-existing +/// source of multi-second-to-multi-minute stalls on this host (real-time +/// AV scanning a freshly-launched process) that this fix does not -- and is +/// not meant to -- address. Anchoring on that line instead isolates the +/// assertion to the one thing this fix actually changed: how promptly a +/// shutdown arriving *during the port-readiness poll itself* is observed +/// and acted on. +/// +/// Does not close `stdin` before waiting on process exit -- on purpose, +/// matching driver.rs, which never closes its end of the control channel +/// before observing the relay exit either. An earlier version of this +/// fix returned normally from `run()` on this path instead of calling +/// `std::process::exit`; `run_control_channel` loops on +/// `stdin.next_line()` for the process's entire lifetime, so with `stdin` +/// still open (as it always is against a real driver) that task -- and so +/// the whole process -- stayed alive indefinitely even after `run()` had +/// already returned. Confirmed by hand with internal timestamps: the fix +/// fired and `run()` returned within single-digit milliseconds while the +/// process, observed externally, never exited. This test would have +/// caught that. +#[tokio::test(flavor = "multi_thread")] +async fn shutdown_during_port_wait_stops_promptly() { + // Generous: covers this host's observed CreateProcess stalls (up to + // ~60s) plus real margin, not just the fast path. + const SPAWN_TIMEOUT: Duration = Duration::from_mins(2); + // Well under the ~300s port-readiness budget this fix exists to avoid + // waiting out -- generous only relative to `TIMEOUT`, since a bare + // `std::process::exit` completes in well under a second. + const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(20); + + let port = { + let l = TcpListener::bind("127.0.0.1:0").await.unwrap(); + l.local_addr().unwrap().port() + }; + + let mut relay = RelayProcess::spawn_capturing_stderr(port).await; + relay.expect_ready().await; + // A real target that never binds `port` -- port-readiness polling never + // succeeds on its own. cmd.exe rather than powershell.exe: smaller, + // simpler, and this test's target just needs to run for a while and + // never bind `port`, not do anything powershell-specific. + let ack = relay + .launch(1, &["cmd", "/c", "timeout /t 300 /nobreak >nul"]) + .await; + assert_eq!(ack["ok"], true, "launch ack: {ack}"); + + // Confirms spawn_target() has returned and wait_for_port_ready has + // started -- only from this point on is the fix under test actually + // in play. + relay + .wait_for_stderr_line("waiting for target on", SPAWN_TIMEOUT) + .await; + + relay.send(json!({"id": 2, "op": "shutdown"})).await; + let ack = relay.next_json().await; + assert_eq!(ack, json!({"id": 2, "ok": true})); + + let status = tokio::time::timeout(SHUTDOWN_TIMEOUT, relay.child.wait()) + .await + .expect( + "relay did not exit promptly after shutdown during port-wait \ + (see imp.rs's port-wait/shutdown race)", + ) + .expect("wait() failed"); + assert!(status.success(), "expected a clean exit, got {status:?}"); +} + +// ── forward: authenticated relay association + byte bridging ─────────────── + +#[tokio::test(flavor = "multi_thread")] +async fn control_channel_forward_round_trips_bytes_without_host_callback_networking() { + let target_port = spawn_echo_target().await; + let mut relay = RelayProcess::spawn(0).await; + relay.expect_ready().await; + let session_id = "a".repeat(64); + + relay + .send(json!({ + "id": 1, "op": "forward_open", + "data": {"session_id": session_id, "target_port": target_port}, + })) + .await; + assert_eq!(relay.next_json().await, json!({"id": 1, "ok": true})); + + let payload = b"stdio-forward-round-trip"; + relay + .send(json!({ + "id": 2, "op": "forward_write", + "data": { + "session_id": session_id, + "bytes": base64::engine::general_purpose::STANDARD.encode(payload), + }, + })) + .await; + assert_eq!(relay.next_json().await, json!({"id": 2, "ok": true})); + + let echoed = loop { + relay + .send(json!({ + "id": 3, "op": "forward_read", "data": {"session_id": session_id}, + })) + .await; + let response = relay.next_json().await; + let encoded = response["data"]["bytes"].as_str().unwrap(); + if !encoded.is_empty() { + break base64::engine::general_purpose::STANDARD + .decode(encoded) + .unwrap(); + } + }; + assert_eq!(echoed, payload); + + relay + .send(json!({ + "id": 4, "op": "forward_close", "data": {"session_id": session_id}, + })) + .await; + assert_eq!(relay.next_json().await, json!({"id": 4, "ok": true})); +} + +#[tokio::test(flavor = "multi_thread")] +async fn forward_with_correct_auth_bridges_bytes_both_directions() { + let target_port = spawn_echo_target().await; + let relay_listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let relay_addr = relay_listener.local_addr().unwrap(); + + let mut relay = RelayProcess::spawn(0).await; + relay.expect_ready().await; + + let nonce = "test-nonce-abc123"; + relay + .send(json!({ + "id": 5, + "op": "forward", + "data": {"relay_addr": relay_addr.to_string(), "target_port": target_port, "nonce": nonce}, + })) + .await; + + // Both sides of the handshake only make progress if driven + // concurrently: the relay's forward ack doesn't arrive until its WS + // client connection to us completes, which needs us to actually + // accept it. + let (mut ws, ack) = tokio::join!( + accept_and_authenticate(&relay_listener, nonce), + relay.next_json() + ); + assert_eq!(ack["id"], 5); + assert_eq!(ack["ok"], true, "forward ack: {ack}"); + + ws.send(Message::Text("SESSION_START".into())) + .await + .unwrap(); + let payload = b"hello over the bridge".to_vec(); + ws.send(Message::Binary(payload.clone().into())) + .await + .unwrap(); + + let echoed = tokio::time::timeout(TIMEOUT, ws.next()) + .await + .expect("timed out waiting for the echoed bytes") + .expect("WS closed before echoing") + .expect("WS read error"); + match echoed { + Message::Binary(b) => assert_eq!(b.as_ref(), payload.as_slice()), + other => panic!("expected a Binary echo, got {other:?}"), + } + + ws.send(Message::Text("SESSION_END".into())).await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn concurrent_forwards_do_not_cross_talk() { + let target_port = spawn_echo_target().await; + let listener_a = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr_a = listener_a.local_addr().unwrap(); + let listener_b = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr_b = listener_b.local_addr().unwrap(); + + let mut relay = RelayProcess::spawn(0).await; + relay.expect_ready().await; + + relay + .send(json!({ + "id": 1, "op": "forward", + "data": {"relay_addr": addr_a.to_string(), "target_port": target_port, "nonce": "nonce-a"}, + })) + .await; + relay + .send(json!({ + "id": 2, "op": "forward", + "data": {"relay_addr": addr_b.to_string(), "target_port": target_port, "nonce": "nonce-b"}, + })) + .await; + + let (ws_a, ws_b) = tokio::join!( + accept_and_authenticate(&listener_a, "nonce-a"), + accept_and_authenticate(&listener_b, "nonce-b"), + ); + let (mut ws_a, mut ws_b) = (ws_a, ws_b); + + let ack1 = relay.next_json().await; + let ack2 = relay.next_json().await; + assert!( + ack1["ok"] == true && ack2["ok"] == true, + "acks: {ack1} / {ack2}" + ); + let ids: HashSet<_> = [ack1["id"].as_u64(), ack2["id"].as_u64()] + .into_iter() + .collect(); + assert_eq!( + ids, + HashSet::from([Some(1), Some(2)]), + "both forward requests must be acked exactly once" + ); + + ws_a.send(Message::Text("SESSION_START".into())) + .await + .unwrap(); + ws_b.send(Message::Text("SESSION_START".into())) + .await + .unwrap(); + ws_a.send(Message::Binary(b"payload-A".to_vec().into())) + .await + .unwrap(); + ws_b.send(Message::Binary(b"payload-B".to_vec().into())) + .await + .unwrap(); + + let (echo_a, echo_b) = tokio::join!( + tokio::time::timeout(TIMEOUT, ws_a.next()), + tokio::time::timeout(TIMEOUT, ws_b.next()), + ); + match echo_a + .expect("timeout on A") + .expect("closed on A") + .expect("read error on A") + { + Message::Binary(b) => assert_eq!( + b.as_ref(), + b"payload-A", + "session A must not see session B's bytes" + ), + other => panic!("unexpected message on A: {other:?}"), + } + match echo_b + .expect("timeout on B") + .expect("closed on B") + .expect("read error on B") + { + Message::Binary(b) => assert_eq!( + b.as_ref(), + b"payload-B", + "session B must not see session A's bytes" + ), + other => panic!("unexpected message on B: {other:?}"), + } +} diff --git a/crates/openshell-tui/src/lib.rs b/crates/openshell-tui/src/lib.rs index 3d451e13c8..a35e5f9181 100644 --- a/crates/openshell-tui/src/lib.rs +++ b/crates/openshell-tui/src/lib.rs @@ -822,6 +822,7 @@ async fn handle_sandbox_delete(app: &mut App, tx: mpsc::UnboundedSender) allow_missing: true, name: sandbox_name, workspace_scope: Some(named_workspace_scope(app.selected_sandbox_workspace())), + ..Default::default() }; match app.client.delete_sandbox(req).await { Ok(response) => { diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index a8db2d4d51..50b73c2104 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -645,7 +645,7 @@ The gateway verifies supervisor JWT-SVIDs with JWT bundles fetched from the SPIFFE Workload API, so this validation path does not require gateway access to the SPIRE OIDC discovery endpoint or its TLS CA. -### MXC +### MXC audit configuration The MXC driver runs Windows workloads through `wxc-exec`. Enable ETW auditing to map Windows Sandboxing provider events into the gateway's OCSF stream. @@ -888,9 +888,51 @@ OpenShell sends no override and Podman applies its runtime-selected profile. The setting applies to the workload container; the supervisor retains Podman's 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. + +```toml +[openshell] +version = 1 + +[openshell.gateway] +bind_address = "127.0.0.1:17670" +log_level = "info" +compute_drivers = ["mxc"] + +[openshell.drivers.mxc] +wxc_exec_path = "C:\\mxc\\wxc-exec.exe" +# process_container (default) or isolation_session. +backend = "process_container" +default_configuration_id = "composable" +pc_least_privilege = false +pc_capabilities = [] +# Command executed inside the MXC sandbox. +agent_command = ["cmd", "/c", "echo hello > C:\\work\\demo\\hello.txt"] +agent_cwd = "C:\\work\\demo" +# Optional non-secret operator environment. Bare keys read the gateway host +# environment; KEY=VALUE entries pass through literally. Do not put secrets here. +agent_env = ["MY_NON_SECRET_SETTING=enabled"] +share_dir = "C:\\work\\demo" +# Pattern C governed egress. MXC 0.8 denies direct Internet egress and permits +# host loopback; proxy-aware clients receive HTTP_PROXY/HTTPS_PROXY and the host +# CONNECT proxy enforces the trimmed network policy. Requires process_container. +egress_proxy = false +egress_proxy_addr = "" +debug = false +etw_audit = false +``` + +Set `egress_proxy = true` with `egress_proxy_addr = "127.0.0.1:18080"` to enable the Windows Pattern C split. The address must be a `127.0.0.1:PORT` socket. The driver allocates a unique ephemeral port per sandbox, injects that listener through proxy environment variables, and stages the public proxy CA beneath `share_dir/.openshell-proxy//`. CA staging requires a non-empty `share_dir`; sandbox-specific subdirectories prevent concurrent sandboxes from overwriting each other's trust files. MXC denies direct Internet egress but allows `127.0.0.1/32`; this permits dynamic forwarding but also means the sandbox can reach unrelated host services bound to loopback. + +MXC rejects policies containing `network_middlewares` before launch because this host-proxy path does not receive the gateway middleware registry. + +Supply the workload command and optional working directory through the sandbox's `mxc` driver configuration, for example `{"mxc":{"command":["cmd","/c","echo hello"],"cwd":"C:\\work"}}`. + 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 to each MXC sandbox, but the listener does not authenticate its TCP peer. Treat Windows processes that can connect directly to gateway-owned loopback ports as part of the trusted host boundary. +The driver assigns a distinct loopback listener port and proxy credentials to each MXC sandbox. The host proxy rejects missing, invalid, duplicate, or cross-sandbox proxy credentials before forwarding. This authenticates the sandbox's proxy access; it does not isolate unrelated host-loopback services or distinguish processes within the same sandbox. Treat the gateway host and processes that can read the sandbox credentials as trusted. ### MicroVM diff --git a/docs/sandboxes/manage-sandboxes.mdx b/docs/sandboxes/manage-sandboxes.mdx index 5415d993b0..255bbf1bbf 100644 --- a/docs/sandboxes/manage-sandboxes.mdx +++ b/docs/sandboxes/manage-sandboxes.mdx @@ -256,7 +256,7 @@ Create a sandbox from a template: openshell sandbox create --template gpu-kata --provider github -- claude ``` -The `--template` flag cannot be combined with inline workload flags such as `--from`, `--cpu`, `--memory`, `--gpu`, `--env`, or `--driver-config-json`. Put those values on the template instead. Create-time policy and provider attachments remain part of the sandbox request, so each sandbox can keep its own access boundary. +The `--template` flag cannot be combined with inline workload flags such as `--from`, `--cpu`, `--memory`, `--gpu`, `--env`, `--env-from`, or `--driver-config-json`. Put those values on the template instead. Create-time policy and provider attachments remain part of the sandbox request, so each sandbox can keep its own access boundary. Inspect and manage templates: @@ -373,6 +373,16 @@ openshell sandbox create --env API_KEY=sk-test --env DEBUG=1 -- my-agent Variables set with `--env` are available to all processes in the sandbox, including interactive shells and exec commands. +Use `--env-from KEY[=ENVVAR]` when the value should come from the CLI process environment instead of appearing in the CLI process arguments. If `ENVVAR` is omitted, OpenShell reads `KEY`: + +```shell +export SESSION_TOKEN="..." +openshell sandbox create --env-from SESSION_TOKEN -- my-agent +openshell sandbox create --env-from AGENT_TOKEN=SESSION_TOKEN -- my-agent +``` + +`--env-from` changes only how the CLI receives the value. The resulting variable is still available to processes in the sandbox, just like `--env`. + When an `--env` key looks like a credential — a known provider variable, or a name whose underscore-separated segments include a credential word such as `TOKEN`, `SECRET`, `PASSWORD`, `CREDENTIAL`, `API_KEY`, `ACCESS_KEY`, or `SECRET_KEY` (for example `DB_TOKEN` or `MY_ACCESS_KEY`) — `sandbox create` prints a non-blocking warning. Matching is on whole segments, so unrelated names like `TOKENIZERS_PARALLELISM` or `PASSWORDLESS_LOGIN` do not warn. The agent inside the sandbox can read plain environment values directly, so to hide a secret from the agent, attach it through a [profile-backed provider](/providers/profiles) with `--provider` instead. Suppress the warning with `--no-credential-warnings`. Detection uses the key name only; values are never inspected or printed. You can also set per-command environment variables with `sandbox exec`: @@ -756,6 +766,20 @@ When a multi-sandbox delete fails for one entry, the CLI reports that sandbox's failure and continues with the remaining names. The command exits with an error after it attempts every requested deletion if any entry failed. +Automation that already observed a sandbox's immutable identity can prevent a +same-name replacement from being deleted: + +```shell +openshell sandbox delete my-sandbox \ + --expected-id \ + --expected-resource-version +``` + +Identity preconditions require exactly one sandbox name, and a resource version +may be supplied only with the immutable ID. The gateway returns an `ABORTED` +error without changing the sandbox or calling its compute driver when either +value no longer matches. + ## Sandbox Lifecycle Every sandbox moves through a defined set of phases: diff --git a/docs/sandboxes/policies.mdx b/docs/sandboxes/policies.mdx index 028724d3b8..e0d9dda7a6 100644 --- a/docs/sandboxes/policies.mdx +++ b/docs/sandboxes/policies.mdx @@ -12,7 +12,7 @@ Use this page to apply and iterate policy changes on running sandboxes. For a fu ## Policy Structure -A policy has static sections `filesystem_policy`, `landlock`, `process`, and `ui` that are locked at sandbox creation, and dynamic `network_policies` and `network_middlewares` sections that are hot-reloadable on a running sandbox. +A policy has static sections `filesystem_policy`, `landlock`, `process`, and `ui` that are locked at sandbox creation. `network_policies` and `network_middlewares` are dynamic schema sections, but live updates work only on compute drivers that support runtime policy reload. MXC rejects live policy replacement and merge updates, so recreate the sandbox there. ```yaml wordWrap showLineNumbers={false} version: 1 diff --git a/proto/openshell.proto b/proto/openshell.proto index 3492f70c38..7f79d2d3d9 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -1337,6 +1337,16 @@ message DeleteSandboxRequest { // Succeed with ALREADY_ABSENT if the target is missing. Does not wait for // asynchronous cleanup and does not suppress authorization or parent errors. bool allow_missing = 4; + // Optional immutable sandbox identity precondition. When non-empty, the + // gateway rejects the request with ABORTED unless the currently resolved + // sandbox has this exact metadata ID. The check is repeated under the + // lifecycle lock immediately before any delete mutation. + string expected_sandbox_id = 5; + // Optional optimistic-concurrency precondition. Requires + // expected_sandbox_id. When non-zero, the gateway rejects the request with + // ABORTED unless the sandbox's current resource version matches this value + // immediately before any delete mutation. + uint64 expected_resource_version = 6; } // Stop sandbox request. diff --git a/skills/openshell-cli/SKILL.md b/skills/openshell-cli/SKILL.md index f1ce300beb..88477b260f 100644 --- a/skills/openshell-cli/SKILL.md +++ b/skills/openshell-cli/SKILL.md @@ -408,6 +408,11 @@ openshell logs my-sandbox --since 5m openshell sandbox delete my-sandbox openshell sandbox delete sandbox-1 sandbox-2 sandbox-3 # Multiple at once openshell sandbox delete --all + +# Fail closed if the observed sandbox was replaced or changed +openshell sandbox delete my-sandbox \ + --expected-id \ + --expected-resource-version ``` `deletion accepted` means cleanup is still pending. Inspect the sandbox until @@ -415,6 +420,10 @@ it disappears before assuming completion. An already-absent sandbox succeeds; missing workspaces and authorization failures remain errors. Do not blindly retry by name if another process might have recreated that name. +Identity preconditions are valid only for one named sandbox, and a resource +version requires the immutable ID. A mismatch returns `ABORTED` before +OpenShell mutates gateway state or calls the compute driver. + ### Stop and start sandboxes Use stop to halt compute while retaining the sandbox and its persistent diff --git a/tasks/scripts/windows-msvc.ps1 b/tasks/scripts/windows-msvc.ps1 index 0ef556fe00..7d8714039f 100644 --- a/tasks/scripts/windows-msvc.ps1 +++ b/tasks/scripts/windows-msvc.ps1 @@ -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 $Z3WorkspaceFeatures" ` + -CargoArgs "cargo build --release --target $RustTarget --bin openshell-gateway --bin openshell --bin openshell-supervisor-relay $Z3WorkspaceFeatures" ` -LogName "build-$RustTarget-release.log" } @@ -542,7 +542,7 @@ function Invoke-UnsupportedContractTests([string] $RustTarget) { foreach ($test in $tests) { Invoke-VsCargo ` -RustTarget $RustTarget ` - -CargoArgs "cargo test -p openshell-gateway --target $RustTarget $test $Z3GatewayFeatures" ` + -CargoArgs "cargo test -p openshell-gateway --target $RustTarget $test $Z3WorkspaceFeatures" ` -LogName "test-$RustTarget-unsupported-$test.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")) { + foreach ($binary in @("openshell-gateway.exe", "openshell.exe", "openshell-supervisor-relay.exe")) { $path = Join-Path $TargetDir "$rustTarget\release\$binary" if (-not (Test-Path $path)) { continue From 7ea2631e598f3771411d22a5e9d06eb974543234 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 15 Sep 2026 18:23:26 -0700 Subject: [PATCH 17/26] fix(mxc): keep workload config sandbox-scoped Signed-off-by: Prekshi Vyas --- crates/openshell-driver-mxc/README.md | 24 +-- .../examples/README-openclaw-forward.txt | 11 +- .../e2e-policies/openclaw-gateway.yaml | 2 +- .../examples/e2e-policies/ws-agent.yaml | 4 +- .../examples/mxc-openclaw-gateway.toml | 67 +------ .../examples/mxc-openclaw-isolation.toml | 36 +--- .../examples/mxc-openclaw-localnet.toml | 26 +-- .../examples/mxc-ws-gateway.toml | 25 +-- .../examples/openclaw-capture.mjs | 4 +- .../examples/run-openclaw-forward-test.ps1 | 58 ++++-- .../examples/run-ws-agent-test.ps1 | 26 ++- crates/openshell-driver-mxc/src/driver.rs | 167 +++++++----------- docs/reference/gateway-config.mdx | 11 +- 13 files changed, 154 insertions(+), 307 deletions(-) diff --git a/crates/openshell-driver-mxc/README.md b/crates/openshell-driver-mxc/README.md index 4edf3935b7..b8c52f9898 100644 --- a/crates/openshell-driver-mxc/README.md +++ b/crates/openshell-driver-mxc/README.md @@ -49,7 +49,7 @@ default_configuration_id = "composable" pc_least_privilege = false pc_capabilities = [] # processContainer only: launch openshell-supervisor-relay instead of -# agent_command/legacy command directly, giving the driver a control +# 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). @@ -57,27 +57,13 @@ 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) + agent_env; pc_minimal_env -# starts from an EMPTY env (agent_env only) for runtimes that choke on an -# unrecognized host env; pc_inherit_full_env is an explicit unsafe opt-in -# to the gateway host's entire environment (secrets included) + agent_env, -# ignored when pc_minimal_env is also set. +# (SYSTEMROOT/WINDIR/PATH/COMSPEC/LOCALAPPDATA); pc_minimal_env starts from an +# EMPTY env for runtimes that need a fully curated per-sandbox environment. pc_minimal_env = false -pc_inherit_full_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 -# Legacy workload settings, used only as a fallback when a sandbox's -# CreateSandbox request carries no --driver-config-json (see below) -- -# agent_command is required for a sandbox to succeed via this fallback path. -agent_command = ["cmd", "/c", "echo hello > C:\\work\\demo\\hello.txt"] -agent_cwd = "C:\\work\\demo" -# Host directory mapped read-write into the sandbox. NOT an automatic -# filesystem grant on its own -- the sandbox's SandboxPolicy is the only -# source of filesystem grants, so a policy's filesystem_policy.read_write -# must include this path explicitly for the workload to reach it. -share_dir = "C:\\work\\demo" # Pattern C governed egress. Requires backend = "process_container". egress_proxy = false egress_proxy_addr = "" @@ -97,7 +83,7 @@ openshell sandbox create --name mxc-demo --policy demo.yaml ` --driver-config-json $config --env MODE=demo --no-tty ``` -The `command` array is required and preserves Windows argument boundaries. `cwd` is optional. Per-sandbox environment variables come from the standard sandbox and template environment maps only; this path never copies values from the gateway host environment. The legacy `agent_env`/`pc_inherit_full_env` TOML fields above are a separate, gateway-wide mechanism and are the only way the gateway host's own environment reaches a sandbox -- bare `agent_env` keys opt specific host values in. 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 `share_dir`, and injects `HTTP_PROXY`/`HTTPS_PROXY` while clearing `NO_PROXY` so inherited bypass rules cannot skip policy enforcement. +The `command` array is required and preserves Windows argument boundaries. `cwd` is optional. Supply per-sandbox environment variables with `--env` or `--env-from`; gateway configuration does not carry workload commands or environment. Provider-owned keys override matching entries case-insensitively, but raw static values remain in the host proxy; MXC receives their revision-scoped placeholders. When governed egress is enabled, the driver replaces common TLS trust environment variables with paths to public proxy CA files staged under `/.openshell-proxy//`, and injects `HTTP_PROXY`/`HTTPS_PROXY` while clearing `NO_PROXY` so inherited bypass rules cannot skip policy enforcement. UI capability (Win32k syscalls, clipboard, input injection) is a `SandboxPolicy` concern, not gateway TOML -- see the Capability Matrix above and `docs/reference/policy-schema.mdx`'s `ui` section. Defaults to disabled (Win32k syscall lockdown) when a policy has no explicit `ui:` section; set `allow_graphical_ui: true` for agents that touch user32/gdi32 at startup even without opening a real window (e.g. Node.js-based targets like OpenClaw's gateway -- see `examples/e2e-policies/openclaw-gateway.yaml`). @@ -167,7 +153,7 @@ Linux-style procfs socket ownership. For HTTPS L7 inspection, the host proxy gen 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 `share_dir/.openshell-proxy/` directory. Other +under the authorized `/.openshell-proxy/` directory. Other environment modes grant the sandbox's unique public-CA directory as an internal read-write share. The directory contains only public CA certificates; the ephemeral CA private key remains in the host proxy's memory. The driver diff --git a/crates/openshell-driver-mxc/examples/README-openclaw-forward.txt b/crates/openshell-driver-mxc/examples/README-openclaw-forward.txt index 96ae4924a1..c3aa149896 100644 --- a/crates/openshell-driver-mxc/examples/README-openclaw-forward.txt +++ b/crates/openshell-driver-mxc/examples/README-openclaw-forward.txt @@ -20,7 +20,7 @@ WHAT THIS PROVES 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 agent_env onto the full inherited host environment rather than + 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). @@ -131,11 +131,10 @@ NOTES - 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 listed in - agent_env -- see the comment above that list for the (non-obvious) - minimum Windows needs just to let CreateProcessW succeed, independent of - anything Node.js-specific. mxc-openclaw-isolation.toml doesn't need this - at all: isolation_session merges agent_env onto the full host env. + only) means the sandboxed process gets ONLY the env vars passed by + run-openclaw-forward-test.ps1 to `sandbox create`. That includes the + non-obvious minimum Windows values needed for CreateProcessW, independent + of anything Node.js-specific. isolation_session does not need this mode. - The relay is entirely on-demand: nothing is listening on any fixed host port before you run `openshell forward service`, and nothing is left listening after the forward process exits. diff --git a/crates/openshell-driver-mxc/examples/e2e-policies/openclaw-gateway.yaml b/crates/openshell-driver-mxc/examples/e2e-policies/openclaw-gateway.yaml index 1b86b32d73..a41ddb21d6 100644 --- a/crates/openshell-driver-mxc/examples/e2e-policies/openclaw-gateway.yaml +++ b/crates/openshell-driver-mxc/examples/e2e-policies/openclaw-gateway.yaml @@ -13,7 +13,7 @@ # (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 -# TOML's share_dir) when -ShareDir overrides the default below. +# 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 diff --git a/crates/openshell-driver-mxc/examples/e2e-policies/ws-agent.yaml b/crates/openshell-driver-mxc/examples/e2e-policies/ws-agent.yaml index 3d17c81729..6160be6f5c 100644 --- a/crates/openshell-driver-mxc/examples/e2e-policies/ws-agent.yaml +++ b/crates/openshell-driver-mxc/examples/e2e-policies/ws-agent.yaml @@ -15,13 +15,13 @@ # TOML. # - No writes to the host filesystem. # -# share_dir (configured in mxc-ws-gateway.toml, default C:\work\openshell-mxc-ws) +# 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 (alongside the TOML's share_dir) when -AgentDir overrides +# 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 diff --git a/crates/openshell-driver-mxc/examples/mxc-openclaw-gateway.toml b/crates/openshell-driver-mxc/examples/mxc-openclaw-gateway.toml index c315125829..b85617a216 100644 --- a/crates/openshell-driver-mxc/examples/mxc-openclaw-gateway.toml +++ b/crates/openshell-driver-mxc/examples/mxc-openclaw-gateway.toml @@ -11,8 +11,8 @@ # # 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 share_dir before creating the sandbox -# (the AppContainer here can only read paths under share_dir -- see the +# 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] @@ -33,67 +33,14 @@ pc_least_privilege = false egress_proxy = true egress_proxy_addr = "127.0.0.1:18080" -# Do not seed from host env — use only the curated set in agent_env below. +# Do not seed from host env; the harness supplies a curated per-sandbox env. pc_minimal_env = true -share_dir = "C:/openshell-openclaw" -agent_cwd = "C:/openshell-openclaw" +# The workload command, cwd, and verified-minimal environment are supplied per +# sandbox by run-openclaw-forward-test.ps1 through `sandbox create`. -agent_command = [ - "C:/openshell-openclaw/node.exe", - "C:/openshell-openclaw/openclaw-capture.mjs", - "gateway", "run", "--dev", "--allow-unconfigured", - "--auth", "token", "--bind", "loopback", "--port", "18889", -] - -# Verified-minimal env set (binary-searched down from a 39-entry "copy the -# whole host env" list): each var here was confirmed load-bearing by -# stripping it and re-testing against a live sandbox. Two surprises from that -# search, worth knowing before touching this list: -# - SYSTEMROOT/WINDIR/PATH/COMSPEC alone are NOT enough. Without -# LOCALAPPDATA, CreateProcessW itself fails with ERROR_ENVVAR_NOT_FOUND -# (Win32 203) under this sandbox's appcontainer-dacl fallback tier -- -# before Node.js ever runs. This is a Windows AppContainer-profile -# requirement, not a Node.js one. -# - SYSTEMDRIVE was tested and confirmed NOT required, despite looking -# like a natural companion to SYSTEMROOT. -# Bare keys are resolved from the gateway host env at sandbox creation time. -# KEY=VALUE entries are passed verbatim. -agent_env = [ - "SYSTEMROOT", - "WINDIR", - "PATH", - "COMSPEC", - # LOCALAPPDATA redirected to share_dir: the AppContainer cannot write to the - # host user's AppData path, and OpenClaw uses LOCALAPPDATA as a fallback - # temp dir for its SQLite read-only backup worker. - "LOCALAPPDATA=C:/openshell-openclaw/local", - # Home/temp redirected to share_dir so openclaw can write state - "HOME=C:/openshell-openclaw/home", - "USERPROFILE=C:/openshell-openclaw/home", - "TEMP=C:/openshell-openclaw/temp", - "TMP=C:/openshell-openclaw/temp", - "NEMOCLAW_MXC_CAPTURE_ENTRY=C:/openshell-openclaw/runtime/node_modules/openclaw/openclaw.mjs", - "NEMOCLAW_MXC_CAPTURE_LOG=C:/openshell-openclaw/openclaw-capture.log", - # Node 22 only consults HTTP_PROXY/HTTPS_PROXY for fetch when this option - # is enabled. The driver supplies the actual per-sandbox proxy URL. - "NODE_OPTIONS=--use-env-proxy", - "NEMOCLAW_MXC_EGRESS_PROOF=1", - "NEMOCLAW_MXC_EGRESS_ALLOWED_URL=https://example.com/", - "NEMOCLAW_MXC_EGRESS_DENIED_URL=https://example.org/", - "NEMOCLAW_MXC_EGRESS_DIRECT_HOST=1.1.1.1", - "NEMOCLAW_MXC_EGRESS_LOOPBACK_PORT=29999", - # Credential-free target-side probe used only by the qualification harness. - # It records response byte counts, never response payloads. - "NEMOCLAW_MXC_CAPTURE_SELF_PROBE_PORT=18889", - # Required for a stable/known token instead of a fresh random one every - # restart. run-openclaw-forward-test.ps1 sets this in the gateway's own - # process env before launching it. - "OPENCLAW_GATEWAY_TOKEN", -] - -# Launch agent_command via the generic openshell-supervisor-relay binary -# instead of directly. The driver sends agent_command/agent_env over the +# 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 diff --git a/crates/openshell-driver-mxc/examples/mxc-openclaw-isolation.toml b/crates/openshell-driver-mxc/examples/mxc-openclaw-isolation.toml index 6faa69bd24..b81f5b5565 100644 --- a/crates/openshell-driver-mxc/examples/mxc-openclaw-isolation.toml +++ b/crates/openshell-driver-mxc/examples/mxc-openclaw-isolation.toml @@ -12,11 +12,10 @@ # # Two things ARE genuinely different from the ProcessContainer config, and # both make this one simpler: -# - No pc_minimal_env: isolation_session MERGES injected agent_env onto the +# - 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 (see mxc-openclaw-gateway.toml's -# agent_env comment for that whole story). +# 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 @@ -25,7 +24,7 @@ # 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 -# share_dir before creating the sandbox, same as the ProcessContainer path. +# the sandbox's policy-authorized working directory before creating it. [openshell.drivers.mxc] wxc_exec_path = "C:\\mxc-kit\\bin\\wxc-exec.exe" @@ -33,33 +32,8 @@ wxc_exec_path = "C:\\mxc-kit\\bin\\wxc-exec.exe" backend = "isolation_session" default_configuration_id = "composable" -share_dir = "C:/openshell-openclaw" -agent_cwd = "C:/openshell-openclaw" - -agent_command = [ - "C:/openshell-openclaw/node.exe", - "C:/openshell-openclaw/openclaw-capture.mjs", - "gateway", "run", "--dev", "--allow-unconfigured", - "--auth", "token", "--bind", "loopback", "--port", "18889", -] - -agent_env = [ - "OPENCLAW_NO_UPDATE_CHECK=1", - "NO_UPDATE_NOTIFIER=1", - "HOME=C:/openshell-openclaw/home", - "USERPROFILE=C:/openshell-openclaw/home", - "TEMP=C:/openshell-openclaw/temp", - "TMP=C:/openshell-openclaw/temp", - "NEMOCLAW_MXC_CAPTURE_ENTRY=C:/openshell-openclaw/runtime/node_modules/openclaw/openclaw.mjs", - "NEMOCLAW_MXC_CAPTURE_LOG=C:/openshell-openclaw/openclaw-capture.log", - # Credential-free target-side probe used only by the qualification harness. - # It records response byte counts, never response payloads. - "NEMOCLAW_MXC_CAPTURE_SELF_PROBE_PORT=18889", - # Required for a stable/known token instead of a fresh random one every - # restart. run-openclaw-forward-test.ps1 sets this in the gateway's own - # process env before launching it. - "OPENCLAW_GATEWAY_TOKEN", -] +# 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, diff --git a/crates/openshell-driver-mxc/examples/mxc-openclaw-localnet.toml b/crates/openshell-driver-mxc/examples/mxc-openclaw-localnet.toml index eab8f27301..f0e4c578d9 100644 --- a/crates/openshell-driver-mxc/examples/mxc-openclaw-localnet.toml +++ b/crates/openshell-driver-mxc/examples/mxc-openclaw-localnet.toml @@ -21,30 +21,8 @@ pc_allow_local_network = true # against mxc-release-binaries-v0.8.0, even though this target never # actually opens a window. -share_dir = "C:/openshell-openclaw" -agent_cwd = "C:/openshell-openclaw" - -agent_command = [ - "C:/openshell-openclaw/node.exe", - "C:/openshell-openclaw/openclaw-capture.mjs", - "gateway", "run", "--dev", "--allow-unconfigured", - "--auth", "token", "--bind", "loopback", "--port", "18889", -] - -agent_env = [ - "OPENCLAW_GATEWAY_TOKEN", - "OPENCLAW_NO_UPDATE_CHECK=1", - "NO_UPDATE_NOTIFIER=1", - "HOME=C:/openshell-openclaw/home", - "USERPROFILE=C:/openshell-openclaw/home", - "TEMP=C:/openshell-openclaw/temp", - "TMP=C:/openshell-openclaw/temp", - "NEMOCLAW_MXC_CAPTURE_ENTRY=C:/openshell-openclaw/runtime/node_modules/openclaw/openclaw.mjs", - "NEMOCLAW_MXC_CAPTURE_LOG=C:/openshell-openclaw/openclaw-capture.log", - # Credential-free target-side probe used only by the qualification harness. - # It records response byte counts, never response payloads. - "NEMOCLAW_MXC_CAPTURE_SELF_PROBE_PORT=18889", -] +# 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, diff --git a/crates/openshell-driver-mxc/examples/mxc-ws-gateway.toml b/crates/openshell-driver-mxc/examples/mxc-ws-gateway.toml index 8d0d2dda8c..afd04a44cd 100644 --- a/crates/openshell-driver-mxc/examples/mxc-ws-gateway.toml +++ b/crates/openshell-driver-mxc/examples/mxc-ws-gateway.toml @@ -4,18 +4,18 @@ # 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, share_dir, agent_cwd, agent_command, pc_relay_spawner_path) +# (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: -# agent_command = mxc-ws-agent.exe server +# 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 agent_command in openshell-supervisor-relay.exe +# 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 @@ -54,23 +54,10 @@ pc_least_privilege = false egress_proxy = true egress_proxy_addr = "127.0.0.1:18080" -# Host folder mapped read-write into the AppContainer by the policy mapper. -# Must contain mxc-ws-agent.exe and openshell-supervisor-relay.exe. -# Patched at runtime by run-ws-agent-test.ps1. -share_dir = "C:/work/openshell-mxc-ws" - -# Working directory inside the AppContainer. Must match share_dir. -# Patched at runtime by run-ws-agent-test.ps1. -agent_cwd = "C:/work/openshell-mxc-ws" - -# The target application -- launched via the spawner wrapper below, not -# directly. Patched at runtime by run-ws-agent-test.ps1. -agent_command = [ - "C:/work/openshell-mxc-ws/mxc-ws-agent.exe", - "server", -] +# The workload command and cwd are supplied per sandbox by +# run-ws-agent-test.ps1 through `sandbox create --driver-config-json`. -# Launch agent_command via the generic openshell-supervisor-relay binary. +# 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 diff --git a/crates/openshell-driver-mxc/examples/openclaw-capture.mjs b/crates/openshell-driver-mxc/examples/openclaw-capture.mjs index fd3fa41543..eb32f624c0 100644 --- a/crates/openshell-driver-mxc/examples/openclaw-capture.mjs +++ b/crates/openshell-driver-mxc/examples/openclaw-capture.mjs @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 // // openclaw-capture.mjs - generic launcher/log-capture shim for running an -// arbitrary Node.js entry point as agent_command inside an MXC +// 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 @@ -12,7 +12,7 @@ // "[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 agent_env in the gateway TOML): +// Required env vars (set via `openshell sandbox create --env`): // NEMOCLAW_MXC_CAPTURE_ENTRY absolute path to the real entry .mjs to run // (e.g. /openclaw.mjs) // NEMOCLAW_MXC_CAPTURE_LOG absolute path to append captured output to diff --git a/crates/openshell-driver-mxc/examples/run-openclaw-forward-test.ps1 b/crates/openshell-driver-mxc/examples/run-openclaw-forward-test.ps1 index 65b8eae884..6935b68754 100644 --- a/crates/openshell-driver-mxc/examples/run-openclaw-forward-test.ps1 +++ b/crates/openshell-driver-mxc/examples/run-openclaw-forward-test.ps1 @@ -19,7 +19,7 @@ # 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 agent_env onto the +# 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. @@ -79,9 +79,9 @@ param( [string] $SandboxName = "", [switch] $KeepRunning, # Which MXC backend to exercise. process_container: one-shot AppContainer, - # no inbound network capability, needs pc_minimal_env's curated agent_env + # no inbound network capability, needs pc_minimal_env's curated sandbox env # (mxc-openclaw-gateway.toml). isolation_session: persistent - # provision/start/exec session, merges agent_env onto the full host env, + # 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", @@ -242,12 +242,8 @@ try { $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 (C:/openshell-openclaw) - # as a literal in share_dir, agent_cwd, agent_command, pc_relay_spawner_path, - # and the NEMOCLAW_MXC_CAPTURE_*/HOME/TEMP agent_env entries -- not just - # wxc_exec_path. When -ShareDir overrides the default, every one of those - # needs to move too, or the sandbox ends up reading/writing the wrong - # directory while wxc_exec_path alone points at the right wxc-exec. + # 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) { @@ -314,6 +310,7 @@ try { 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 @@ -344,10 +341,9 @@ try { # 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 a bare key - # in agent_env, resolved from THIS process's env at sandbox-create time, - # so setting it here gives the sandboxed OpenClaw a stable, known token - # instead of a fresh random one every restart. + # 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 @@ -426,7 +422,41 @@ try { elseif ($delOut) { Info "sandbox pre-delete '$SandboxName': $delOut (continuing)" } else { Info "sandbox pre-delete '$SandboxName': delete exited $delCode (continuing)" } } - try { $createOut = & $cli sandbox create --name $SandboxName --policy $policyUsed --no-tty -- exit 2>&1; $createCode = $LASTEXITCODE } + $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) { diff --git a/crates/openshell-driver-mxc/examples/run-ws-agent-test.ps1 b/crates/openshell-driver-mxc/examples/run-ws-agent-test.ps1 index e9ddc0e5ba..0564d17fc5 100644 --- a/crates/openshell-driver-mxc/examples/run-ws-agent-test.ps1 +++ b/crates/openshell-driver-mxc/examples/run-ws-agent-test.ps1 @@ -49,8 +49,8 @@ param( # Path to wxc-exec.exe. Required for real runs; ignored in mock mode. [string] $WxcExecPath = "", - # Working directory the AppContainer can read/write (becomes share_dir in - # the gateway TOML). mxc-ws-agent.exe is expected alongside this script; + # 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", @@ -135,7 +135,7 @@ $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 agent_command (see mxc-ws-gateway.toml's +# 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" @@ -298,16 +298,8 @@ function Render-Toml { "wxc_exec_path = `"$(Esc $WxcExecPath)`"") } - $agentDirFwd = Fwd $AgentDir - $agentExeFwd = Fwd $agentExe $relayExeFwd = Fwd $relayExe - $t = [regex]::Replace($t, '(?m)^\s*#?\s*share_dir\s*=.*$', - "share_dir = `"$agentDirFwd`"") - $t = [regex]::Replace($t, '(?m)^\s*#?\s*agent_cwd\s*=.*$', - "agent_cwd = `"$agentDirFwd`"") - $t = [regex]::Replace($t, '(?ms)^agent_command\s*=\s*\[.*?\]', - "agent_command = [`"$agentExeFwd`", `"server`"]") $t = [regex]::Replace($t, '(?m)^\s*#?\s*pc_relay_spawner_path\s*=.*$', "pc_relay_spawner_path = `"$relayExeFwd`"") @@ -317,9 +309,8 @@ function Render-Toml { # --- Render policy (disposable copy) ------------------------------------------ # 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 AgentDir literal as the TOML's share_dir, so it needs the -# same -AgentDir substitution, or an overridden AgentDir loses its grant +# (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)) { @@ -485,6 +476,12 @@ try { 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. @@ -494,6 +491,7 @@ try { $createOut = & $cli sandbox create ` --name $sandboxName ` --policy $policyUsed ` + --driver-config-json $driverConfigJson ` --no-tty ` -- cmd.exe /c exit 0 ` 2>&1 diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index 7335db7f95..d60f3c581a 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -93,35 +93,23 @@ pub struct MxcComputeConfig { pub pc_allow_local_network: bool, /// `processContainer` only: when `true`, start with an EMPTY process env /// (not even `MINIMAL_WINDOWS_BOOTSTRAP_ENV`) instead of the safe - /// default -- only the entries in `agent_env` are passed to the process. + /// default -- only the sandbox's explicit per-request environment is + /// passed to the process. /// Use for agents like Node.js that fail with `STATUS_DLL_INIT_FAILED` /// when unrecognised host env vars are present; the caller is then /// responsible for supplying `SYSTEMROOT`/`WINDIR`/`PATH`/`COMSPEC`/ - /// `LOCALAPPDATA` themselves via `agent_env` if the agent needs them + /// `LOCALAPPDATA` through `sandbox create --env/--env-from` if needed /// (`CreateProcessW` itself won't succeed without `LOCALAPPDATA` at /// least -- see `MINIMAL_WINDOWS_BOOTSTRAP_ENV`). /// - /// Three tiers overall, safest first: this flag (`agent_env` only) -> - /// the default (`MINIMAL_WINDOWS_BOOTSTRAP_ENV` + `agent_env`) -> - /// `pc_inherit_full_env` (the gateway's entire host env + `agent_env`, - /// explicit unsafe opt-in). + /// The sandbox's explicit per-request environment is layered on top. pub pc_minimal_env: bool, - /// `processContainer` only: when `true`, seed the process env from the - /// gateway host's ENTIRE environment instead of the safe - /// `MINIMAL_WINDOWS_BOOTSTRAP_ENV` default. This hands whatever the - /// gateway process itself happens to have in its environment -- - /// including host secrets unrelated to this sandbox, e.g. API keys or - /// tokens picked up from the operator's shell -- to whatever untrusted - /// code `agent_command` runs inside the sandbox. Explicit, unsafe - /// opt-in only; ignored when `pc_minimal_env` is also set (that flag - /// wins). See `pc_minimal_env` for the full tier breakdown. - pub pc_inherit_full_env: bool, /// `processContainer` only: path to a generic spawn+relay-bridge binary /// (see the `openshell-supervisor-relay` crate). When non-empty (and /// `pc_relay_target_port != 0`), the driver launches this binary instead - /// of `agent_command` directly, sending the real `agent_command` / env + /// of the per-sandbox workload command directly, sending the command/env /// over the control channel once the spawner announces readiness (the - /// "launch" handshake) rather than writing them to `share_dir`. This + /// "launch" handshake) rather than writing them to the workload directory. This /// decouples the relay-bridging logic from the target application (e.g. /// `OpenClaw`) entirely — the target needs no awareness of the relay /// protocol. It's also what gives the driver a control channel into the @@ -129,23 +117,14 @@ pub struct MxcComputeConfig { /// `openshell forward service` bridging) depends on regardless of any /// particular port being pre-declared. pub pc_relay_spawner_path: String, - /// `processContainer` only: the TCP port `agent_command`'s target process + /// `processContainer` only: the TCP port the workload's target process /// binds, which `pc_relay_spawner_path` bridges to the gateway relay. /// Ignored unless `pc_relay_spawner_path` is set. `0` disables spawner - /// wrapping (default) — `agent_command` runs directly as before. + /// wrapping (default) — the per-sandbox command runs directly. pub pc_relay_target_port: u16, /// MXC `configurationId` for isolation session. Default: `"composable"`. /// Never use `"small"` (known OS bug). pub default_configuration_id: String, - /// Legacy gateway-wide workload command. New callers should use - /// `template.driver_config.mxc.command`. - pub agent_command: Vec, - /// Legacy gateway-wide workload directory. - pub agent_cwd: String, - /// Legacy gateway-host environment passthrough entries. - pub agent_env: Vec, - /// Legacy default working directory used when `agent_cwd` is empty. - pub share_dir: String, /// Enable Pattern-C governed egress. When true, MXC permits loopback-only /// egress, the driver injects proxy environment variables, and the host /// CONNECT proxy receives the full network policy. @@ -175,12 +154,7 @@ impl Default for MxcComputeConfig { pc_relay_target_port: 0, pc_allow_local_network: false, pc_minimal_env: false, - pc_inherit_full_env: false, default_configuration_id: crate::mxc::DEFAULT_CONFIGURATION_ID.into(), - agent_command: Vec::new(), - agent_cwd: String::new(), - agent_env: Vec::new(), - share_dir: String::new(), egress_proxy: false, egress_proxy_addr: String::new(), @@ -244,7 +218,7 @@ struct SandboxEntry { /// silently skipping the wait because the field looks empty. terminated_rx: Option>, /// Path to the shutdown signal file written by `delete_sandbox` so - /// `mxc-ws-agent.rs` (set directly as `agent_command`, no control + /// `mxc-ws-agent.rs` (set directly as the sandbox command, no control /// channel) can detect a deletion and exit cleanly. Only set for that /// case -- when spawner wrapping is active, `delete_sandbox` sends a /// `"shutdown"` control-channel request to `openshell-supervisor-relay` @@ -347,29 +321,21 @@ impl std::fmt::Debug for MxcComputeBackend { } } -fn sandbox_config( - sandbox: &DriverSandbox, - legacy: &MxcComputeConfig, -) -> Result { +fn sandbox_config(sandbox: &DriverSandbox) -> Result { let config = sandbox .spec .as_ref() .and_then(|spec| spec.template.as_ref()) - .and_then(|template| template.driver_config.as_ref()); - let config = if let Some(config) = config { + .and_then(|template| template.driver_config.as_ref()) + .ok_or_else(|| { + tonic::Status::invalid_argument( + "mxc requires template.driver_config.mxc with a non-empty command array", + ) + })?; + let config: MxcSandboxConfig = serde_json::from_value(struct_to_json_value(config)).map_err(|error| { tonic::Status::invalid_argument(format!("invalid mxc driver_config: {error}")) - })? - } else { - MxcSandboxConfig { - command: legacy.agent_command.clone(), - cwd: if legacy.agent_cwd.is_empty() { - legacy.share_dir.clone() - } else { - legacy.agent_cwd.clone() - }, - } - }; + })?; if config.command.is_empty() || config.command[0].is_empty() { return Err(tonic::Status::invalid_argument( "mxc driver_config.command must contain a non-empty executable", @@ -378,13 +344,8 @@ fn sandbox_config( Ok(config) } -fn sandbox_environment(sandbox: &DriverSandbox, legacy: &MxcComputeConfig) -> Vec { +fn sandbox_environment(sandbox: &DriverSandbox) -> Vec { let mut environment = HashMap::new(); - for entry in resolve_agent_env(&legacy.agent_env) { - if let Some((key, value)) = entry.split_once('=') { - environment.insert(key.to_string(), value.to_string()); - } - } if let Some(spec) = sandbox.spec.as_ref() { if let Some(template) = spec.template.as_ref() { environment.extend(template.environment.clone()); @@ -399,20 +360,6 @@ fn sandbox_environment(sandbox: &DriverSandbox, legacy: &MxcComputeConfig) -> Ve environment } -fn resolve_agent_env(entries: &[String]) -> Vec { - let mut resolved = Vec::with_capacity(entries.len()); - for entry in entries { - if entry.contains('=') { - resolved.push(entry.clone()); - } else if let Ok(value) = std::env::var(entry) { - resolved.push(format!("{entry}={value}")); - } else { - warn!(var = %entry, "agent_env passthrough variable not set; skipping"); - } - } - resolved -} - /// Merge provider-owned child environment values into MXC `process.env`. /// /// Provider entries win case-insensitively, matching Windows environment @@ -527,14 +474,14 @@ fn allocate_sandbox_proxy_addr( /// Minimum Windows environment variables required just for `CreateProcessW` /// / `AppContainer`-DACL process creation to succeed at all -- independent of -/// whatever runtime `agent_command` happens to be. Confirmed empirically: +/// whatever per-sandbox runtime command is selected. Confirmed empirically: /// without `LOCALAPPDATA` specifically, `CreateProcessW` itself fails with /// `ERROR_ENVVAR_NOT_FOUND` (Win32 203) under the appcontainer-dacl fallback /// tier, before the agent binary is ever reached -- a Windows `AppContainer` /// requirement, not specific to Node.js or any other agent. None of these /// are secrets, so resolving them from the gateway host is safe; this is -/// the default baseline `agent_env` layers on top of. See `pc_minimal_env` -/// / `pc_inherit_full_env` on `MxcComputeConfig` for the other two tiers. +/// the per-sandbox environment layers on top of. See `pc_minimal_env` on +/// `MxcComputeConfig` for the explicit empty-baseline option. const MINIMAL_WINDOWS_BOOTSTRAP_ENV: [&str; 5] = ["SYSTEMROOT", "WINDIR", "PATH", "COMSPEC", "LOCALAPPDATA"]; @@ -582,16 +529,16 @@ fn append_tls_env_vars(env: &mut Vec, ca_paths: Option<&(PathBuf, PathBu fn stage_tls_ca_files( ca_paths: Option<&(PathBuf, PathBuf)>, - share_dir: &str, + workload_dir: &str, sandbox_id: &str, ) -> std::io::Result> { let Some((ca_cert_path, combined_bundle_path)) = ca_paths else { return Ok(None); }; - if share_dir.trim().is_empty() { + if workload_dir.trim().is_empty() { return Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, - "share_dir must be set when staging proxy CA files", + "mxc driver_config.cwd must be set when staging proxy CA files", )); } if sandbox_id.is_empty() @@ -604,7 +551,7 @@ fn stage_tls_ca_files( "sandbox_id must be a non-empty alphanumeric, hyphen or underscore component", )); } - let target_dir = PathBuf::from(share_dir) + let target_dir = PathBuf::from(workload_dir) .join(".openshell-proxy") .join(sandbox_id); std::fs::create_dir_all(&target_dir)?; @@ -866,7 +813,7 @@ impl MxcComputeBackend { )); } } - sandbox_config(sandbox, &self.config)?; + sandbox_config(sandbox)?; Ok(()) } @@ -921,7 +868,7 @@ impl MxcComputeBackend { validate_provider_child_env_keys(provider_credentials.as_ref())?; self.validate_sandbox_fields(sandbox)?; - let sandbox_config = sandbox_config(sandbox, &self.config)?; + let sandbox_config = sandbox_config(sandbox)?; let (egress_addr, reserved_proxy_listener) = match configured_egress_addr(&self.config)? { Some(configured_addr) => { let (addr, reservation) = allocate_sandbox_proxy_addr(configured_addr).map_err( @@ -1497,11 +1444,15 @@ async fn run_lifecycle( .as_ref() .and_then(openshell_supervisor_network::host::HostProxyHandle::ca_file_paths); // A curated ProcessContainer cannot read the host's private temp folder. - // Stage only the public CA material beneath share_dir, whose AppContainer + // Stage only the public CA material beneath the per-sandbox working directory, // DACL is already granted by the policy, so HTTPS clients can authenticate // the OpenShell inspection proxy without broadening filesystem access. let agent_proxy_ca_paths = if config.pc_minimal_env && host_proxy_ca_paths.is_some() { - match stage_tls_ca_files(host_proxy_ca_paths.as_ref(), &config.share_dir, &sandbox_id) { + match stage_tls_ca_files( + host_proxy_ca_paths.as_ref(), + &sandbox_config.cwd, + &sandbox_id, + ) { Ok(paths) => paths, Err(error) => { set_failed( @@ -1550,19 +1501,12 @@ async fn run_lifecycle( }; let command_line = encode_windows_command_line(&sandbox_config.command); // ProcessContainer starts with a completely blank environment — no PATH, - // no SystemRoot, nothing. Three tiers of base env, safest first (see - // `pc_minimal_env` / `pc_inherit_full_env` field docs for the full - // rationale) -- then layer sandbox_environment's output (static - // agent_env passthrough merged with any per-request environment from - // the CreateSandbox spec) on top, and finally layer TLS CA vars when an - // egress proxy is active. Skip internal Windows drive-letter variables - // (keys starting with '=') in the full-inherit tier. + // no SystemRoot, nothing. Start with either an empty environment or the + // safe Windows bootstrap set, then layer the per-request environment from + // the CreateSandbox spec and the TLS/proxy variables required by governed + // egress. let mut env_map: HashMap = if config.pc_minimal_env { HashMap::new() - } else if config.pc_inherit_full_env { - std::env::vars() - .filter(|(k, _)| !k.is_empty() && !k.starts_with('=')) - .collect() } else { MINIMAL_WINDOWS_BOOTSTRAP_ENV .iter() @@ -1570,7 +1514,7 @@ async fn run_lifecycle( .collect() }; - for entry in sandbox_environment(&sandbox, &config) { + for entry in sandbox_environment(&sandbox) { if let Some(pos) = entry.find('=') { env_map.insert(entry[..pos].to_string(), entry[pos + 1..].to_string()); } @@ -1589,13 +1533,13 @@ async fn run_lifecycle( info!(sandbox = %sandbox_name, count = env.len(), "MXC process env vars"); // When spawner wrapping is configured, launch openshell-supervisor-relay - // instead of agent_command directly. The real command/env are sent over + // instead of the per-sandbox command directly. The real command/env are sent over // the control channel once the spawner announces readiness (see the - // "launch" handshake below) rather than written to share_dir as + // "launch" handshake below) rather than written to the working directory as // agent-cmd.txt/agent-env.txt -- this keeps command/env (which can carry // secrets, e.g. OPENCLAW_GATEWAY_TOKEN) off disk entirely and eliminates // the file-staleness/namespace-mismatch bug class that existed when they - // were file-based. `agent_command`'s target application (e.g. OpenClaw) + // were file-based. The target application (e.g. OpenClaw) // stays entirely unaware of the relay protocol either way. let spawner_wrapping_active = !config.pc_relay_spawner_path.is_empty() && config.pc_relay_target_port != 0; @@ -1614,12 +1558,12 @@ async fn run_lifecycle( // Downstream logging/ETW attribution should reflect what's actually // launched (openshell-supervisor-relay, when wrapping is active), not - // the original agent_command -- shadow command_line with the effective + // the original workload command -- shadow command_line with the effective // value. let command_line = effective_command_line; let process = MxcProcess { command_line: command_line.clone(), - cwd: sandbox_config.cwd, + cwd: sandbox_config.cwd.clone(), // Cloned: the launch handshake below (spawner_wrapping_active case) // needs its own copy of `env` to send over the control channel. env: env.clone(), @@ -1762,7 +1706,7 @@ async fn run_lifecycle( // pending requests sent over stdin (see control_channel.rs). Only // meaningful when the process on the other end is // openshell-supervisor-relay (spawner wrapping active) -- an arbitrary - // agent_command target wouldn't understand this protocol, so stdin is + // workload target wouldn't understand this protocol, so stdin is // left untouched (and unpiped expectations unaffected) otherwise. let control_channel: Option> = if spawner_wrapping_active { if let Some(stdin) = child.stdin.take() { @@ -1953,11 +1897,11 @@ async fn run_lifecycle( // The generic spawner (openshell-supervisor-relay) gets its // shutdown notice over the control channel (see delete_sandbox's // "shutdown" request) -- no file needed. Only mxc-ws-agent.rs - // (set directly as agent_command, not spawner-wrapped, no + // (set directly as the sandbox command, not spawner-wrapped, no // control channel) still polls a signal file for it. - if !spawner_wrapping_active && !config.share_dir.is_empty() { + if !spawner_wrapping_active && !sandbox_config.cwd.is_empty() { entry.signal_file = - Some(PathBuf::from(&config.share_dir).join("openshell-shutdown.signal")); + Some(PathBuf::from(&sandbox_config.cwd).join("openshell-shutdown.signal")); } Some((rx, done_tx)) } else { @@ -1991,7 +1935,7 @@ async fn run_lifecycle( // When spawner wrapping is active, openshell-supervisor-relay.rs hasn't // spawned the real target yet -- it waits for a "launch" request over // the control channel instead of reading agent-cmd.txt/agent-env.txt - // from share_dir (see its module docs). Wait for its startup-ready + // from files in the workload directory (see its module docs). Wait for its startup-ready // event, then send the real command/env directly; this keeps them off // disk (they can carry secrets, e.g. OPENCLAW_GATEWAY_TOKEN) and also // proves the correlated request/response path works end to end -- the @@ -2535,6 +2479,17 @@ mod lifecycle_tests { assert!(config.egress_proxy_addr.is_empty()); } + #[test] + fn gateway_config_rejects_per_sandbox_workload_fields() { + for field in ["agent_command", "agent_cwd", "agent_env", "share_dir"] { + let mut config = serde_json::Map::new(); + config.insert(field.to_string(), serde_json::json!([])); + let error = serde_json::from_value::(config.into()) + .expect_err("workload fields must not be accepted in gateway config"); + assert!(error.to_string().contains(field)); + } + } + #[test] fn sandbox_proxy_addr_uses_ephemeral_loopback_port() { let configured = "127.0.0.1:18080".parse().unwrap(); @@ -2896,7 +2851,7 @@ mod lifecycle_tests { .insert("SHARED".into(), "template".into()); spec.environment.insert("SHARED".into(), "spec".into()); spec.environment.insert("TOKEN".into(), "value".into()); - let environment = sandbox_environment(&sandbox, &MxcComputeConfig::default()); + let environment = sandbox_environment(&sandbox); assert!(environment.contains(&"SHARED=spec".to_string())); assert!(environment.contains(&"TOKEN=value".to_string())); assert!(environment.iter().all(|entry| { diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index 50b73c2104..e30844d684 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -908,13 +908,6 @@ backend = "process_container" default_configuration_id = "composable" pc_least_privilege = false pc_capabilities = [] -# Command executed inside the MXC sandbox. -agent_command = ["cmd", "/c", "echo hello > C:\\work\\demo\\hello.txt"] -agent_cwd = "C:\\work\\demo" -# Optional non-secret operator environment. Bare keys read the gateway host -# environment; KEY=VALUE entries pass through literally. Do not put secrets here. -agent_env = ["MY_NON_SECRET_SETTING=enabled"] -share_dir = "C:\\work\\demo" # 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. @@ -924,11 +917,11 @@ 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 `share_dir/.openshell-proxy//`. CA staging requires a non-empty `share_dir`; 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. +Set `egress_proxy = true` with `egress_proxy_addr = "127.0.0.1:18080"` to enable the Windows Pattern C split. The address must be a `127.0.0.1:PORT` socket. The driver allocates a unique ephemeral port per sandbox, injects that listener through proxy environment variables, and stages the public proxy CA beneath the sandbox's configured `/.openshell-proxy//`. A non-empty per-sandbox `cwd` is therefore required when governed egress is enabled; sandbox-specific subdirectories prevent concurrent sandboxes from overwriting each other's trust files. MXC denies direct Internet egress but allows `127.0.0.1/32`; this permits dynamic forwarding but also means the sandbox can reach unrelated host services bound to loopback. MXC rejects policies containing `network_middlewares` before launch because this host-proxy path does not receive the gateway middleware registry. -Supply the workload command and optional working directory through the sandbox's `mxc` driver configuration, for example `{"mxc":{"command":["cmd","/c","echo hello"],"cwd":"C:\\work"}}`. +Supply the workload command and optional working directory through `sandbox create --driver-config-json`, for example `{"mxc":{"command":["cmd","/c","echo hello"],"cwd":"C:\\work"}}`. Supply workload environment through `sandbox create --env` or `--env-from`; it is not part of gateway configuration. Attached provider credentials require this governed-egress path. The gateway gives MXC only revision-scoped environment placeholders and keeps real values in the host proxy's endpoint-bound resolver. Provider keys override matching sandbox environment entries case-insensitively. Sandbox creation fails when attached provider material exists but `egress_proxy` is disabled. MXC takes its static provider snapshot at creation and rejects credentials with an expiration timestamp because it has no live refresh channel. Dynamic token grants continue to mint credentials per request in the host proxy. Recreate the sandbox after attaching, detaching, rotating, or revoking a non-expiring static provider credential. From ba73e13ecaefa44c18cb60be9a3d5a346ee37fd4 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Wed, 16 Sep 2026 00:38:45 -0700 Subject: [PATCH 18/26] fix(mxc): harden relay integration after rebase Signed-off-by: Drew Newberry --- .../src/control_channel.rs | 24 ++--- crates/openshell-driver-mxc/src/driver.rs | 13 ++- crates/openshell-driver-mxc/src/relay.rs | 95 ++++++++++--------- crates/openshell-server/src/storage_proto.rs | 10 +- crates/openshell-supervisor-relay/src/imp.rs | 77 ++++++++------- .../tests/control_channel_contract.rs | 37 ++++++++ 6 files changed, 156 insertions(+), 100 deletions(-) diff --git a/crates/openshell-driver-mxc/src/control_channel.rs b/crates/openshell-driver-mxc/src/control_channel.rs index 673b82177e..0e429db489 100644 --- a/crates/openshell-driver-mxc/src/control_channel.rs +++ b/crates/openshell-driver-mxc/src/control_channel.rs @@ -192,21 +192,23 @@ impl ControlChannel { let mut line = serde_json::to_string(&req)?; line.push('\n'); - let write_result = { + let request = async { let mut stdin = self.stdin.lock().await; - match stdin.write_all(line.as_bytes()).await { - Ok(()) => stdin.flush().await, - Err(e) => Err(e), - } + 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)?) }; - if let Err(e) = write_result { - self.pending.lock().await.remove(&id); - return Err(ControlChannelError::Write(e)); - } - match tokio::time::timeout(timeout, rx).await { + match tokio::time::timeout(timeout, request).await { Ok(Ok(value)) => Ok(value), - Ok(Err(_)) => Err(ControlChannelError::Dropped), + Ok(Err(error)) => { + self.pending.lock().await.remove(&id); + Err(error) + } Err(_) => { self.pending.lock().await.remove(&id); Err(ControlChannelError::Timeout(timeout)) diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index d60f3c581a..cd149d1ae4 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -2191,15 +2191,13 @@ async fn monitor_exec( } else { (child.wait().await, None) }; - if wait_result.is_ok() { + let termination_confirmed = wait_result.is_ok(); + if termination_confirmed { if let Some(pid) = wxc_pid && let Ok(mut idx) = attribution.lock() { idx.retire_launch(&sandbox_id, pid); } - if let Some(done_tx) = done_tx { - let _ = done_tx.send(true); - } } match wait_result { @@ -2257,6 +2255,13 @@ async fn monitor_exec( warn!(sandbox = %sandbox.name, error = %error, "MXC agent exec wait error"); } } + // Publish the terminal registry state and watch event before waking a + // concurrent stop/delete waiter. Otherwise that waiter can publish + // Stopped (or remove the entry), only for this monitor to race in later + // and resurrect an AgentCompleted/ExecFailed state. + if termination_confirmed && let Some(done_tx) = done_tx { + let _ = done_tx.send(true); + } } async fn set_failed( registry: &Arc>>, diff --git a/crates/openshell-driver-mxc/src/relay.rs b/crates/openshell-driver-mxc/src/relay.rs index da0a000188..a5a56315e4 100644 --- a/crates/openshell-driver-mxc/src/relay.rs +++ b/crates/openshell-driver-mxc/src/relay.rs @@ -262,53 +262,60 @@ async fn control_channel_relay_task( let mut host_to_sandbox_bytes = 0_u64; let mut sandbox_to_host_bytes = 0_u64; let mut shutting_down = false; - loop { - tokio::select! { - result = host_read.read(&mut host_buf) => match result { - Ok(0) | Err(_) => break, - Ok(n) => { - let bytes = base64::engine::general_purpose::STANDARD.encode(&host_buf[..n]); - let response = control_channel.request( - "forward_write", - serde_json::json!({"session_id": session_id, "bytes": bytes}), - Duration::from_secs(10), - ).await; - if !control_response_ok(&response) { - break; + '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 = Box::pin(control_channel.request( + "forward_read", + serde_json::json!({"session_id": session_id}), + Duration::from_secs(10), + )); + loop { + tokio::select! { + result = host_read.read(&mut host_buf) => match result { + Ok(0) | 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; } - host_to_sandbox_bytes += n as u64; - } - }, - response = control_channel.request( - "forward_read", - serde_json::json!({"session_id": session_id}), - Duration::from_secs(10), - ) => { - let Ok(response) = response else { break }; - if response.get("ok").and_then(serde_json::Value::as_bool) != Some(true) { - break; - } - let data = response.get("data").unwrap_or(&serde_json::Value::Null); - if data.get("eof").and_then(serde_json::Value::as_bool) == Some(true) { - break; - } - let Some(encoded) = data.get("bytes").and_then(serde_json::Value::as_str) else { - break; - }; - if !encoded.is_empty() { - let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(encoded) else { - break; + }, + response = &mut forward_read => { + let 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 host_write.write_all(&bytes).await.is_err() { - break; + 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; } - sandbox_to_host_bytes += bytes.len() as u64; - } - }, - _ = &mut shutdown_rx => { - shutting_down = true; - break; - }, + break; + }, + _ = &mut shutdown_rx => { + shutting_down = true; + break 'connection; + }, + } } } let _ = control_channel diff --git a/crates/openshell-server/src/storage_proto.rs b/crates/openshell-server/src/storage_proto.rs index 589a513b9f..edd5e4071c 100644 --- a/crates/openshell-server/src/storage_proto.rs +++ b/crates/openshell-server/src/storage_proto.rs @@ -118,11 +118,11 @@ mod tests { const STORAGE_V1_SCHEMA_SHA256: &str = "574bf5fcff731bd6e3fd84ed3f124161035bd236ef0fb7e32b4d8a8c55ceba5e"; const PUBLIC_RPC_SCHEMA_SHA256: &str = - "d323fdd0c989049950ed3bf9cc2cbae9f05e1ae83b13fa2a65a2f5db1ebab95f"; + "18e924d6cf7580588e3409c8eac224ec62e5f6e9bb2779dff0d58b5c8c2abcba"; const DURABLE_SCHEMA_SHA256: &str = - "65066c0b0eef57a4c708f20fcbbb8e8f47376da9f4bf73dfc3bca0b3df174ba8"; + "c439724f77f71b794a68a76cec4040dde9eb22cbdb6e4f1517028683399c87ef"; const PUBLIC_DURABLE_OVERLAP_SHA256: &str = - "39e8aaf0d1fbc86906c49a9e7f60641a3ce203d3130799c8065e09acf9d53ddf"; + "ea546b1a1ee0ddf50ee74f2541dcf68541054c70a5ce29c93d3fd29e6af98820"; // A persisted Sandbox without endpoint status retains its lifecycle fields; // the absent repeated field decodes empty and needs no database rewrite. const SANDBOX_WITHOUT_ENDPOINT_STATUS: &str = "0a1e0a0a73616e64626f782d6964120773616e64626f783a0764656661756c741a2b0a0773616e64626f782a0d0a05526561647912045472756530023807420d73757065727669736f722d6964"; @@ -539,9 +539,9 @@ mod tests { ); assert_eq!( (durable_closure.messages.len(), durable_closure.enums.len()), - (83, 9) + (83, 10) ); - assert_eq!((overlap_messages.len(), overlap_enums.len()), (73, 9)); + assert_eq!((overlap_messages.len(), overlap_enums.len()), (73, 10)); assert_eq!( public_inventory_hash, PUBLIC_RPC_SCHEMA_SHA256, diff --git a/crates/openshell-supervisor-relay/src/imp.rs b/crates/openshell-supervisor-relay/src/imp.rs index ad09f7dcb8..3f8080214e 100644 --- a/crates/openshell-supervisor-relay/src/imp.rs +++ b/crates/openshell-supervisor-relay/src/imp.rs @@ -327,7 +327,6 @@ async fn run_control_channel( use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; let stdin = tokio::io::stdin(); - let mut lines = BufReader::new(stdin).lines(); let mut stdout = tokio::io::stdout(); // Announce readiness before entering the request loop: this is the @@ -343,49 +342,54 @@ async fn run_control_channel( } eprintln!("[openshell-supervisor-relay] control channel ready (stdin/stdout)"); - // `None` once fired (or once main()'s sender is dropped without firing, - // e.g. wait_for_port_ready failed) -- the `if` guard below then disables - // that select arm instead of it firing repeatedly on every subsequent - // poll of an already-resolved oneshot. - let mut target_ready_rx = Some(target_ready_rx); - - loop { - tokio::select! { - line_result = lines.next_line() => { - let line = match line_result { - Ok(Some(l)) => l, - Ok(None) => { - eprintln!("[openshell-supervisor-relay] control channel: stdin closed"); - break; - } - Err(e) => { - eprintln!("[openshell-supervisor-relay] control channel read error: {e}"); - break; - } - }; + // Read and execute requests independently so a slow target socket write + // or long poll on one forwarding session cannot block shutdown, launch, + // or traffic for another session. Correlation ids make response ordering + // irrelevant; stdout remains single-owner below to prevent interleaving. + let (response_tx, mut response_rx) = tokio::sync::mpsc::unbounded_channel::(); + let request_reader = tokio::spawn({ + let launch = launch.clone(); + let shutdown = shutdown.clone(); + let forward_sessions = forward_sessions.clone(); + async move { + let mut lines = BufReader::new(stdin).lines(); + while let Ok(Some(line)) = lines.next_line().await { let trimmed = line.trim(); if trimmed.is_empty() { continue; } - - // Log only the operation and correlation id, never the raw request -- - // "launch" carries the target's complete environment (e.g. - // OPENCLAW_GATEWAY_TOKEN) and this process's stderr is forwarded - // verbatim into the gateway's own logs, so printing `trimmed` here - // would expose it even when MXC's own --debug flag is off. eprintln!( "[openshell-supervisor-relay] control request: {}", describe_control_request(trimmed) ); - let response = handle_control_request( - trimmed, - &launch, - &shutdown, - &forward_sessions, - ) - .await; - let mut out = response.to_string(); - out.push('\n'); + let request = trimmed.to_string(); + let launch = launch.clone(); + let shutdown = shutdown.clone(); + let forward_sessions = forward_sessions.clone(); + let response_tx = response_tx.clone(); + tokio::spawn(async move { + let mut response = + handle_control_request(&request, &launch, &shutdown, &forward_sessions) + .await + .to_string(); + response.push('\n'); + let _ = response_tx.send(response); + }); + } + eprintln!("[openshell-supervisor-relay] control channel: stdin closed"); + } + }); + + // `None` once fired (or once main()'s sender is dropped without firing, + // e.g. wait_for_port_ready failed) -- the `if` guard below then disables + // that select arm instead of it firing repeatedly on every subsequent + // poll of an already-resolved oneshot. + let mut target_ready_rx = Some(target_ready_rx); + + loop { + tokio::select! { + response = response_rx.recv() => { + let Some(out) = response else { break }; if stdout.write_all(out.as_bytes()).await.is_err() || stdout.flush().await.is_err() { eprintln!("[openshell-supervisor-relay] control channel write failed"); break; @@ -410,6 +414,7 @@ async fn run_control_channel( } } } + request_reader.abort(); } /// `forward` opens a new, independent relay bridge for a target port, e.g. diff --git a/crates/openshell-supervisor-relay/tests/control_channel_contract.rs b/crates/openshell-supervisor-relay/tests/control_channel_contract.rs index 1f3fb51464..f03921ba0e 100644 --- a/crates/openshell-supervisor-relay/tests/control_channel_contract.rs +++ b/crates/openshell-supervisor-relay/tests/control_channel_contract.rs @@ -527,6 +527,43 @@ async fn control_channel_forward_round_trips_bytes_without_host_callback_network assert_eq!(relay.next_json().await, json!({"id": 4, "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; From 23362f7f5d950ae118fff1f3827df6a4be5a5d3a Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Wed, 16 Sep 2026 00:47:51 -0700 Subject: [PATCH 19/26] test(cli): isolate completers from system gateways Signed-off-by: Drew Newberry --- crates/openshell-cli/src/completers.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/openshell-cli/src/completers.rs b/crates/openshell-cli/src/completers.rs index c5fec20634..a0ab9a9b5a 100644 --- a/crates/openshell-cli/src/completers.rs +++ b/crates/openshell-cli/src/completers.rs @@ -217,6 +217,7 @@ mod tests { with_vars( [ ("XDG_CONFIG_HOME", Some(tmp.as_str())), + ("OPENSHELL_SYSTEM_GATEWAY_DIR", Some(tmp.as_str())), ("OPENSHELL_GATEWAY", None::<&str>), ], f, From 026afbfce3f6722a0c966bd95260bd7e019df188 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Wed, 16 Sep 2026 00:56:36 -0700 Subject: [PATCH 20/26] fix(sdk-go): honor system gateway directory override Signed-off-by: Drew Newberry --- sdk/go/openshell/v1/gateway/gateway.go | 2 +- sdk/go/openshell/v1/gateway/gateway_test.go | 3 +++ sdk/go/openshell/v1/gateway/paths.go | 15 ++++++++++++++- sdk/go/openshell/v1/gateway/paths_test.go | 16 ++++++++++++++++ 4 files changed, 34 insertions(+), 2 deletions(-) diff --git a/sdk/go/openshell/v1/gateway/gateway.go b/sdk/go/openshell/v1/gateway/gateway.go index da7c9814b4..dde3545664 100644 --- a/sdk/go/openshell/v1/gateway/gateway.go +++ b/sdk/go/openshell/v1/gateway/gateway.go @@ -135,7 +135,7 @@ func ListGateways() ([]Info, error) { } } - sysNames, listErr := listGatewayDirs(systemConfigBase) + sysNames, listErr := listGatewayDirs(systemConfigDir()) if listErr != nil { return nil, listErr } diff --git a/sdk/go/openshell/v1/gateway/gateway_test.go b/sdk/go/openshell/v1/gateway/gateway_test.go index ec519c29a6..e22a0a7873 100644 --- a/sdk/go/openshell/v1/gateway/gateway_test.go +++ b/sdk/go/openshell/v1/gateway/gateway_test.go @@ -409,6 +409,7 @@ func TestLoadConfig_ActiveGateway(t *testing.T) { func TestListGateways_MultipleGateways(t *testing.T) { tmp := t.TempDir() t.Setenv("XDG_CONFIG_HOME", tmp) + t.Setenv(systemGatewayDirEnv, tmp) for _, name := range []string{"prod", "staging", "dev"} { gwDir := filepath.Join(tmp, "openshell", "gateways", name) @@ -432,6 +433,7 @@ func TestListGateways_MultipleGateways(t *testing.T) { func TestListGateways_EmptyDirs(t *testing.T) { tmp := t.TempDir() t.Setenv("XDG_CONFIG_HOME", tmp) + t.Setenv(systemGatewayDirEnv, tmp) gateways, err := ListGateways() require.NoError(t, err) @@ -441,6 +443,7 @@ func TestListGateways_EmptyDirs(t *testing.T) { func TestListGateways_ActiveStatus(t *testing.T) { tmp := t.TempDir() t.Setenv("XDG_CONFIG_HOME", tmp) + t.Setenv(systemGatewayDirEnv, tmp) for _, name := range []string{"alpha", "beta"} { gwDir := filepath.Join(tmp, "openshell", "gateways", name) diff --git a/sdk/go/openshell/v1/gateway/paths.go b/sdk/go/openshell/v1/gateway/paths.go index fa0e1373c2..0fa9cb0092 100644 --- a/sdk/go/openshell/v1/gateway/paths.go +++ b/sdk/go/openshell/v1/gateway/paths.go @@ -24,6 +24,10 @@ const ( // systemConfigBase is the system-wide config directory. systemConfigBase = "/etc/openshell" + + // systemGatewayDirEnv overrides the system-wide config root. Keep this in + // sync with the Rust CLI so SDK discovery sees the same gateway set. + systemGatewayDirEnv = "OPENSHELL_SYSTEM_GATEWAY_DIR" ) // userConfigDir returns the user-specific configuration directory for @@ -49,7 +53,16 @@ func userConfigDir() (string, error) { // systemGatewayDir returns the system-wide gateway config directory. func systemGatewayDir() string { - return filepath.Join(systemConfigBase, gatewaySubdir) + return filepath.Join(systemConfigDir(), gatewaySubdir) +} + +// systemConfigDir returns the system-wide configuration root. Empty and +// relative overrides are ignored to match the CLI's fail-safe behavior. +func systemConfigDir() string { + if dir := os.Getenv(systemGatewayDirEnv); dir != "" && filepath.IsAbs(dir) { + return dir + } + return systemConfigBase } // resolveGatewayDir searches for a gateway directory by name, checking the diff --git a/sdk/go/openshell/v1/gateway/paths_test.go b/sdk/go/openshell/v1/gateway/paths_test.go index fff9a01d73..60a91569ea 100644 --- a/sdk/go/openshell/v1/gateway/paths_test.go +++ b/sdk/go/openshell/v1/gateway/paths_test.go @@ -39,6 +39,22 @@ func TestSystemGatewayDir(t *testing.T) { assert.Equal(t, filepath.FromSlash("/etc/openshell/gateways"), dir) } +func TestSystemGatewayDirOverride(t *testing.T) { + tmp := t.TempDir() + t.Setenv(systemGatewayDirEnv, tmp) + + assert.Equal(t, filepath.Join(tmp, "gateways"), systemGatewayDir()) +} + +func TestSystemGatewayDirIgnoresInvalidOverrides(t *testing.T) { + for _, override := range []string{"", "relative/path"} { + t.Run(override, func(t *testing.T) { + t.Setenv(systemGatewayDirEnv, override) + assert.Equal(t, filepath.FromSlash("/etc/openshell/gateways"), systemGatewayDir()) + }) + } +} + func TestResolveGatewayDir_UserDir(t *testing.T) { tmp := t.TempDir() t.Setenv("XDG_CONFIG_HOME", tmp) From 438bcf0529d42cd7f41f230cf57fd55c97c41342 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Wed, 16 Sep 2026 01:05:26 -0700 Subject: [PATCH 21/26] fix(mxc): preserve relayed TCP half-closes Signed-off-by: Drew Newberry --- .../src/control_channel.rs | 12 +-- crates/openshell-driver-mxc/src/relay.rs | 65 ++++++++++++-- crates/openshell-supervisor-relay/src/imp.rs | 18 +++- .../tests/control_channel_contract.rs | 87 ++++++++++++++++++- 4 files changed, 165 insertions(+), 17 deletions(-) diff --git a/crates/openshell-driver-mxc/src/control_channel.rs b/crates/openshell-driver-mxc/src/control_channel.rs index 0e429db489..e8db243b7a 100644 --- a/crates/openshell-driver-mxc/src/control_channel.rs +++ b/crates/openshell-driver-mxc/src/control_channel.rs @@ -50,7 +50,7 @@ pub type ReadySlot = Mutex>>>; /// "forward", or the `"target_ready"` event itself) -- an independently /// staged, stale relay binary then fails fast with a clear error instead of /// hanging or misbehaving against fields/events it doesn't understand. -const REQUIRED_SUPERVISOR_RELAY_PROTOCOL_VERSION: u64 = 2; +const REQUIRED_SUPERVISOR_RELAY_PROTOCOL_VERSION: u64 = 3; /// One control channel per sandboxed process. `request()` is safe to call /// concurrently — each call gets its own correlation id and awaits only its @@ -281,7 +281,7 @@ mod tests { let (slot, rx) = armed_ready_slot(); let consumed = - ControlChannel::try_route_ready(&slot, r#"{"event":"ready","protocol_version":2}"#) + ControlChannel::try_route_ready(&slot, r#"{"event":"ready","protocol_version":3}"#) .await; assert!(consumed); @@ -300,13 +300,13 @@ mod tests { consumed, "a recognized ready event is consumed even when rejected" ); - let err = rx.await.unwrap().expect_err("version 2 must be rejected"); + let err = rx.await.unwrap().expect_err("version 1 must be rejected"); assert!( - err.contains('2'), + err.contains('1'), "error should name the offending version: {err}" ); assert!( - err.contains('1'), + err.contains('3'), "error should name the required version: {err}" ); } @@ -360,7 +360,7 @@ mod tests { // other's slot. let consumed = ControlChannel::try_route_target_ready( &slot, - r#"{"event":"ready","protocol_version":2}"#, + r#"{"event":"ready","protocol_version":3}"#, ) .await; diff --git a/crates/openshell-driver-mxc/src/relay.rs b/crates/openshell-driver-mxc/src/relay.rs index a5a56315e4..378f030c9d 100644 --- a/crates/openshell-driver-mxc/src/relay.rs +++ b/crates/openshell-driver-mxc/src/relay.rs @@ -262,19 +262,42 @@ async fn control_channel_relay_task( 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 = Box::pin(control_channel.request( - "forward_read", - serde_json::json!({"session_id": session_id}), - Duration::from_secs(10), - )); + 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) => match result { - Ok(0) | Err(_) => break 'connection, + 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( @@ -289,7 +312,7 @@ async fn control_channel_relay_task( } }, response = &mut forward_read => { - let Ok(response) = response else { break 'connection }; + let Ok(Ok(response)) = response else { break 'connection }; if response.get("ok").and_then(serde_json::Value::as_bool) != Some(true) { break 'connection; } @@ -606,12 +629,30 @@ mod control_relay_cleanup_tests { 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": {"bytes": "", "eof": false}, + "data": data, }); ControlChannel::try_route_response(&pending, &response.to_string()).await; observed_tx @@ -647,6 +688,12 @@ mod control_relay_cleanup_tests { 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 { diff --git a/crates/openshell-supervisor-relay/src/imp.rs b/crates/openshell-supervisor-relay/src/imp.rs index 3f8080214e..fe22bf2343 100644 --- a/crates/openshell-supervisor-relay/src/imp.rs +++ b/crates/openshell-supervisor-relay/src/imp.rs @@ -107,7 +107,7 @@ use tokio_tungstenite::tungstenite::Message; /// out-of-sync peer can't safely ignore, so an independently staged, stale /// binary on either side fails fast with a clear version-mismatch error /// instead of hanging or misbehaving against a field/event it predates. -const PROTOCOL_VERSION: u64 = 2; +const PROTOCOL_VERSION: u64 = 3; struct ForwardSession { reader: tokio::sync::Mutex, @@ -634,6 +634,22 @@ async fn handle_control_request( } } } + "forward_shutdown" => { + let data = req.get("data").cloned().unwrap_or(serde_json::Value::Null); + let Some(session_id) = data.get("session_id").and_then(|v| v.as_str()) else { + return serde_json::json!({"id": id, "ok": false, "error": "forward_shutdown requires session_id"}); + }; + let session = forward_sessions.lock().await.get(session_id).cloned(); + let Some(session) = session else { + return serde_json::json!({"id": id, "ok": false, "error": "forward session not found"}); + }; + match session.writer.lock().await.shutdown().await { + Ok(()) => serde_json::json!({"id": id, "ok": true}), + Err(error) => { + serde_json::json!({"id": id, "ok": false, "error": format!("target shutdown failed: {error}")}) + } + } + } "forward_close" => { let data = req.get("data").cloned().unwrap_or(serde_json::Value::Null); let Some(session_id) = data.get("session_id").and_then(|v| v.as_str()) else { diff --git a/crates/openshell-supervisor-relay/tests/control_channel_contract.rs b/crates/openshell-supervisor-relay/tests/control_channel_contract.rs index f03921ba0e..85c11574e7 100644 --- a/crates/openshell-supervisor-relay/tests/control_channel_contract.rs +++ b/crates/openshell-supervisor-relay/tests/control_channel_contract.rs @@ -187,7 +187,7 @@ impl RelayProcess { async fn expect_ready(&mut self) { let v = self.next_json().await; assert_eq!(v["event"], "ready"); - assert_eq!(v["protocol_version"], 2); + assert_eq!(v["protocol_version"], 3); } async fn launch(&mut self, id: u64, command: &[&str]) -> Value { @@ -527,6 +527,91 @@ async fn control_channel_forward_round_trips_bytes_without_host_callback_network 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(); From dc1242f528e469313388443d3031da88ebf041c7 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Wed, 16 Sep 2026 17:45:43 -0700 Subject: [PATCH 22/26] fix(mxc): reconcile rebased main APIs Signed-off-by: Drew Newberry --- crates/openshell-policy-schema/src/lib.rs | 8 + crates/openshell-policy/src/lib.rs | 3 +- crates/openshell-server/src/compute/mod.rs | 15 +- crates/openshell-server/src/grpc/policy.rs | 12 +- crates/openshell-server/src/grpc/provider.rs | 4 +- crates/openshell-server/src/grpc/sandbox.rs | 2 + crates/openshell-server/src/storage_proto.rs | 12 +- sdk/go/proto/openshellv1/openshell.pb.go | 36 +- sdk/go/proto/sandboxv1/sandbox.pb.go | 457 +++++++++++++------ 9 files changed, 375 insertions(+), 174 deletions(-) diff --git a/crates/openshell-policy-schema/src/lib.rs b/crates/openshell-policy-schema/src/lib.rs index 46968f23e8..163f9071b8 100644 --- a/crates/openshell-policy-schema/src/lib.rs +++ b/crates/openshell-policy-schema/src/lib.rs @@ -694,6 +694,7 @@ fn inspect_document(root: &serde_yml::Value) -> InspectionResult { "filesystem_policy", "landlock", "process", + "ui", "network_policies", "network_middlewares", ], @@ -713,6 +714,11 @@ fn inspect_document(root: &serde_yml::Value) -> InspectionResult { "process", &["run_as_user", "run_as_group"], )?; + inspect_named( + root.get("ui"), + "ui", + &["allow_graphical_ui", "clipboard", "allow_input_injection"], + )?; for (name, rule) in open_map(root.get("network_policies")) { let path = join("network_policies", name); @@ -1174,6 +1180,7 @@ mod tests { for source in [ "version: 1\nfilesystem_policy: null\n", "version: 1\nprocess: null\n", + "version: 1\nui: null\n", "version: 1\nmetadata: null\n", "version: 1\nnetwork_policies:\n x:\n endpoints:\n - host: x\n port: 443\n mcp: null\n", ] { @@ -1210,6 +1217,7 @@ mod tests { "landlock.future", ), ("version: 1\nprocess: { future: true }\n", "process.future"), + ("version: 1\nui: { future: true }\n", "ui.future"), ( "version: 1\nnetwork_policies: { api: { future: true } }\n", "network_policies.api.future", diff --git a/crates/openshell-policy/src/lib.rs b/crates/openshell-policy/src/lib.rs index ed5498925f..9c94d3d003 100644 --- a/crates/openshell-policy/src/lib.rs +++ b/crates/openshell-policy/src/lib.rs @@ -2058,6 +2058,7 @@ network_policies: #[test] fn ui_unspecified_clipboard_canonicalizes_to_none_across_yaml_round_trip() { let raw = SandboxPolicy { + version: 1, ui: Some(UiPolicy { allow_graphical_ui: true, clipboard: UiClipboardAccess::Unspecified as i32, @@ -2085,7 +2086,7 @@ network_policies: assert!( error .to_string() - .contains("failed to parse sandbox policy YAML") + .contains("failed to decode sandbox policy fields") ); } diff --git a/crates/openshell-server/src/compute/mod.rs b/crates/openshell-server/src/compute/mod.rs index f140189894..d89430c860 100644 --- a/crates/openshell-server/src/compute/mod.rs +++ b/crates/openshell-server/src/compute/mod.rs @@ -1762,8 +1762,12 @@ impl ComputeRuntime { workspace: &str, name: &str, ) -> Result { - self.delete_sandbox_allow_missing(workspace, name, false) - .await + self.delete_sandbox_with_preconditions( + workspace, + name, + SandboxDeletePreconditions::default(), + ) + .await } pub(crate) async fn delete_sandbox_allow_missing( @@ -1798,6 +1802,11 @@ impl ComputeRuntime { allow_missing: bool, preconditions: SandboxDeletePreconditions, ) -> Result { + if preconditions == SandboxDeletePreconditions::default() { + return self + .delete_sandbox_allow_missing(workspace, name, allow_missing) + .await; + } self.delete_sandbox_with_options(workspace, name, allow_missing, preconditions) .await } @@ -9542,7 +9551,7 @@ mod tests { .await .unwrap(); - assert!(result.deleted); + assert!(result.acknowledged()); assert_eq!(result.sandbox_id, "sb-1"); assert_eq!(driver.delete_calls(), 1); } diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index a178eb074e..2e8197e180 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -3361,7 +3361,9 @@ pub(super) async fn resolve_sandbox_create_runtime_inputs( "withholding unbound static provider credential from MXC sandbox" ); provider_environment.environment.remove(&key); - provider_environment.credential_expires_at_ms.remove(&key); + provider_environment + .credential_expiration_times + .remove(&key); provider_environment.static_credential_keys.remove(&key); } validate_create_time_provider_credential_lifetimes(sandbox_id, &provider_environment)?; @@ -3379,7 +3381,7 @@ pub(super) async fn resolve_sandbox_create_runtime_inputs( openshell_core::provider_credentials::ProviderCredentialState::from_bound_environment( provider_env_revision, provider_environment.environment, - provider_environment.credential_expires_at_ms, + provider_environment.credential_expiration_times, provider_environment.dynamic_credentials, provider_environment.static_credential_bindings, non_secret_environment_keys, @@ -3407,7 +3409,7 @@ fn validate_create_time_provider_credential_lifetimes( .iter() .filter(|key| { provider_environment - .credential_expires_at_ms + .credential_expiration_times .get(*key) .is_some_and(|expires_at_ms| *expires_at_ms > 0) }) @@ -11995,7 +11997,7 @@ mod tests { #[test] fn create_time_provider_credentials_reject_expiring_static_values() { let provider_environment = ProviderEnvironment { - credential_expires_at_ms: HashMap::from([ + credential_expiration_times: HashMap::from([ ("B_TOKEN".to_string(), 20_000), ("A_TOKEN".to_string(), 10_000), ("NON_SECRET".to_string(), 30_000), @@ -12018,7 +12020,7 @@ mod tests { #[test] fn create_time_provider_credentials_allow_non_expiring_static_values() { let provider_environment = ProviderEnvironment { - credential_expires_at_ms: HashMap::from([("STATIC_TOKEN".to_string(), 0)]), + credential_expiration_times: HashMap::from([("STATIC_TOKEN".to_string(), 0)]), static_credential_keys: HashSet::from(["STATIC_TOKEN".to_string()]), ..Default::default() }; diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index 2e24b29a0c..4140c66a82 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -10943,9 +10943,9 @@ mod tests { "GITHUB_TOKEN", "github-token", ); - provider.credential_expires_at_ms.insert( + provider.credential_expiration_times.insert( "GITHUB_TOKEN".to_string(), - crate::persistence::current_time_ms() - 1, + ts(crate::persistence::current_time_ms() - 1), ); create_provider_record_validating( &store, diff --git a/crates/openshell-server/src/grpc/sandbox.rs b/crates/openshell-server/src/grpc/sandbox.rs index c810eb05bb..c3a7a04790 100644 --- a/crates/openshell-server/src/grpc/sandbox.rs +++ b/crates/openshell-server/src/grpc/sandbox.rs @@ -3900,6 +3900,7 @@ mod tests { let error = handle_delete_sandbox_inner( &state, authed_request(DeleteSandboxRequest { + allow_missing: false, name: "guarded-delete".to_string(), workspace_scope: Some(openshell_core::proto::workspace_selector("default")), expected_sandbox_id: "sb-stale".to_string(), @@ -3930,6 +3931,7 @@ mod tests { let error = handle_delete_sandbox_inner( &state, authed_request(DeleteSandboxRequest { + allow_missing: false, name: "guarded-delete".to_string(), workspace_scope: Some(openshell_core::proto::workspace_selector("default")), expected_sandbox_id: String::new(), diff --git a/crates/openshell-server/src/storage_proto.rs b/crates/openshell-server/src/storage_proto.rs index edd5e4071c..ebd9cc83b2 100644 --- a/crates/openshell-server/src/storage_proto.rs +++ b/crates/openshell-server/src/storage_proto.rs @@ -118,11 +118,11 @@ mod tests { const STORAGE_V1_SCHEMA_SHA256: &str = "574bf5fcff731bd6e3fd84ed3f124161035bd236ef0fb7e32b4d8a8c55ceba5e"; const PUBLIC_RPC_SCHEMA_SHA256: &str = - "18e924d6cf7580588e3409c8eac224ec62e5f6e9bb2779dff0d58b5c8c2abcba"; + "a1c3e6e7d54d57cf686f11837a8d4fe9b0fab8c222e5f66c98de02c62e904af8"; const DURABLE_SCHEMA_SHA256: &str = - "c439724f77f71b794a68a76cec4040dde9eb22cbdb6e4f1517028683399c87ef"; + "d665d84ca16d663b312cb6c469613821375453014e43b38cbd8c656b3993b3f0"; const PUBLIC_DURABLE_OVERLAP_SHA256: &str = - "ea546b1a1ee0ddf50ee74f2541dcf68541054c70a5ce29c93d3fd29e6af98820"; + "b54aa96237c5e16fb5c933f5986774990d1972b232e246f47864fe495c2dedab"; // A persisted Sandbox without endpoint status retains its lifecycle fields; // the absent repeated field decodes empty and needs no database rewrite. const SANDBOX_WITHOUT_ENDPOINT_STATUS: &str = "0a1e0a0a73616e64626f782d6964120773616e64626f783a0764656661756c741a2b0a0773616e64626f782a0d0a05526561647912045472756530023807420d73757065727669736f722d6964"; @@ -535,13 +535,13 @@ mod tests { assert_eq!( (public_closure.messages.len(), public_closure.enums.len()), - (283, 14) + (284, 15) ); assert_eq!( (durable_closure.messages.len(), durable_closure.enums.len()), - (83, 10) + (84, 10) ); - assert_eq!((overlap_messages.len(), overlap_enums.len()), (73, 10)); + assert_eq!((overlap_messages.len(), overlap_enums.len()), (74, 10)); assert_eq!( public_inventory_hash, PUBLIC_RPC_SCHEMA_SHA256, diff --git a/sdk/go/proto/openshellv1/openshell.pb.go b/sdk/go/proto/openshellv1/openshell.pb.go index 5c0a00dbdb..14662ddf4a 100644 --- a/sdk/go/proto/openshellv1/openshell.pb.go +++ b/sdk/go/proto/openshellv1/openshell.pb.go @@ -3633,9 +3633,19 @@ type DeleteSandboxRequest struct { WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,3,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` // Succeed with ALREADY_ABSENT if the target is missing. Does not wait for // asynchronous cleanup and does not suppress authorization or parent errors. - AllowMissing bool `protobuf:"varint,4,opt,name=allow_missing,json=allowMissing,proto3" json:"allow_missing,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + AllowMissing bool `protobuf:"varint,4,opt,name=allow_missing,json=allowMissing,proto3" json:"allow_missing,omitempty"` + // Optional immutable sandbox identity precondition. When non-empty, the + // gateway rejects the request with ABORTED unless the currently resolved + // sandbox has this exact metadata ID. The check is repeated under the + // lifecycle lock immediately before any delete mutation. + ExpectedSandboxId string `protobuf:"bytes,5,opt,name=expected_sandbox_id,json=expectedSandboxId,proto3" json:"expected_sandbox_id,omitempty"` + // Optional optimistic-concurrency precondition. Requires + // expected_sandbox_id. When non-zero, the gateway rejects the request with + // ABORTED unless the sandbox's current resource version matches this value + // immediately before any delete mutation. + ExpectedResourceVersion uint64 `protobuf:"varint,6,opt,name=expected_resource_version,json=expectedResourceVersion,proto3" json:"expected_resource_version,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *DeleteSandboxRequest) Reset() { @@ -3689,6 +3699,20 @@ func (x *DeleteSandboxRequest) GetAllowMissing() bool { return false } +func (x *DeleteSandboxRequest) GetExpectedSandboxId() string { + if x != nil { + return x.ExpectedSandboxId + } + return "" +} + +func (x *DeleteSandboxRequest) GetExpectedResourceVersion() uint64 { + if x != nil { + return x.ExpectedResourceVersion + } + return 0 +} + // Stop sandbox request. type StopSandboxRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -15119,11 +15143,13 @@ const file_openshell_proto_rawDesc = "" + "\fsandbox_name\x18\x01 \x01(\tR\vsandboxName\x12#\n" + "\rprovider_name\x18\x02 \x01(\tR\fproviderName\x12:\n" + "\x19expected_resource_version\x18\x03 \x01(\x04R\x17expectedResourceVersion\x12R\n" + - "\x0fworkspace_scope\x18\x05 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x04\x10\x05R\tworkspace\"\xb4\x01\n" + + "\x0fworkspace_scope\x18\x05 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x04\x10\x05R\tworkspace\"\xa0\x02\n" + "\x14DeleteSandboxRequest\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12R\n" + "\x0fworkspace_scope\x18\x03 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScope\x12#\n" + - "\rallow_missing\x18\x04 \x01(\bR\fallowMissingJ\x04\b\x02\x10\x03R\tworkspace\"\x8d\x01\n" + + "\rallow_missing\x18\x04 \x01(\bR\fallowMissing\x12.\n" + + "\x13expected_sandbox_id\x18\x05 \x01(\tR\x11expectedSandboxId\x12:\n" + + "\x19expected_resource_version\x18\x06 \x01(\x04R\x17expectedResourceVersionJ\x04\b\x02\x10\x03R\tworkspace\"\x8d\x01\n" + "\x12StopSandboxRequest\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12R\n" + "\x0fworkspace_scope\x18\x03 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x02\x10\x03R\tworkspace\"\x8e\x01\n" + diff --git a/sdk/go/proto/sandboxv1/sandbox.pb.go b/sdk/go/proto/sandboxv1/sandbox.pb.go index 6cbbd5fbda..1c3b1db33d 100644 --- a/sdk/go/proto/sandboxv1/sandbox.pb.go +++ b/sdk/go/proto/sandboxv1/sandbox.pb.go @@ -26,6 +26,67 @@ const ( _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) +// Directional clipboard access for a sandboxed workload. +type UiClipboardAccess int32 + +const ( + // Unspecified resolves to no clipboard access. + UiClipboardAccess_UI_CLIPBOARD_ACCESS_UNSPECIFIED UiClipboardAccess = 0 + // No clipboard reads or writes. + UiClipboardAccess_UI_CLIPBOARD_ACCESS_NONE UiClipboardAccess = 1 + // The sandbox may read host clipboard contents. + UiClipboardAccess_UI_CLIPBOARD_ACCESS_READ UiClipboardAccess = 2 + // The sandbox may write host clipboard contents. + UiClipboardAccess_UI_CLIPBOARD_ACCESS_WRITE UiClipboardAccess = 3 + // The sandbox may read and write host clipboard contents. + UiClipboardAccess_UI_CLIPBOARD_ACCESS_ALL UiClipboardAccess = 4 +) + +// Enum value maps for UiClipboardAccess. +var ( + UiClipboardAccess_name = map[int32]string{ + 0: "UI_CLIPBOARD_ACCESS_UNSPECIFIED", + 1: "UI_CLIPBOARD_ACCESS_NONE", + 2: "UI_CLIPBOARD_ACCESS_READ", + 3: "UI_CLIPBOARD_ACCESS_WRITE", + 4: "UI_CLIPBOARD_ACCESS_ALL", + } + UiClipboardAccess_value = map[string]int32{ + "UI_CLIPBOARD_ACCESS_UNSPECIFIED": 0, + "UI_CLIPBOARD_ACCESS_NONE": 1, + "UI_CLIPBOARD_ACCESS_READ": 2, + "UI_CLIPBOARD_ACCESS_WRITE": 3, + "UI_CLIPBOARD_ACCESS_ALL": 4, + } +) + +func (x UiClipboardAccess) Enum() *UiClipboardAccess { + p := new(UiClipboardAccess) + *p = x + return p +} + +func (x UiClipboardAccess) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (UiClipboardAccess) Descriptor() protoreflect.EnumDescriptor { + return file_sandbox_proto_enumTypes[0].Descriptor() +} + +func (UiClipboardAccess) Type() protoreflect.EnumType { + return &file_sandbox_proto_enumTypes[0] +} + +func (x UiClipboardAccess) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use UiClipboardAccess.Descriptor instead. +func (UiClipboardAccess) EnumDescriptor() ([]byte, []int) { + return file_sandbox_proto_rawDescGZIP(), []int{0} +} + // Scope that currently controls a setting. type SettingScope int32 @@ -60,11 +121,11 @@ func (x SettingScope) String() string { } func (SettingScope) Descriptor() protoreflect.EnumDescriptor { - return file_sandbox_proto_enumTypes[0].Descriptor() + return file_sandbox_proto_enumTypes[1].Descriptor() } func (SettingScope) Type() protoreflect.EnumType { - return &file_sandbox_proto_enumTypes[0] + return &file_sandbox_proto_enumTypes[1] } func (x SettingScope) Number() protoreflect.EnumNumber { @@ -73,7 +134,7 @@ func (x SettingScope) Number() protoreflect.EnumNumber { // Deprecated: Use SettingScope.Descriptor instead. func (SettingScope) EnumDescriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{0} + return file_sandbox_proto_rawDescGZIP(), []int{1} } // Source used for the policy payload in GetSandboxConfigResponse. @@ -110,11 +171,11 @@ func (x PolicySource) String() string { } func (PolicySource) Descriptor() protoreflect.EnumDescriptor { - return file_sandbox_proto_enumTypes[1].Descriptor() + return file_sandbox_proto_enumTypes[2].Descriptor() } func (PolicySource) Type() protoreflect.EnumType { - return &file_sandbox_proto_enumTypes[1] + return &file_sandbox_proto_enumTypes[2] } func (x PolicySource) Number() protoreflect.EnumNumber { @@ -123,7 +184,7 @@ func (x PolicySource) Number() protoreflect.EnumNumber { // Deprecated: Use PolicySource.Descriptor instead. func (PolicySource) EnumDescriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{1} + return file_sandbox_proto_rawDescGZIP(), []int{2} } // Sandbox security policy configuration. @@ -143,8 +204,12 @@ type SandboxPolicy struct { // policy-local names. At most 10 configs are accepted, and at most 10 stages // can be selected per request. NetworkMiddlewares map[string]*NetworkMiddlewareConfig `protobuf:"bytes,6,rep,name=network_middlewares,json=networkMiddlewares,proto3" json:"network_middlewares,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Static, platform-neutral user-interface access policy. Within an explicit + // section, omitted capabilities deny. Omitting the section preserves the + // compute platform's existing behavior. + Ui *UiPolicy `protobuf:"bytes,7,opt,name=ui,proto3" json:"ui,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *SandboxPolicy) Reset() { @@ -219,6 +284,13 @@ func (x *SandboxPolicy) GetNetworkMiddlewares() map[string]*NetworkMiddlewareCon return nil } +func (x *SandboxPolicy) GetUi() *UiPolicy { + if x != nil { + return x.Ui + } + return nil +} + // Filesystem access policy. type FilesystemPolicy struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -384,6 +456,72 @@ func (x *ProcessPolicy) GetRunAsGroup() string { return "" } +// Platform-neutral user-interface capabilities. Every omitted field in an +// explicit policy defaults to deny. Compute platforms without complete support +// reject the entire explicit policy before provisioning. +type UiPolicy struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Allow the sandbox to display graphical windows. + AllowGraphicalUi bool `protobuf:"varint,1,opt,name=allow_graphical_ui,json=allowGraphicalUi,proto3" json:"allow_graphical_ui,omitempty"` + // Directional host clipboard access. + Clipboard UiClipboardAccess `protobuf:"varint,2,opt,name=clipboard,proto3,enum=openshell.sandbox.v1.UiClipboardAccess" json:"clipboard,omitempty"` + // Allow the sandbox to synthesize keyboard or pointer input. + AllowInputInjection bool `protobuf:"varint,3,opt,name=allow_input_injection,json=allowInputInjection,proto3" json:"allow_input_injection,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UiPolicy) Reset() { + *x = UiPolicy{} + mi := &file_sandbox_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UiPolicy) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UiPolicy) ProtoMessage() {} + +func (x *UiPolicy) ProtoReflect() protoreflect.Message { + mi := &file_sandbox_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UiPolicy.ProtoReflect.Descriptor instead. +func (*UiPolicy) Descriptor() ([]byte, []int) { + return file_sandbox_proto_rawDescGZIP(), []int{4} +} + +func (x *UiPolicy) GetAllowGraphicalUi() bool { + if x != nil { + return x.AllowGraphicalUi + } + return false +} + +func (x *UiPolicy) GetClipboard() UiClipboardAccess { + if x != nil { + return x.Clipboard + } + return UiClipboardAccess_UI_CLIPBOARD_ACCESS_UNSPECIFIED +} + +func (x *UiPolicy) GetAllowInputInjection() bool { + if x != nil { + return x.AllowInputInjection + } + return false +} + // A named network access policy rule. type NetworkPolicyRule struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -399,7 +537,7 @@ type NetworkPolicyRule struct { func (x *NetworkPolicyRule) Reset() { *x = NetworkPolicyRule{} - mi := &file_sandbox_proto_msgTypes[4] + mi := &file_sandbox_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -411,7 +549,7 @@ func (x *NetworkPolicyRule) String() string { func (*NetworkPolicyRule) ProtoMessage() {} func (x *NetworkPolicyRule) ProtoReflect() protoreflect.Message { - mi := &file_sandbox_proto_msgTypes[4] + mi := &file_sandbox_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -424,7 +562,7 @@ func (x *NetworkPolicyRule) ProtoReflect() protoreflect.Message { // Deprecated: Use NetworkPolicyRule.ProtoReflect.Descriptor instead. func (*NetworkPolicyRule) Descriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{4} + return file_sandbox_proto_rawDescGZIP(), []int{5} } func (x *NetworkPolicyRule) GetName() string { @@ -469,7 +607,7 @@ type NetworkMiddlewareConfig struct { func (x *NetworkMiddlewareConfig) Reset() { *x = NetworkMiddlewareConfig{} - mi := &file_sandbox_proto_msgTypes[5] + mi := &file_sandbox_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -481,7 +619,7 @@ func (x *NetworkMiddlewareConfig) String() string { func (*NetworkMiddlewareConfig) ProtoMessage() {} func (x *NetworkMiddlewareConfig) ProtoReflect() protoreflect.Message { - mi := &file_sandbox_proto_msgTypes[5] + mi := &file_sandbox_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -494,7 +632,7 @@ func (x *NetworkMiddlewareConfig) ProtoReflect() protoreflect.Message { // Deprecated: Use NetworkMiddlewareConfig.ProtoReflect.Descriptor instead. func (*NetworkMiddlewareConfig) Descriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{5} + return file_sandbox_proto_rawDescGZIP(), []int{6} } func (x *NetworkMiddlewareConfig) GetName() string { @@ -554,7 +692,7 @@ type MiddlewareEndpointSelector struct { func (x *MiddlewareEndpointSelector) Reset() { *x = MiddlewareEndpointSelector{} - mi := &file_sandbox_proto_msgTypes[6] + mi := &file_sandbox_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -566,7 +704,7 @@ func (x *MiddlewareEndpointSelector) String() string { func (*MiddlewareEndpointSelector) ProtoMessage() {} func (x *MiddlewareEndpointSelector) ProtoReflect() protoreflect.Message { - mi := &file_sandbox_proto_msgTypes[6] + mi := &file_sandbox_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -579,7 +717,7 @@ func (x *MiddlewareEndpointSelector) ProtoReflect() protoreflect.Message { // Deprecated: Use MiddlewareEndpointSelector.ProtoReflect.Descriptor instead. func (*MiddlewareEndpointSelector) Descriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{6} + return file_sandbox_proto_rawDescGZIP(), []int{7} } func (x *MiddlewareEndpointSelector) GetInclude() []string { @@ -608,7 +746,7 @@ type NetworkCredentialBinding struct { func (x *NetworkCredentialBinding) Reset() { *x = NetworkCredentialBinding{} - mi := &file_sandbox_proto_msgTypes[7] + mi := &file_sandbox_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -620,7 +758,7 @@ func (x *NetworkCredentialBinding) String() string { func (*NetworkCredentialBinding) ProtoMessage() {} func (x *NetworkCredentialBinding) ProtoReflect() protoreflect.Message { - mi := &file_sandbox_proto_msgTypes[7] + mi := &file_sandbox_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -633,7 +771,7 @@ func (x *NetworkCredentialBinding) ProtoReflect() protoreflect.Message { // Deprecated: Use NetworkCredentialBinding.ProtoReflect.Descriptor instead. func (*NetworkCredentialBinding) Descriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{7} + return file_sandbox_proto_rawDescGZIP(), []int{8} } func (x *NetworkCredentialBinding) GetProvider() string { @@ -745,7 +883,7 @@ type NetworkEndpoint struct { func (x *NetworkEndpoint) Reset() { *x = NetworkEndpoint{} - mi := &file_sandbox_proto_msgTypes[8] + mi := &file_sandbox_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -757,7 +895,7 @@ func (x *NetworkEndpoint) String() string { func (*NetworkEndpoint) ProtoMessage() {} func (x *NetworkEndpoint) ProtoReflect() protoreflect.Message { - mi := &file_sandbox_proto_msgTypes[8] + mi := &file_sandbox_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -770,7 +908,7 @@ func (x *NetworkEndpoint) ProtoReflect() protoreflect.Message { // Deprecated: Use NetworkEndpoint.ProtoReflect.Descriptor instead. func (*NetworkEndpoint) Descriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{8} + return file_sandbox_proto_rawDescGZIP(), []int{9} } func (x *NetworkEndpoint) GetHost() string { @@ -996,7 +1134,7 @@ type McpOptions struct { func (x *McpOptions) Reset() { *x = McpOptions{} - mi := &file_sandbox_proto_msgTypes[9] + mi := &file_sandbox_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1008,7 +1146,7 @@ func (x *McpOptions) String() string { func (*McpOptions) ProtoMessage() {} func (x *McpOptions) ProtoReflect() protoreflect.Message { - mi := &file_sandbox_proto_msgTypes[9] + mi := &file_sandbox_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1021,7 +1159,7 @@ func (x *McpOptions) ProtoReflect() protoreflect.Message { // Deprecated: Use McpOptions.ProtoReflect.Descriptor instead. func (*McpOptions) Descriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{9} + return file_sandbox_proto_rawDescGZIP(), []int{10} } func (x *McpOptions) GetStrictToolNames() bool { @@ -1060,7 +1198,7 @@ type GraphqlOperation struct { func (x *GraphqlOperation) Reset() { *x = GraphqlOperation{} - mi := &file_sandbox_proto_msgTypes[10] + mi := &file_sandbox_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1072,7 +1210,7 @@ func (x *GraphqlOperation) String() string { func (*GraphqlOperation) ProtoMessage() {} func (x *GraphqlOperation) ProtoReflect() protoreflect.Message { - mi := &file_sandbox_proto_msgTypes[10] + mi := &file_sandbox_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1085,7 +1223,7 @@ func (x *GraphqlOperation) ProtoReflect() protoreflect.Message { // Deprecated: Use GraphqlOperation.ProtoReflect.Descriptor instead. func (*GraphqlOperation) Descriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{10} + return file_sandbox_proto_rawDescGZIP(), []int{11} } func (x *GraphqlOperation) GetOperationType() string { @@ -1140,7 +1278,7 @@ type L7DenyRule struct { func (x *L7DenyRule) Reset() { *x = L7DenyRule{} - mi := &file_sandbox_proto_msgTypes[11] + mi := &file_sandbox_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1152,7 +1290,7 @@ func (x *L7DenyRule) String() string { func (*L7DenyRule) ProtoMessage() {} func (x *L7DenyRule) ProtoReflect() protoreflect.Message { - mi := &file_sandbox_proto_msgTypes[11] + mi := &file_sandbox_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1165,7 +1303,7 @@ func (x *L7DenyRule) ProtoReflect() protoreflect.Message { // Deprecated: Use L7DenyRule.ProtoReflect.Descriptor instead. func (*L7DenyRule) Descriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{11} + return file_sandbox_proto_rawDescGZIP(), []int{12} } func (x *L7DenyRule) GetMethod() string { @@ -1234,7 +1372,7 @@ type L7Rule struct { func (x *L7Rule) Reset() { *x = L7Rule{} - mi := &file_sandbox_proto_msgTypes[12] + mi := &file_sandbox_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1246,7 +1384,7 @@ func (x *L7Rule) String() string { func (*L7Rule) ProtoMessage() {} func (x *L7Rule) ProtoReflect() protoreflect.Message { - mi := &file_sandbox_proto_msgTypes[12] + mi := &file_sandbox_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1259,7 +1397,7 @@ func (x *L7Rule) ProtoReflect() protoreflect.Message { // Deprecated: Use L7Rule.ProtoReflect.Descriptor instead. func (*L7Rule) Descriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{12} + return file_sandbox_proto_rawDescGZIP(), []int{13} } func (x *L7Rule) GetAllow() *L7Allow { @@ -1299,7 +1437,7 @@ type L7Allow struct { func (x *L7Allow) Reset() { *x = L7Allow{} - mi := &file_sandbox_proto_msgTypes[13] + mi := &file_sandbox_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1311,7 +1449,7 @@ func (x *L7Allow) String() string { func (*L7Allow) ProtoMessage() {} func (x *L7Allow) ProtoReflect() protoreflect.Message { - mi := &file_sandbox_proto_msgTypes[13] + mi := &file_sandbox_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1324,7 +1462,7 @@ func (x *L7Allow) ProtoReflect() protoreflect.Message { // Deprecated: Use L7Allow.ProtoReflect.Descriptor instead. func (*L7Allow) Descriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{13} + return file_sandbox_proto_rawDescGZIP(), []int{14} } func (x *L7Allow) GetMethod() string { @@ -1396,7 +1534,7 @@ type L7QueryMatcher struct { func (x *L7QueryMatcher) Reset() { *x = L7QueryMatcher{} - mi := &file_sandbox_proto_msgTypes[14] + mi := &file_sandbox_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1408,7 +1546,7 @@ func (x *L7QueryMatcher) String() string { func (*L7QueryMatcher) ProtoMessage() {} func (x *L7QueryMatcher) ProtoReflect() protoreflect.Message { - mi := &file_sandbox_proto_msgTypes[14] + mi := &file_sandbox_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1421,7 +1559,7 @@ func (x *L7QueryMatcher) ProtoReflect() protoreflect.Message { // Deprecated: Use L7QueryMatcher.ProtoReflect.Descriptor instead. func (*L7QueryMatcher) Descriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{14} + return file_sandbox_proto_rawDescGZIP(), []int{15} } func (x *L7QueryMatcher) GetGlob() string { @@ -1448,7 +1586,7 @@ type NetworkBinary struct { func (x *NetworkBinary) Reset() { *x = NetworkBinary{} - mi := &file_sandbox_proto_msgTypes[15] + mi := &file_sandbox_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1460,7 +1598,7 @@ func (x *NetworkBinary) String() string { func (*NetworkBinary) ProtoMessage() {} func (x *NetworkBinary) ProtoReflect() protoreflect.Message { - mi := &file_sandbox_proto_msgTypes[15] + mi := &file_sandbox_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1473,7 +1611,7 @@ func (x *NetworkBinary) ProtoReflect() protoreflect.Message { // Deprecated: Use NetworkBinary.ProtoReflect.Descriptor instead. func (*NetworkBinary) Descriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{15} + return file_sandbox_proto_rawDescGZIP(), []int{16} } func (x *NetworkBinary) GetPath() string { @@ -1494,7 +1632,7 @@ type GetSandboxConfigRequest struct { func (x *GetSandboxConfigRequest) Reset() { *x = GetSandboxConfigRequest{} - mi := &file_sandbox_proto_msgTypes[16] + mi := &file_sandbox_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1506,7 +1644,7 @@ func (x *GetSandboxConfigRequest) String() string { func (*GetSandboxConfigRequest) ProtoMessage() {} func (x *GetSandboxConfigRequest) ProtoReflect() protoreflect.Message { - mi := &file_sandbox_proto_msgTypes[16] + mi := &file_sandbox_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1519,7 +1657,7 @@ func (x *GetSandboxConfigRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetSandboxConfigRequest.ProtoReflect.Descriptor instead. func (*GetSandboxConfigRequest) Descriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{16} + return file_sandbox_proto_rawDescGZIP(), []int{17} } func (x *GetSandboxConfigRequest) GetSandboxId() string { @@ -1538,7 +1676,7 @@ type GetGatewayConfigRequest struct { func (x *GetGatewayConfigRequest) Reset() { *x = GetGatewayConfigRequest{} - mi := &file_sandbox_proto_msgTypes[17] + mi := &file_sandbox_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1550,7 +1688,7 @@ func (x *GetGatewayConfigRequest) String() string { func (*GetGatewayConfigRequest) ProtoMessage() {} func (x *GetGatewayConfigRequest) ProtoReflect() protoreflect.Message { - mi := &file_sandbox_proto_msgTypes[17] + mi := &file_sandbox_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1563,7 +1701,7 @@ func (x *GetGatewayConfigRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetGatewayConfigRequest.ProtoReflect.Descriptor instead. func (*GetGatewayConfigRequest) Descriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{17} + return file_sandbox_proto_rawDescGZIP(), []int{18} } // Response containing gateway-global settings. @@ -1580,7 +1718,7 @@ type GetGatewayConfigResponse struct { func (x *GetGatewayConfigResponse) Reset() { *x = GetGatewayConfigResponse{} - mi := &file_sandbox_proto_msgTypes[18] + mi := &file_sandbox_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1592,7 +1730,7 @@ func (x *GetGatewayConfigResponse) String() string { func (*GetGatewayConfigResponse) ProtoMessage() {} func (x *GetGatewayConfigResponse) ProtoReflect() protoreflect.Message { - mi := &file_sandbox_proto_msgTypes[18] + mi := &file_sandbox_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1605,7 +1743,7 @@ func (x *GetGatewayConfigResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetGatewayConfigResponse.ProtoReflect.Descriptor instead. func (*GetGatewayConfigResponse) Descriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{18} + return file_sandbox_proto_rawDescGZIP(), []int{19} } func (x *GetGatewayConfigResponse) GetSettings() map[string]*SettingValue { @@ -1638,7 +1776,7 @@ type SettingValue struct { func (x *SettingValue) Reset() { *x = SettingValue{} - mi := &file_sandbox_proto_msgTypes[19] + mi := &file_sandbox_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1650,7 +1788,7 @@ func (x *SettingValue) String() string { func (*SettingValue) ProtoMessage() {} func (x *SettingValue) ProtoReflect() protoreflect.Message { - mi := &file_sandbox_proto_msgTypes[19] + mi := &file_sandbox_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1663,7 +1801,7 @@ func (x *SettingValue) ProtoReflect() protoreflect.Message { // Deprecated: Use SettingValue.ProtoReflect.Descriptor instead. func (*SettingValue) Descriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{19} + return file_sandbox_proto_rawDescGZIP(), []int{20} } func (x *SettingValue) GetValue() isSettingValue_Value { @@ -1748,7 +1886,7 @@ type EffectiveSetting struct { func (x *EffectiveSetting) Reset() { *x = EffectiveSetting{} - mi := &file_sandbox_proto_msgTypes[20] + mi := &file_sandbox_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1760,7 +1898,7 @@ func (x *EffectiveSetting) String() string { func (*EffectiveSetting) ProtoMessage() {} func (x *EffectiveSetting) ProtoReflect() protoreflect.Message { - mi := &file_sandbox_proto_msgTypes[20] + mi := &file_sandbox_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1773,7 +1911,7 @@ func (x *EffectiveSetting) ProtoReflect() protoreflect.Message { // Deprecated: Use EffectiveSetting.ProtoReflect.Descriptor instead. func (*EffectiveSetting) Descriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{20} + return file_sandbox_proto_rawDescGZIP(), []int{21} } func (x *EffectiveSetting) GetValue() *SettingValue { @@ -1832,7 +1970,7 @@ type GetSandboxConfigResponse struct { func (x *GetSandboxConfigResponse) Reset() { *x = GetSandboxConfigResponse{} - mi := &file_sandbox_proto_msgTypes[21] + mi := &file_sandbox_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1844,7 +1982,7 @@ func (x *GetSandboxConfigResponse) String() string { func (*GetSandboxConfigResponse) ProtoMessage() {} func (x *GetSandboxConfigResponse) ProtoReflect() protoreflect.Message { - mi := &file_sandbox_proto_msgTypes[21] + mi := &file_sandbox_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1857,7 +1995,7 @@ func (x *GetSandboxConfigResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetSandboxConfigResponse.ProtoReflect.Descriptor instead. func (*GetSandboxConfigResponse) Descriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{21} + return file_sandbox_proto_rawDescGZIP(), []int{22} } func (x *GetSandboxConfigResponse) GetPolicy() *SandboxPolicy { @@ -1976,7 +2114,7 @@ type SupervisorMiddlewareService struct { func (x *SupervisorMiddlewareService) Reset() { *x = SupervisorMiddlewareService{} - mi := &file_sandbox_proto_msgTypes[22] + mi := &file_sandbox_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1988,7 +2126,7 @@ func (x *SupervisorMiddlewareService) String() string { func (*SupervisorMiddlewareService) ProtoMessage() {} func (x *SupervisorMiddlewareService) ProtoReflect() protoreflect.Message { - mi := &file_sandbox_proto_msgTypes[22] + mi := &file_sandbox_proto_msgTypes[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2001,7 +2139,7 @@ func (x *SupervisorMiddlewareService) ProtoReflect() protoreflect.Message { // Deprecated: Use SupervisorMiddlewareService.ProtoReflect.Descriptor instead. func (*SupervisorMiddlewareService) Descriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{22} + return file_sandbox_proto_rawDescGZIP(), []int{23} } func (x *SupervisorMiddlewareService) GetName() string { @@ -2057,7 +2195,7 @@ var File_sandbox_proto protoreflect.FileDescriptor const file_sandbox_proto_rawDesc = "" + "\n" + - "\rsandbox.proto\x12\x14openshell.sandbox.v1\x1a\x1cgoogle/protobuf/struct.proto\x1a\x1egoogle/protobuf/duration.proto\"\xa8\x05\n" + + "\rsandbox.proto\x12\x14openshell.sandbox.v1\x1a\x1cgoogle/protobuf/struct.proto\x1a\x1egoogle/protobuf/duration.proto\"\xd8\x05\n" + "\rSandboxPolicy\x12\x18\n" + "\aversion\x18\x01 \x01(\rR\aversion\x12F\n" + "\n" + @@ -2066,7 +2204,8 @@ const file_sandbox_proto_rawDesc = "" + "\blandlock\x18\x03 \x01(\v2$.openshell.sandbox.v1.LandlockPolicyR\blandlock\x12=\n" + "\aprocess\x18\x04 \x01(\v2#.openshell.sandbox.v1.ProcessPolicyR\aprocess\x12c\n" + "\x10network_policies\x18\x05 \x03(\v28.openshell.sandbox.v1.SandboxPolicy.NetworkPoliciesEntryR\x0fnetworkPolicies\x12l\n" + - "\x13network_middlewares\x18\x06 \x03(\v2;.openshell.sandbox.v1.SandboxPolicy.NetworkMiddlewaresEntryR\x12networkMiddlewares\x1ak\n" + + "\x13network_middlewares\x18\x06 \x03(\v2;.openshell.sandbox.v1.SandboxPolicy.NetworkMiddlewaresEntryR\x12networkMiddlewares\x12.\n" + + "\x02ui\x18\a \x01(\v2\x1e.openshell.sandbox.v1.UiPolicyR\x02ui\x1ak\n" + "\x14NetworkPoliciesEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12=\n" + "\x05value\x18\x02 \x01(\v2'.openshell.sandbox.v1.NetworkPolicyRuleR\x05value:\x028\x01\x1at\n" + @@ -2083,7 +2222,11 @@ const file_sandbox_proto_rawDesc = "" + "\rProcessPolicy\x12\x1e\n" + "\vrun_as_user\x18\x01 \x01(\tR\trunAsUser\x12 \n" + "\frun_as_group\x18\x02 \x01(\tR\n" + - "runAsGroup\"\xad\x01\n" + + "runAsGroup\"\xb3\x01\n" + + "\bUiPolicy\x12,\n" + + "\x12allow_graphical_ui\x18\x01 \x01(\bR\x10allowGraphicalUi\x12E\n" + + "\tclipboard\x18\x02 \x01(\x0e2'.openshell.sandbox.v1.UiClipboardAccessR\tclipboard\x122\n" + + "\x15allow_input_injection\x18\x03 \x01(\bR\x13allowInputInjection\"\xad\x01\n" + "\x11NetworkPolicyRule\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12C\n" + "\tendpoints\x18\x02 \x03(\v2%.openshell.sandbox.v1.NetworkEndpointR\tendpoints\x12?\n" + @@ -2233,7 +2376,13 @@ const file_sandbox_proto_rawDesc = "" + "\x0frequest_timeout\x18h \x01(\v2\x19.google.protobuf.DurationR\x0erequestTimeout\x12%\n" + "\x0ftls_ca_cert_pem\x18\x05 \x01(\fR\ftlsCaCertPem\x12\x1a\n" + "\baudience\x18\x06 \x01(\tR\baudience\x128\n" + - "\x18allow_insecure_transport\x18\a \x01(\bR\x16allowInsecureTransportJ\x04\b\x04\x10\x05R\atimeout*b\n" + + "\x18allow_insecure_transport\x18\a \x01(\bR\x16allowInsecureTransportJ\x04\b\x04\x10\x05R\atimeout*\xb0\x01\n" + + "\x11UiClipboardAccess\x12#\n" + + "\x1fUI_CLIPBOARD_ACCESS_UNSPECIFIED\x10\x00\x12\x1c\n" + + "\x18UI_CLIPBOARD_ACCESS_NONE\x10\x01\x12\x1c\n" + + "\x18UI_CLIPBOARD_ACCESS_READ\x10\x02\x12\x1d\n" + + "\x19UI_CLIPBOARD_ACCESS_WRITE\x10\x03\x12\x1b\n" + + "\x17UI_CLIPBOARD_ACCESS_ALL\x10\x04*b\n" + "\fSettingScope\x12\x1d\n" + "\x19SETTING_SCOPE_UNSPECIFIED\x10\x00\x12\x19\n" + "\x15SETTING_SCOPE_SANDBOX\x10\x01\x12\x18\n" + @@ -2255,88 +2404,92 @@ func file_sandbox_proto_rawDescGZIP() []byte { return file_sandbox_proto_rawDescData } -var file_sandbox_proto_enumTypes = make([]protoimpl.EnumInfo, 2) -var file_sandbox_proto_msgTypes = make([]protoimpl.MessageInfo, 32) +var file_sandbox_proto_enumTypes = make([]protoimpl.EnumInfo, 3) +var file_sandbox_proto_msgTypes = make([]protoimpl.MessageInfo, 33) var file_sandbox_proto_goTypes = []any{ - (SettingScope)(0), // 0: openshell.sandbox.v1.SettingScope - (PolicySource)(0), // 1: openshell.sandbox.v1.PolicySource - (*SandboxPolicy)(nil), // 2: openshell.sandbox.v1.SandboxPolicy - (*FilesystemPolicy)(nil), // 3: openshell.sandbox.v1.FilesystemPolicy - (*LandlockPolicy)(nil), // 4: openshell.sandbox.v1.LandlockPolicy - (*ProcessPolicy)(nil), // 5: openshell.sandbox.v1.ProcessPolicy - (*NetworkPolicyRule)(nil), // 6: openshell.sandbox.v1.NetworkPolicyRule - (*NetworkMiddlewareConfig)(nil), // 7: openshell.sandbox.v1.NetworkMiddlewareConfig - (*MiddlewareEndpointSelector)(nil), // 8: openshell.sandbox.v1.MiddlewareEndpointSelector - (*NetworkCredentialBinding)(nil), // 9: openshell.sandbox.v1.NetworkCredentialBinding - (*NetworkEndpoint)(nil), // 10: openshell.sandbox.v1.NetworkEndpoint - (*McpOptions)(nil), // 11: openshell.sandbox.v1.McpOptions - (*GraphqlOperation)(nil), // 12: openshell.sandbox.v1.GraphqlOperation - (*L7DenyRule)(nil), // 13: openshell.sandbox.v1.L7DenyRule - (*L7Rule)(nil), // 14: openshell.sandbox.v1.L7Rule - (*L7Allow)(nil), // 15: openshell.sandbox.v1.L7Allow - (*L7QueryMatcher)(nil), // 16: openshell.sandbox.v1.L7QueryMatcher - (*NetworkBinary)(nil), // 17: openshell.sandbox.v1.NetworkBinary - (*GetSandboxConfigRequest)(nil), // 18: openshell.sandbox.v1.GetSandboxConfigRequest - (*GetGatewayConfigRequest)(nil), // 19: openshell.sandbox.v1.GetGatewayConfigRequest - (*GetGatewayConfigResponse)(nil), // 20: openshell.sandbox.v1.GetGatewayConfigResponse - (*SettingValue)(nil), // 21: openshell.sandbox.v1.SettingValue - (*EffectiveSetting)(nil), // 22: openshell.sandbox.v1.EffectiveSetting - (*GetSandboxConfigResponse)(nil), // 23: openshell.sandbox.v1.GetSandboxConfigResponse - (*SupervisorMiddlewareService)(nil), // 24: openshell.sandbox.v1.SupervisorMiddlewareService - nil, // 25: openshell.sandbox.v1.SandboxPolicy.NetworkPoliciesEntry - nil, // 26: openshell.sandbox.v1.SandboxPolicy.NetworkMiddlewaresEntry - nil, // 27: openshell.sandbox.v1.NetworkEndpoint.GraphqlPersistedQueriesEntry - nil, // 28: openshell.sandbox.v1.L7DenyRule.QueryEntry - nil, // 29: openshell.sandbox.v1.L7DenyRule.ParamsEntry - nil, // 30: openshell.sandbox.v1.L7Allow.QueryEntry - nil, // 31: openshell.sandbox.v1.L7Allow.ParamsEntry - nil, // 32: openshell.sandbox.v1.GetGatewayConfigResponse.SettingsEntry - nil, // 33: openshell.sandbox.v1.GetSandboxConfigResponse.SettingsEntry - (*structpb.Struct)(nil), // 34: google.protobuf.Struct - (*durationpb.Duration)(nil), // 35: google.protobuf.Duration + (UiClipboardAccess)(0), // 0: openshell.sandbox.v1.UiClipboardAccess + (SettingScope)(0), // 1: openshell.sandbox.v1.SettingScope + (PolicySource)(0), // 2: openshell.sandbox.v1.PolicySource + (*SandboxPolicy)(nil), // 3: openshell.sandbox.v1.SandboxPolicy + (*FilesystemPolicy)(nil), // 4: openshell.sandbox.v1.FilesystemPolicy + (*LandlockPolicy)(nil), // 5: openshell.sandbox.v1.LandlockPolicy + (*ProcessPolicy)(nil), // 6: openshell.sandbox.v1.ProcessPolicy + (*UiPolicy)(nil), // 7: openshell.sandbox.v1.UiPolicy + (*NetworkPolicyRule)(nil), // 8: openshell.sandbox.v1.NetworkPolicyRule + (*NetworkMiddlewareConfig)(nil), // 9: openshell.sandbox.v1.NetworkMiddlewareConfig + (*MiddlewareEndpointSelector)(nil), // 10: openshell.sandbox.v1.MiddlewareEndpointSelector + (*NetworkCredentialBinding)(nil), // 11: openshell.sandbox.v1.NetworkCredentialBinding + (*NetworkEndpoint)(nil), // 12: openshell.sandbox.v1.NetworkEndpoint + (*McpOptions)(nil), // 13: openshell.sandbox.v1.McpOptions + (*GraphqlOperation)(nil), // 14: openshell.sandbox.v1.GraphqlOperation + (*L7DenyRule)(nil), // 15: openshell.sandbox.v1.L7DenyRule + (*L7Rule)(nil), // 16: openshell.sandbox.v1.L7Rule + (*L7Allow)(nil), // 17: openshell.sandbox.v1.L7Allow + (*L7QueryMatcher)(nil), // 18: openshell.sandbox.v1.L7QueryMatcher + (*NetworkBinary)(nil), // 19: openshell.sandbox.v1.NetworkBinary + (*GetSandboxConfigRequest)(nil), // 20: openshell.sandbox.v1.GetSandboxConfigRequest + (*GetGatewayConfigRequest)(nil), // 21: openshell.sandbox.v1.GetGatewayConfigRequest + (*GetGatewayConfigResponse)(nil), // 22: openshell.sandbox.v1.GetGatewayConfigResponse + (*SettingValue)(nil), // 23: openshell.sandbox.v1.SettingValue + (*EffectiveSetting)(nil), // 24: openshell.sandbox.v1.EffectiveSetting + (*GetSandboxConfigResponse)(nil), // 25: openshell.sandbox.v1.GetSandboxConfigResponse + (*SupervisorMiddlewareService)(nil), // 26: openshell.sandbox.v1.SupervisorMiddlewareService + nil, // 27: openshell.sandbox.v1.SandboxPolicy.NetworkPoliciesEntry + nil, // 28: openshell.sandbox.v1.SandboxPolicy.NetworkMiddlewaresEntry + nil, // 29: openshell.sandbox.v1.NetworkEndpoint.GraphqlPersistedQueriesEntry + nil, // 30: openshell.sandbox.v1.L7DenyRule.QueryEntry + nil, // 31: openshell.sandbox.v1.L7DenyRule.ParamsEntry + nil, // 32: openshell.sandbox.v1.L7Allow.QueryEntry + nil, // 33: openshell.sandbox.v1.L7Allow.ParamsEntry + nil, // 34: openshell.sandbox.v1.GetGatewayConfigResponse.SettingsEntry + nil, // 35: openshell.sandbox.v1.GetSandboxConfigResponse.SettingsEntry + (*structpb.Struct)(nil), // 36: google.protobuf.Struct + (*durationpb.Duration)(nil), // 37: google.protobuf.Duration } var file_sandbox_proto_depIdxs = []int32{ - 3, // 0: openshell.sandbox.v1.SandboxPolicy.filesystem:type_name -> openshell.sandbox.v1.FilesystemPolicy - 4, // 1: openshell.sandbox.v1.SandboxPolicy.landlock:type_name -> openshell.sandbox.v1.LandlockPolicy - 5, // 2: openshell.sandbox.v1.SandboxPolicy.process:type_name -> openshell.sandbox.v1.ProcessPolicy - 25, // 3: openshell.sandbox.v1.SandboxPolicy.network_policies:type_name -> openshell.sandbox.v1.SandboxPolicy.NetworkPoliciesEntry - 26, // 4: openshell.sandbox.v1.SandboxPolicy.network_middlewares:type_name -> openshell.sandbox.v1.SandboxPolicy.NetworkMiddlewaresEntry - 10, // 5: openshell.sandbox.v1.NetworkPolicyRule.endpoints:type_name -> openshell.sandbox.v1.NetworkEndpoint - 17, // 6: openshell.sandbox.v1.NetworkPolicyRule.binaries:type_name -> openshell.sandbox.v1.NetworkBinary - 34, // 7: openshell.sandbox.v1.NetworkMiddlewareConfig.config:type_name -> google.protobuf.Struct - 8, // 8: openshell.sandbox.v1.NetworkMiddlewareConfig.endpoints:type_name -> openshell.sandbox.v1.MiddlewareEndpointSelector - 14, // 9: openshell.sandbox.v1.NetworkEndpoint.rules:type_name -> openshell.sandbox.v1.L7Rule - 13, // 10: openshell.sandbox.v1.NetworkEndpoint.deny_rules:type_name -> openshell.sandbox.v1.L7DenyRule - 27, // 11: openshell.sandbox.v1.NetworkEndpoint.graphql_persisted_queries:type_name -> openshell.sandbox.v1.NetworkEndpoint.GraphqlPersistedQueriesEntry - 11, // 12: openshell.sandbox.v1.NetworkEndpoint.mcp:type_name -> openshell.sandbox.v1.McpOptions - 9, // 13: openshell.sandbox.v1.NetworkEndpoint.credential_binding:type_name -> openshell.sandbox.v1.NetworkCredentialBinding - 28, // 14: openshell.sandbox.v1.L7DenyRule.query:type_name -> openshell.sandbox.v1.L7DenyRule.QueryEntry - 29, // 15: openshell.sandbox.v1.L7DenyRule.params:type_name -> openshell.sandbox.v1.L7DenyRule.ParamsEntry - 15, // 16: openshell.sandbox.v1.L7Rule.allow:type_name -> openshell.sandbox.v1.L7Allow - 30, // 17: openshell.sandbox.v1.L7Allow.query:type_name -> openshell.sandbox.v1.L7Allow.QueryEntry - 31, // 18: openshell.sandbox.v1.L7Allow.params:type_name -> openshell.sandbox.v1.L7Allow.ParamsEntry - 32, // 19: openshell.sandbox.v1.GetGatewayConfigResponse.settings:type_name -> openshell.sandbox.v1.GetGatewayConfigResponse.SettingsEntry - 21, // 20: openshell.sandbox.v1.EffectiveSetting.value:type_name -> openshell.sandbox.v1.SettingValue - 0, // 21: openshell.sandbox.v1.EffectiveSetting.scope:type_name -> openshell.sandbox.v1.SettingScope - 2, // 22: openshell.sandbox.v1.GetSandboxConfigResponse.policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 33, // 23: openshell.sandbox.v1.GetSandboxConfigResponse.settings:type_name -> openshell.sandbox.v1.GetSandboxConfigResponse.SettingsEntry - 1, // 24: openshell.sandbox.v1.GetSandboxConfigResponse.policy_source:type_name -> openshell.sandbox.v1.PolicySource - 24, // 25: openshell.sandbox.v1.GetSandboxConfigResponse.supervisor_middleware_services:type_name -> openshell.sandbox.v1.SupervisorMiddlewareService - 35, // 26: openshell.sandbox.v1.SupervisorMiddlewareService.request_timeout:type_name -> google.protobuf.Duration - 6, // 27: openshell.sandbox.v1.SandboxPolicy.NetworkPoliciesEntry.value:type_name -> openshell.sandbox.v1.NetworkPolicyRule - 7, // 28: openshell.sandbox.v1.SandboxPolicy.NetworkMiddlewaresEntry.value:type_name -> openshell.sandbox.v1.NetworkMiddlewareConfig - 12, // 29: openshell.sandbox.v1.NetworkEndpoint.GraphqlPersistedQueriesEntry.value:type_name -> openshell.sandbox.v1.GraphqlOperation - 16, // 30: openshell.sandbox.v1.L7DenyRule.QueryEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher - 16, // 31: openshell.sandbox.v1.L7DenyRule.ParamsEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher - 16, // 32: openshell.sandbox.v1.L7Allow.QueryEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher - 16, // 33: openshell.sandbox.v1.L7Allow.ParamsEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher - 21, // 34: openshell.sandbox.v1.GetGatewayConfigResponse.SettingsEntry.value:type_name -> openshell.sandbox.v1.SettingValue - 22, // 35: openshell.sandbox.v1.GetSandboxConfigResponse.SettingsEntry.value:type_name -> openshell.sandbox.v1.EffectiveSetting - 36, // [36:36] is the sub-list for method output_type - 36, // [36:36] is the sub-list for method input_type - 36, // [36:36] is the sub-list for extension type_name - 36, // [36:36] is the sub-list for extension extendee - 0, // [0:36] is the sub-list for field type_name + 4, // 0: openshell.sandbox.v1.SandboxPolicy.filesystem:type_name -> openshell.sandbox.v1.FilesystemPolicy + 5, // 1: openshell.sandbox.v1.SandboxPolicy.landlock:type_name -> openshell.sandbox.v1.LandlockPolicy + 6, // 2: openshell.sandbox.v1.SandboxPolicy.process:type_name -> openshell.sandbox.v1.ProcessPolicy + 27, // 3: openshell.sandbox.v1.SandboxPolicy.network_policies:type_name -> openshell.sandbox.v1.SandboxPolicy.NetworkPoliciesEntry + 28, // 4: openshell.sandbox.v1.SandboxPolicy.network_middlewares:type_name -> openshell.sandbox.v1.SandboxPolicy.NetworkMiddlewaresEntry + 7, // 5: openshell.sandbox.v1.SandboxPolicy.ui:type_name -> openshell.sandbox.v1.UiPolicy + 0, // 6: openshell.sandbox.v1.UiPolicy.clipboard:type_name -> openshell.sandbox.v1.UiClipboardAccess + 12, // 7: openshell.sandbox.v1.NetworkPolicyRule.endpoints:type_name -> openshell.sandbox.v1.NetworkEndpoint + 19, // 8: openshell.sandbox.v1.NetworkPolicyRule.binaries:type_name -> openshell.sandbox.v1.NetworkBinary + 36, // 9: openshell.sandbox.v1.NetworkMiddlewareConfig.config:type_name -> google.protobuf.Struct + 10, // 10: openshell.sandbox.v1.NetworkMiddlewareConfig.endpoints:type_name -> openshell.sandbox.v1.MiddlewareEndpointSelector + 16, // 11: openshell.sandbox.v1.NetworkEndpoint.rules:type_name -> openshell.sandbox.v1.L7Rule + 15, // 12: openshell.sandbox.v1.NetworkEndpoint.deny_rules:type_name -> openshell.sandbox.v1.L7DenyRule + 29, // 13: openshell.sandbox.v1.NetworkEndpoint.graphql_persisted_queries:type_name -> openshell.sandbox.v1.NetworkEndpoint.GraphqlPersistedQueriesEntry + 13, // 14: openshell.sandbox.v1.NetworkEndpoint.mcp:type_name -> openshell.sandbox.v1.McpOptions + 11, // 15: openshell.sandbox.v1.NetworkEndpoint.credential_binding:type_name -> openshell.sandbox.v1.NetworkCredentialBinding + 30, // 16: openshell.sandbox.v1.L7DenyRule.query:type_name -> openshell.sandbox.v1.L7DenyRule.QueryEntry + 31, // 17: openshell.sandbox.v1.L7DenyRule.params:type_name -> openshell.sandbox.v1.L7DenyRule.ParamsEntry + 17, // 18: openshell.sandbox.v1.L7Rule.allow:type_name -> openshell.sandbox.v1.L7Allow + 32, // 19: openshell.sandbox.v1.L7Allow.query:type_name -> openshell.sandbox.v1.L7Allow.QueryEntry + 33, // 20: openshell.sandbox.v1.L7Allow.params:type_name -> openshell.sandbox.v1.L7Allow.ParamsEntry + 34, // 21: openshell.sandbox.v1.GetGatewayConfigResponse.settings:type_name -> openshell.sandbox.v1.GetGatewayConfigResponse.SettingsEntry + 23, // 22: openshell.sandbox.v1.EffectiveSetting.value:type_name -> openshell.sandbox.v1.SettingValue + 1, // 23: openshell.sandbox.v1.EffectiveSetting.scope:type_name -> openshell.sandbox.v1.SettingScope + 3, // 24: openshell.sandbox.v1.GetSandboxConfigResponse.policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 35, // 25: openshell.sandbox.v1.GetSandboxConfigResponse.settings:type_name -> openshell.sandbox.v1.GetSandboxConfigResponse.SettingsEntry + 2, // 26: openshell.sandbox.v1.GetSandboxConfigResponse.policy_source:type_name -> openshell.sandbox.v1.PolicySource + 26, // 27: openshell.sandbox.v1.GetSandboxConfigResponse.supervisor_middleware_services:type_name -> openshell.sandbox.v1.SupervisorMiddlewareService + 37, // 28: openshell.sandbox.v1.SupervisorMiddlewareService.request_timeout:type_name -> google.protobuf.Duration + 8, // 29: openshell.sandbox.v1.SandboxPolicy.NetworkPoliciesEntry.value:type_name -> openshell.sandbox.v1.NetworkPolicyRule + 9, // 30: openshell.sandbox.v1.SandboxPolicy.NetworkMiddlewaresEntry.value:type_name -> openshell.sandbox.v1.NetworkMiddlewareConfig + 14, // 31: openshell.sandbox.v1.NetworkEndpoint.GraphqlPersistedQueriesEntry.value:type_name -> openshell.sandbox.v1.GraphqlOperation + 18, // 32: openshell.sandbox.v1.L7DenyRule.QueryEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher + 18, // 33: openshell.sandbox.v1.L7DenyRule.ParamsEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher + 18, // 34: openshell.sandbox.v1.L7Allow.QueryEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher + 18, // 35: openshell.sandbox.v1.L7Allow.ParamsEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher + 23, // 36: openshell.sandbox.v1.GetGatewayConfigResponse.SettingsEntry.value:type_name -> openshell.sandbox.v1.SettingValue + 24, // 37: openshell.sandbox.v1.GetSandboxConfigResponse.SettingsEntry.value:type_name -> openshell.sandbox.v1.EffectiveSetting + 38, // [38:38] is the sub-list for method output_type + 38, // [38:38] is the sub-list for method input_type + 38, // [38:38] is the sub-list for extension type_name + 38, // [38:38] is the sub-list for extension extendee + 0, // [0:38] is the sub-list for field type_name } func init() { file_sandbox_proto_init() } @@ -2344,8 +2497,8 @@ func file_sandbox_proto_init() { if File_sandbox_proto != nil { return } - file_sandbox_proto_msgTypes[9].OneofWrappers = []any{} - file_sandbox_proto_msgTypes[19].OneofWrappers = []any{ + file_sandbox_proto_msgTypes[10].OneofWrappers = []any{} + file_sandbox_proto_msgTypes[20].OneofWrappers = []any{ (*SettingValue_StringValue)(nil), (*SettingValue_BoolValue)(nil), (*SettingValue_IntValue)(nil), @@ -2356,8 +2509,8 @@ func file_sandbox_proto_init() { File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_sandbox_proto_rawDesc), len(file_sandbox_proto_rawDesc)), - NumEnums: 2, - NumMessages: 32, + NumEnums: 3, + NumMessages: 33, NumExtensions: 0, NumServices: 0, }, From 7cd18e1201291e14fc35bd74d6696df5512b6609 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Tue, 15 Sep 2026 23:46:28 -0700 Subject: [PATCH 23/26] refactor(isolation): make confirmation backend-neutral Signed-off-by: Drew Newberry --- Cargo.lock | 1 + architecture/sandbox.md | 8 +- .../openshell-isolation-interface/Cargo.toml | 1 + .../src/contract.rs | 181 +++++++--------- .../openshell-isolation-interface/src/lib.rs | 6 +- .../tests/backend_conformance.rs | 67 ++---- .../src/boundary_protocol.rs | 204 +++++++++++++++++- .../openshell-sandbox-backend/src/runtime.rs | 45 ++-- .../openshell-sandbox/src/boundary_server.rs | 47 ++-- crates/openshell-sandbox/src/lib.rs | 2 +- crates/openshell-sandbox/src/main.rs | 2 +- 11 files changed, 375 insertions(+), 189 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 854cfaa98a..98d143c963 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4397,6 +4397,7 @@ dependencies = [ "openshell-core", "rustix 1.1.4", "serde", + "serde_json", "tokio", ] diff --git a/architecture/sandbox.md b/architecture/sandbox.md index c988b3dbd5..65f04c8dc6 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -69,8 +69,12 @@ replacement from granting authority. 3. `openshell-supervisor` loads policy and runtime settings from the gateway, attaches to the sandbox, and verifies the driver's generation and evidence. 4. The sandbox installs its seccomp notification broker and Landlock baseline, - then reports measured confirmation. The supervisor must accept that evidence -before it sends the launch permit. + validates its mechanism-specific audit evidence, and reports backend-neutral + enforcement properties. The supervisor must accept those properties and + their immutable session and resource binding before it sends the launch + permit. Other isolation backends may establish the same properties with + different mechanisms and retain their detailed evidence in backend-owned + audit data. 5. The sandbox starts the canonical process through its single workload launcher. The supervisor starts SSH and registers its gateway session. 6. Exec, signaling, PTY, DNS, TCP, and loopback-forwarding operations cross the diff --git a/crates/openshell-isolation-interface/Cargo.toml b/crates/openshell-isolation-interface/Cargo.toml index 94220dda73..43affc4927 100644 --- a/crates/openshell-isolation-interface/Cargo.toml +++ b/crates/openshell-isolation-interface/Cargo.toml @@ -14,6 +14,7 @@ repository.workspace = true openshell-core = { path = "../openshell-core", default-features = false } async-trait = "0.1" serde = { workspace = true } +serde_json = { workspace = true } tokio = { workspace = true } [target.'cfg(unix)'.dependencies] diff --git a/crates/openshell-isolation-interface/src/contract.rs b/crates/openshell-isolation-interface/src/contract.rs index f671dd99e9..27091a7338 100644 --- a/crates/openshell-isolation-interface/src/contract.rs +++ b/crates/openshell-isolation-interface/src/contract.rs @@ -16,7 +16,8 @@ //! //! Each transition consumes the prior state by value (`self: Box`). //! Trusted backend implementations construct confirmation through a validating -//! constructor; the supervisor cannot obtain a ready boundary without evidence. +//! constructor; the supervisor cannot obtain a ready boundary without confirmed +//! backend-neutral enforcement properties. //! The supervisor holds no `match`/downcast on concrete backends: the //! registry is the only lookup by `backend_name`, and everything past it is a //! `Box` / `Arc`. @@ -385,46 +386,6 @@ pub trait BoundBoundary: Send { async fn confirm(self: Box) -> Result; } -/// Capability masks measured from `/proc//status`. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub struct CapabilityEvidence { - pub inheritable: u64, - pub permitted: u64, - pub effective: u64, - pub bounding: u64, - pub ambient: u64, -} - -impl CapabilityEvidence { - /// True only when every Linux capability set is empty. - #[must_use] - pub const fn is_empty(self) -> bool { - self.inheritable == 0 - && self.permitted == 0 - && self.effective == 0 - && self.bounding == 0 - && self.ambient == 0 - } -} - -/// Active seccomp notification and socket-broker evidence. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[allow( - clippy::struct_excessive_bools, - reason = "each independently measured kernel operation is reported explicitly" -)] -pub struct SeccompEvidence { - pub new_listener: bool, - pub notification_round_trip: bool, - pub id_validation: bool, - pub addfd_send: bool, - pub retained_socket_operation: bool, - pub proc_fd_identity: bool, - pub task_memory_read: bool, - pub task_memory_write: bool, - pub cancellation: bool, -} - /// Driver-owned evidence that the mandatory outer network fence is installed. /// /// The sandbox cannot observe the Docker daemon, Kubernetes API, or VM device @@ -512,29 +473,67 @@ impl DriverFenceEvidence { } } -/// Measured sandbox-owned evidence produced before agent launch. +/// A backend-neutral security property established before agent launch. +/// +/// `mechanism` is diagnostic and audit metadata. It never authorizes launch; +/// the registered backend is responsible for validating its mechanism-specific +/// evidence before setting `enforced`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EnforcedProperty { + pub enforced: bool, + pub mechanism: String, +} + +impl EnforcedProperty { + #[must_use] + pub fn new(enforced: bool, mechanism: impl Into) -> Self { + Self { + enforced, + mechanism: mechanism.into(), + } + } + + fn validate(&self, name: &str) -> Result<(), BackendError> { + if self.enforced && !self.mechanism.trim().is_empty() { + Ok(()) + } else { + Err(BackendError::Confirm(format!( + "{name} is not enforced or has no declared mechanism" + ))) + } + } +} + +/// Security properties every isolation backend establishes before launch. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BoundaryProperties { + pub filesystem_confinement: EnforcedProperty, + pub egress_interception: EnforcedProperty, + pub request_attribution: EnforcedProperty, + pub privilege_floor: EnforcedProperty, +} + +impl BoundaryProperties { + fn validate(&self) -> Result<(), BackendError> { + self.filesystem_confinement + .validate("filesystem confinement")?; + self.egress_interception.validate("egress interception")?; + self.request_attribution.validate("request attribution")?; + self.privilege_floor.validate("privilege floor") + } +} + +/// Per-boundary confirmation produced before agent launch. +/// +/// Common validation binds the confirmation to the admitted workload and +/// checks backend-neutral properties. `backend_audit` remains opaque to this +/// crate; the registered backend owns its schema and validates it before +/// constructing [`ConfirmedBoundary`]. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[allow( - clippy::struct_excessive_bools, - reason = "confirmation preserves independently measured security results" -)] -pub struct SandboxConfirmEvidence { +pub struct BoundaryConfirmation { pub generation: String, pub identity: ResolvedWorkloadIdentity, - pub capabilities: CapabilityEvidence, - pub no_new_privileges: bool, - pub sandbox_dumpable: bool, - pub child_dumpable: bool, - pub core_limit_zero: bool, - pub native_architecture: String, - pub kernel_release: String, - pub seccomp: SeccompEvidence, - pub landlock_abi: u32, - pub landlock_allow_deny: bool, - pub udp_dns_round_trip: bool, - pub tcp_dns_round_trip: bool, - pub tcp_allow_round_trip: bool, - pub tcp_deny_round_trip: bool, + pub properties: BoundaryProperties, pub authenticated_supervisor: bool, pub session_id: SandboxSessionId, pub driver_fence: DriverFenceEvidence, @@ -542,33 +541,15 @@ pub struct SandboxConfirmEvidence { /// Sandbox Runtime exits. pub runtime_exit_terminates_workload: bool, pub resource_claims: BTreeMap, + pub backend_audit: serde_json::Value, } -impl SandboxConfirmEvidence { - /// Validate the security-critical evidence required before launch. +impl BoundaryConfirmation { + /// Validate common security properties and immutable launch binding. pub fn validate(&self, expected: &ResolvedWorkloadIdentity) -> Result<(), BackendError> { self.driver_fence.validate()?; + self.properties.validate()?; let complete = &self.identity == expected - && self.capabilities.is_empty() - && self.no_new_privileges - && !self.sandbox_dumpable - && self.child_dumpable - && self.core_limit_zero - && self.seccomp.new_listener - && self.seccomp.notification_round_trip - && self.seccomp.id_validation - && self.seccomp.addfd_send - && self.seccomp.retained_socket_operation - && self.seccomp.proc_fd_identity - && self.seccomp.task_memory_read - && self.seccomp.task_memory_write - && self.seccomp.cancellation - && self.landlock_abi >= 3 - && self.landlock_allow_deny - && self.udp_dns_round_trip - && self.tcp_dns_round_trip - && self.tcp_allow_round_trip - && self.tcp_deny_round_trip && self.authenticated_supervisor && self.runtime_exit_terminates_workload && !self.generation.is_empty(); @@ -576,41 +557,45 @@ impl SandboxConfirmEvidence { Ok(()) } else { Err(BackendError::Confirm( - "sandbox confirmation evidence is incomplete or mismatched".to_string(), + "boundary confirmation is incomplete or mismatched".to_string(), )) } } } -/// Ready boundary paired with the evidence measured by `confirm`. +/// Ready boundary paired with the confirmation established by `confirm`. pub struct ConfirmedBoundary { boundary: Box, - evidence: SandboxConfirmEvidence, + confirmation: BoundaryConfirmation, } impl ConfirmedBoundary { - /// Construct confirmation after checking measured evidence against the - /// immutable identity admitted at attach time. + /// Construct confirmation after checking backend-neutral properties and + /// immutable identity binding. /// - /// Backend implementations are trusted to collect this evidence and bind - /// it to their resource. This constructor enforces the common requirements - /// without requiring those implementations to live in the interface crate. + /// Backend implementations are trusted to validate their audit evidence and + /// bind this confirmation to their resource. This constructor enforces the + /// common requirements without requiring those implementations to live in + /// the interface crate. /// /// # Errors /// - /// Returns an error if evidence is incomplete or the identity does not match. + /// Returns an error if confirmation is incomplete or the identity does not match. pub fn try_new( boundary: Box, - evidence: SandboxConfirmEvidence, + confirmation: BoundaryConfirmation, expected: &ResolvedWorkloadIdentity, ) -> Result { - evidence.validate(expected)?; - Ok(Self { boundary, evidence }) + confirmation.validate(expected)?; + Ok(Self { + boundary, + confirmation, + }) } - /// Return the measured evidence carried by this confirmed state. - pub fn evidence(&self) -> &SandboxConfirmEvidence { - &self.evidence + /// Return the record carried by this confirmed state. + pub fn confirmation(&self) -> &BoundaryConfirmation { + &self.confirmation } /// Consume confirmation and advance to the sole launch-capable state. diff --git a/crates/openshell-isolation-interface/src/lib.rs b/crates/openshell-isolation-interface/src/lib.rs index a7740d8a7d..b09320dc97 100644 --- a/crates/openshell-isolation-interface/src/lib.rs +++ b/crates/openshell-isolation-interface/src/lib.rs @@ -21,8 +21,10 @@ //! `start_agent` -> Running. Nothing untrusted runs inside the boundary until it //! is confirmed ready. This is enforced *by construction*: each transition //! consumes the prior state by value. Trusted backends construct confirmation -//! through [`contract::ConfirmedBoundary::try_new`], which checks common evidence -//! before the supervisor can obtain a [`contract::ReadyBoundary`]. +//! through [`contract::ConfirmedBoundary::try_new`], which checks common +//! enforcement properties and immutable launch binding before the supervisor +//! can obtain a [`contract::ReadyBoundary`]. Mechanism-specific evidence stays +//! owned by the backend that can interpret it. //! //! [`AgentSpec`] is shared between the workload definition the supervisor //! submits and the [`contract::SandboxContext`] that `attach` binds to a diff --git a/crates/openshell-isolation-interface/tests/backend_conformance.rs b/crates/openshell-isolation-interface/tests/backend_conformance.rs index 73559b203f..d5f6e5b22a 100644 --- a/crates/openshell-isolation-interface/tests/backend_conformance.rs +++ b/crates/openshell-isolation-interface/tests/backend_conformance.rs @@ -239,7 +239,7 @@ impl BoundBoundary for MockBound { async fn confirm(self: Box) -> Result { ConfirmedBoundary::try_new( Box::new(MockReady:: { _k: PhantomData }), - confirmation_evidence(), + confirmation(), &workload_identity(), ) } @@ -368,40 +368,16 @@ fn workload_identity() -> ResolvedWorkloadIdentity { .unwrap() } -fn confirmation_evidence() -> SandboxConfirmEvidence { - SandboxConfirmEvidence { +fn confirmation() -> BoundaryConfirmation { + BoundaryConfirmation { generation: "generation-1".to_string(), identity: workload_identity(), - capabilities: CapabilityEvidence { - inheritable: 0, - permitted: 0, - effective: 0, - bounding: 0, - ambient: 0, + properties: BoundaryProperties { + filesystem_confinement: EnforcedProperty::new(true, "mock-filesystem"), + egress_interception: EnforcedProperty::new(true, "mock-egress"), + request_attribution: EnforcedProperty::new(true, "mock-attribution"), + privilege_floor: EnforcedProperty::new(true, "mock-privilege-floor"), }, - no_new_privileges: true, - sandbox_dumpable: false, - child_dumpable: true, - core_limit_zero: true, - native_architecture: std::env::consts::ARCH.to_string(), - kernel_release: "test".to_string(), - seccomp: SeccompEvidence { - new_listener: true, - notification_round_trip: true, - id_validation: true, - addfd_send: true, - retained_socket_operation: true, - proc_fd_identity: true, - task_memory_read: true, - task_memory_write: true, - cancellation: true, - }, - landlock_abi: 3, - landlock_allow_deny: true, - udp_dns_round_trip: true, - tcp_dns_round_trip: true, - tcp_allow_round_trip: true, - tcp_deny_round_trip: true, authenticated_supervisor: true, session_id: SandboxSessionId::new(), driver_fence: DriverFenceEvidence::Vm { @@ -410,6 +386,7 @@ fn confirmation_evidence() -> SandboxConfirmEvidence { }, runtime_exit_terminates_workload: true, resource_claims: BTreeMap::new(), + backend_audit: serde_json::json!({"backend": "mock"}), } } @@ -458,7 +435,7 @@ async fn drive( let _ingress = bound.network_mediation_source(); assert_eq!(bound.host_gateway_ip(), None); let confirmed = bound.confirm().await?; - confirmed.evidence().validate(&sandbox_ctx().identity)?; + confirmed.confirmation().validate(&sandbox_ctx().identity)?; confirmed.into_boundary().start_agent().await } @@ -536,12 +513,12 @@ async fn one_driver_runs_both_backends() { } #[test] -fn confirmation_constructor_rejects_incomplete_evidence() { - let mut evidence = confirmation_evidence(); - evidence.seccomp.cancellation = false; +fn confirmation_constructor_rejects_unenforced_property() { + let mut confirmation = confirmation(); + confirmation.properties.egress_interception.enforced = false; let result = ConfirmedBoundary::try_new( Box::new(MockReady:: { _k: PhantomData }), - evidence, + confirmation, &workload_identity(), ); assert!(matches!(result, Err(BackendError::Confirm(_)))); @@ -559,7 +536,7 @@ fn confirmation_constructor_rejects_another_workload_identity() { .unwrap(); let result = ConfirmedBoundary::try_new( Box::new(MockReady:: { _k: PhantomData }), - confirmation_evidence(), + confirmation(), &expected, ); assert!(matches!(result, Err(BackendError::Confirm(_)))); @@ -842,16 +819,16 @@ fn workload_identity_rejects_root_and_normalizes_groups() { } #[test] -fn confirmation_evidence_rejects_identity_or_posture_drift() { +fn confirmation_rejects_identity_or_property_drift() { let expected = workload_identity(); - let evidence = confirmation_evidence(); - evidence.validate(&expected).unwrap(); + let baseline = confirmation(); + baseline.validate(&expected).unwrap(); - let mut drifted = confirmation_evidence(); - drifted.capabilities.effective = 1; + let mut drifted = confirmation(); + drifted.properties.privilege_floor.enforced = false; assert!(drifted.validate(&expected).is_err()); - let mut unmanaged = confirmation_evidence(); + let mut unmanaged = confirmation(); unmanaged.runtime_exit_terminates_workload = false; assert!(unmanaged.validate(&expected).is_err()); @@ -863,7 +840,7 @@ fn confirmation_evidence_rejects_identity_or_posture_drift() { "sha256:test".into(), ) .unwrap(); - assert!(evidence.validate(&different).is_err()); + assert!(baseline.validate(&different).is_err()); } // --------------------------------------------------------------------------- diff --git a/crates/openshell-sandbox-backend/src/boundary_protocol.rs b/crates/openshell-sandbox-backend/src/boundary_protocol.rs index e619d68dc2..3d9c68f412 100644 --- a/crates/openshell-sandbox-backend/src/boundary_protocol.rs +++ b/crates/openshell-sandbox-backend/src/boundary_protocol.rs @@ -23,8 +23,9 @@ use openshell_core::policy::{ use openshell_isolation_interface::AgentSpec; use openshell_isolation_interface::contract::Sha256Digest; use openshell_isolation_interface::contract::{ - BackendDescriptor, BackendError, BinaryIdentity, BoundaryExitStatus, BoundarySignal, - DriverFenceEvidence, ExecSpec, ResolveError, SandboxConfirmEvidence, + BackendDescriptor, BackendError, BinaryIdentity, BoundaryConfirmation, BoundaryExitStatus, + BoundaryProperties, BoundarySignal, DriverFenceEvidence, EnforcedProperty, ExecSpec, + ResolveError, }; use rcgen::{CertificateParams, DnType, ExtendedKeyUsagePurpose, IsCa, KeyPair, KeyUsagePurpose}; use serde::de::DeserializeOwned; @@ -42,6 +43,145 @@ pub const STREAM_STDIN_CLOSED: u8 = 4; pub const STREAM_NETWORK_DECISION: u8 = 5; pub const MAX_STREAM_FRAME_BYTES: usize = 64 * 1024; +/// Capability masks measured from `/proc//status` by the `OpenShell` +/// co-located runtime. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct CapabilityEvidence { + pub inheritable: u64, + pub permitted: u64, + pub effective: u64, + pub bounding: u64, + pub ambient: u64, +} + +impl CapabilityEvidence { + #[must_use] + pub const fn is_empty(self) -> bool { + self.inheritable == 0 + && self.permitted == 0 + && self.effective == 0 + && self.bounding == 0 + && self.ambient == 0 + } +} + +/// Active seccomp notification and socket-broker measurements specific to the +/// `OpenShell` co-located runtime. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[allow( + clippy::struct_excessive_bools, + reason = "each independently measured kernel operation is reported explicitly" +)] +pub struct SeccompEvidence { + pub new_listener: bool, + pub notification_round_trip: bool, + pub id_validation: bool, + pub addfd_send: bool, + pub retained_socket_operation: bool, + pub proc_fd_identity: bool, + pub task_memory_read: bool, + pub task_memory_write: bool, + pub cancellation: bool, +} + +/// Mechanism-specific audit evidence for the `OpenShell` co-located runtime. +/// +/// This schema belongs to this backend rather than the generic isolation +/// interface. The host-side backend validates it before constructing a +/// backend-neutral `ConfirmedBoundary`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[allow( + clippy::struct_excessive_bools, + reason = "audit evidence preserves independently measured security results" +)] +pub struct OpenShellSandboxAuditEvidence { + pub capabilities: CapabilityEvidence, + pub no_new_privileges: bool, + pub sandbox_dumpable: bool, + pub child_dumpable: bool, + pub core_limit_zero: bool, + pub native_architecture: String, + pub kernel_release: String, + pub seccomp: SeccompEvidence, + pub landlock_abi: u32, + pub landlock_allow_deny: bool, + pub udp_dns_round_trip: bool, + pub tcp_dns_round_trip: bool, + pub tcp_allow_round_trip: bool, + pub tcp_deny_round_trip: bool, +} + +impl OpenShellSandboxAuditEvidence { + /// Validate the complete mechanism-specific posture required by this backend. + pub fn validate(&self) -> Result<(), BackendError> { + let complete = self.capabilities.is_empty() + && self.no_new_privileges + && !self.sandbox_dumpable + && self.child_dumpable + && self.core_limit_zero + && !self.native_architecture.is_empty() + && !self.kernel_release.is_empty() + && self.seccomp.new_listener + && self.seccomp.notification_round_trip + && self.seccomp.id_validation + && self.seccomp.addfd_send + && self.seccomp.retained_socket_operation + && self.seccomp.proc_fd_identity + && self.seccomp.task_memory_read + && self.seccomp.task_memory_write + && self.seccomp.cancellation + && self.landlock_abi >= 3 + && self.landlock_allow_deny + && self.udp_dns_round_trip + && self.tcp_dns_round_trip + && self.tcp_allow_round_trip + && self.tcp_deny_round_trip; + if complete { + Ok(()) + } else { + Err(BackendError::Confirm( + "OpenShell sandbox audit evidence is incomplete".to_string(), + )) + } + } + + /// Project backend measurements into the common property contract. + #[must_use] + pub fn properties(&self) -> BoundaryProperties { + BoundaryProperties { + filesystem_confinement: EnforcedProperty::new( + self.landlock_abi >= 3 && self.landlock_allow_deny, + format!("landlock-v{}", self.landlock_abi), + ), + egress_interception: EnforcedProperty::new( + self.seccomp.new_listener + && self.seccomp.notification_round_trip + && self.seccomp.addfd_send + && self.udp_dns_round_trip + && self.tcp_dns_round_trip + && self.tcp_allow_round_trip + && self.tcp_deny_round_trip, + "seccomp-notify", + ), + request_attribution: EnforcedProperty::new( + self.seccomp.id_validation + && self.seccomp.proc_fd_identity + && self.seccomp.task_memory_read + && self.seccomp.task_memory_write, + "seccomp-notify-procfs", + ), + privilege_floor: EnforcedProperty::new( + self.capabilities.is_empty() + && self.no_new_privileges + && !self.sandbox_dumpable + && self.child_dumpable + && self.core_limit_zero, + "linux-capability-free", + ), + } + } +} + /// Ephemeral identity of the supervisor process that owns one sandbox runtime. /// /// The supervisor generates this value in memory and presents it on every @@ -704,8 +844,9 @@ pub enum Response { snapshot: SessionSnapshotWire, }, Confirmed { - /// Measured capability-free posture produced before workload launch. - evidence: Box, + /// Backend-neutral properties and backend-owned audit evidence produced + /// before workload launch. + confirmation: Box, }, Started { process_id: String, @@ -1184,6 +1325,61 @@ pub enum FrameError { mod tests { use super::*; + fn complete_audit_evidence() -> OpenShellSandboxAuditEvidence { + OpenShellSandboxAuditEvidence { + capabilities: CapabilityEvidence { + inheritable: 0, + permitted: 0, + effective: 0, + bounding: 0, + ambient: 0, + }, + no_new_privileges: true, + sandbox_dumpable: false, + child_dumpable: true, + core_limit_zero: true, + native_architecture: "x86_64".to_string(), + kernel_release: "6.12.0".to_string(), + seccomp: SeccompEvidence { + new_listener: true, + notification_round_trip: true, + id_validation: true, + addfd_send: true, + retained_socket_operation: true, + proc_fd_identity: true, + task_memory_read: true, + task_memory_write: true, + cancellation: true, + }, + landlock_abi: 6, + landlock_allow_deny: true, + udp_dns_round_trip: true, + tcp_dns_round_trip: true, + tcp_allow_round_trip: true, + tcp_deny_round_trip: true, + } + } + + #[test] + fn openshell_audit_evidence_projects_backend_neutral_properties() { + let audit = complete_audit_evidence(); + audit.validate().unwrap(); + let properties = audit.properties(); + assert!(properties.filesystem_confinement.enforced); + assert_eq!(properties.filesystem_confinement.mechanism, "landlock-v6"); + assert!(properties.egress_interception.enforced); + assert!(properties.request_attribution.enforced); + assert!(properties.privilege_floor.enforced); + } + + #[test] + fn openshell_audit_evidence_rejects_mechanism_failure() { + let mut audit = complete_audit_evidence(); + audit.seccomp.addfd_send = false; + assert!(audit.validate().is_err()); + assert!(!audit.properties().egress_interception.enforced); + } + #[test] fn binary_identity_wire_rejects_ambiguous_or_invalid_shapes() { for encoded in [ diff --git a/crates/openshell-sandbox-backend/src/runtime.rs b/crates/openshell-sandbox-backend/src/runtime.rs index 29558ebae0..425c5f3740 100644 --- a/crates/openshell-sandbox-backend/src/runtime.rs +++ b/crates/openshell-sandbox-backend/src/runtime.rs @@ -293,19 +293,29 @@ impl BoundBoundary for RemoteBound { async fn confirm(self: Box) -> Result { let response = self.client.call_idempotent(Request::Confirm).await?; - let Response::Confirmed { evidence } = response else { - return Err(unexpected_response("confirmed_with_evidence", &response)); + let Response::Confirmed { confirmation } = response else { + return Err(unexpected_response("confirmed", &response)); }; - if evidence.generation != self.generation - || evidence.session_id != self.session_id - || evidence.resource_claims != self.resource_claims - || evidence.driver_fence != self.driver_fence + if confirmation.generation != self.generation + || confirmation.session_id != self.session_id + || confirmation.resource_claims != self.resource_claims + || confirmation.driver_fence != self.driver_fence { return Err(BackendError::Confirm( "sandbox confirmation generation, session, resource claims, or driver fence do not match runtime descriptor" .to_string(), )); } + let audit: crate::boundary_protocol::OpenShellSandboxAuditEvidence = + serde_json::from_value(confirmation.backend_audit.clone()).map_err(|error| { + BackendError::Confirm(format!("decode OpenShell sandbox audit evidence: {error}")) + })?; + audit.validate()?; + if confirmation.properties != audit.properties() { + return Err(BackendError::Confirm( + "sandbox confirmation properties do not match OpenShell audit evidence".to_string(), + )); + } self.client.start_credential_monitor(); ConfirmedBoundary::try_new( Box::new(RemoteReady { @@ -316,7 +326,7 @@ impl BoundBoundary for RemoteBound { ca_file_paths: self.ca_file_paths, provider_credentials: self.provider_credentials, }), - *evidence, + *confirmation, &self.identity, ) } @@ -1939,7 +1949,7 @@ mod tests { }, }, Request::Confirm => Response::Confirmed { - evidence: Box::new(test_confirmation_evidence()), + confirmation: Box::new(test_confirmation()), }, Request::OpenMediation if mediation_ready => Response::MediationReady, Request::OpenMediation => Response::Error { @@ -2437,12 +2447,9 @@ mod tests { } } - fn test_confirmation_evidence() - -> openshell_isolation_interface::contract::SandboxConfirmEvidence { - openshell_isolation_interface::contract::SandboxConfirmEvidence { - generation: "test-generation".to_string(), - identity: sandbox().identity, - capabilities: openshell_isolation_interface::contract::CapabilityEvidence { + fn test_confirmation() -> openshell_isolation_interface::contract::BoundaryConfirmation { + let audit = crate::boundary_protocol::OpenShellSandboxAuditEvidence { + capabilities: crate::boundary_protocol::CapabilityEvidence { inheritable: 0, permitted: 0, effective: 0, @@ -2455,7 +2462,7 @@ mod tests { core_limit_zero: true, native_architecture: std::env::consts::ARCH.to_string(), kernel_release: "test".to_string(), - seccomp: openshell_isolation_interface::contract::SeccompEvidence { + seccomp: crate::boundary_protocol::SeccompEvidence { new_listener: true, notification_round_trip: true, id_validation: true, @@ -2472,11 +2479,17 @@ mod tests { tcp_dns_round_trip: true, tcp_allow_round_trip: true, tcp_deny_round_trip: true, + }; + openshell_isolation_interface::contract::BoundaryConfirmation { + generation: "test-generation".to_string(), + identity: sandbox().identity, + properties: audit.properties(), authenticated_supervisor: true, session_id: test_session_id(), driver_fence: test_driver_fence(), runtime_exit_terminates_workload: true, resource_claims: std::collections::BTreeMap::new(), + backend_audit: serde_json::to_value(audit).expect("serialize audit evidence"), } } @@ -2594,7 +2607,7 @@ mod tests { .await .expect("TLS request"), Response::Confirmed { - evidence: Box::new(test_confirmation_evidence()), + confirmation: Box::new(test_confirmation()), } ); server.abort(); diff --git a/crates/openshell-sandbox/src/boundary_server.rs b/crates/openshell-sandbox/src/boundary_server.rs index 3326570d96..cf1a29740b 100644 --- a/crates/openshell-sandbox/src/boundary_server.rs +++ b/crates/openshell-sandbox/src/boundary_server.rs @@ -36,9 +36,8 @@ mod linux { }; use openshell_core::provider_credentials::ProviderCredentialState; use openshell_isolation_interface::contract::{ - BoundaryExec, BoundaryLoopbackConnector, BoundaryProcess, BoundaryTerminal, - CapabilityEvidence, ExecSession, LoopbackTarget, ResolvedWorkloadIdentity, - SandboxConfirmEvidence, + BoundaryConfirmation, BoundaryExec, BoundaryLoopbackConnector, BoundaryProcess, + BoundaryTerminal, ExecSession, LoopbackTarget, ResolvedWorkloadIdentity, }; use openshell_sandbox_backend::GPU_RESOURCE_CLAIM; use openshell_sandbox_backend::mediation::{ @@ -60,11 +59,11 @@ mod linux { use openshell_sandbox_backend::boundary_protocol::{ AgentSpecWire, BinaryIdentityWire, BoundaryConfig, BoundaryErrorKind, BoundaryListener as BoundaryListenerConfig, DnsQueryResultWire, ExecSpecWire, - ExitStatusWire, MediationTimingWire, 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, 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); @@ -2220,20 +2219,20 @@ mod linux { if let Err(error) = prepared.confirm(&self.process_runtime) { return guest_error(BoundaryErrorKind::Process, error); } - let evidence = match self.measure_confirmation_evidence() { - Ok(evidence) => evidence, + let confirmation = match self.measure_confirmation() { + Ok(confirmation) => confirmation, Err(error) => return guest_error(BoundaryErrorKind::Process, error), }; *state = RuntimeState::Ready(prepared.clone()); Response::Confirmed { - evidence: Box::new(evidence), + confirmation: Box::new(confirmation), } } RuntimeState::Ready(_) | RuntimeState::Running(_) => { - self.measure_confirmation_evidence().map_or_else( + self.measure_confirmation().map_or_else( |error| guest_error(BoundaryErrorKind::Process, error), - |evidence| Response::Confirmed { - evidence: Box::new(evidence), + |confirmation| Response::Confirmed { + confirmation: Box::new(confirmation), }, ) } @@ -2244,7 +2243,7 @@ mod linux { } } - fn measure_confirmation_evidence(&self) -> Result { + fn measure_confirmation(&self) -> Result { validate_running_identity( &self.config.workload_identity, allows_runtime_supplementary_groups(&self.config), @@ -2257,7 +2256,7 @@ mod linux { } let status = std::fs::read_to_string("/proc/self/status") .map_err(|error| format!("read sandbox process status: {error}"))?; - let capabilities = CapabilityEvidence { + let capabilities = openshell_sandbox_backend::boundary_protocol::CapabilityEvidence { inheritable: parse_status_hex(&status, "CapInh")?, permitted: parse_status_hex(&status, "CapPrm")?, effective: parse_status_hex(&status, "CapEff")?, @@ -2278,9 +2277,7 @@ mod linux { // SAFETY: successful getrlimit initialized the value. let core_limit = unsafe { core_limit.assume_init() }; let (native_architecture, kernel_release) = uname_values()?; - Ok(SandboxConfirmEvidence { - generation: self.config.generation.clone(), - identity: self.config.workload_identity.clone(), + let audit = OpenShellSandboxAuditEvidence { capabilities, no_new_privileges, sandbox_dumpable, @@ -2295,11 +2292,21 @@ mod linux { tcp_dns_round_trip: self.qualification.tcp_dns_round_trip, tcp_allow_round_trip: self.qualification.tcp_allow_round_trip, tcp_deny_round_trip: self.qualification.tcp_deny_round_trip, + }; + audit.validate().map_err(|error| error.to_string())?; + let properties = audit.properties(); + let backend_audit = serde_json::to_value(audit) + .map_err(|error| format!("encode OpenShell sandbox audit evidence: {error}"))?; + Ok(BoundaryConfirmation { + generation: self.config.generation.clone(), + identity: self.config.workload_identity.clone(), + properties, authenticated_supervisor: true, session_id: self.config.session_id, driver_fence: self.config.driver_fence.clone(), runtime_exit_terminates_workload: true, resource_claims: self.config.resource_claims.clone(), + backend_audit, }) } @@ -4201,7 +4208,7 @@ mod linux { fn test_runtime_qualification() -> crate::RuntimeQualification { crate::RuntimeQualification { - seccomp: openshell_isolation_interface::contract::SeccompEvidence { + seccomp: openshell_sandbox_backend::boundary_protocol::SeccompEvidence { new_listener: true, notification_round_trip: true, id_validation: true, diff --git a/crates/openshell-sandbox/src/lib.rs b/crates/openshell-sandbox/src/lib.rs index 4928fe6f40..4166d8d2c1 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -34,7 +34,7 @@ pub mod sandbox; reason = "qualification preserves independently exercised security results" )] pub struct RuntimeQualification { - pub seccomp: openshell_isolation_interface::contract::SeccompEvidence, + pub seccomp: openshell_sandbox_backend::boundary_protocol::SeccompEvidence, pub landlock_abi: u32, pub landlock_allow_deny: bool, pub udp_dns_round_trip: bool, diff --git a/crates/openshell-sandbox/src/main.rs b/crates/openshell-sandbox/src/main.rs index 24fa65ab16..dbdc8733ec 100644 --- a/crates/openshell-sandbox/src/main.rs +++ b/crates/openshell-sandbox/src/main.rs @@ -239,7 +239,7 @@ fn qualify_runtime() -> Result<(openshell_sandbox::RuntimeQualification, Qualifi wait_killable_recv: notification.wait_killable_recv, }; let qualification = openshell_sandbox::RuntimeQualification { - seccomp: openshell_isolation_interface::contract::SeccompEvidence { + seccomp: openshell_sandbox_backend::boundary_protocol::SeccompEvidence { new_listener: notification.notification_round_trip(), notification_round_trip: notification.notification_round_trip(), id_validation: notification.notification_round_trip(), From a5301e8eeaa1ea7bfa00a4325abfabc4e7d82c20 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Wed, 16 Sep 2026 00:48:01 -0700 Subject: [PATCH 24/26] fix(sandbox): validate confirmation evidence at host boundary Signed-off-by: Drew Newberry --- crates/openshell-sandbox/src/boundary_server.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/openshell-sandbox/src/boundary_server.rs b/crates/openshell-sandbox/src/boundary_server.rs index cf1a29740b..05aebad0a5 100644 --- a/crates/openshell-sandbox/src/boundary_server.rs +++ b/crates/openshell-sandbox/src/boundary_server.rs @@ -2293,7 +2293,10 @@ mod linux { tcp_allow_round_trip: self.qualification.tcp_allow_round_trip, tcp_deny_round_trip: self.qualification.tcp_deny_round_trip, }; - audit.validate().map_err(|error| error.to_string())?; + // The boundary reports mechanism evidence; the authenticated host + // backend validates it before constructing a ConfirmedBoundary. + // 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) .map_err(|error| format!("encode OpenShell sandbox audit evidence: {error}"))?; From ff98babdf57fd87c5be342cec2cda1464bb52dce Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Wed, 16 Sep 2026 11:58:44 -0700 Subject: [PATCH 25/26] refactor(isolation): keep fence evidence driver-owned Signed-off-by: Drew Newberry --- Cargo.lock | 1 + architecture/sandbox.md | 12 +- .../openshell-driver-docker/src/isolation.rs | 63 +++++-- crates/openshell-driver-docker/src/lib.rs | 3 +- .../openshell-driver-kubernetes/src/driver.rs | 6 +- .../src/isolation.rs | 67 ++++++-- .../openshell-driver-podman/src/isolation.rs | 45 +++-- .../openshell-driver-vm/src/isolation/mod.rs | 42 +++-- .../openshell-isolation-interface/Cargo.toml | 1 + .../src/contract.rs | 156 +++++++++--------- .../tests/backend_conformance.rs | 57 +++---- .../src/boundary_protocol.rs | 14 +- .../openshell-sandbox-backend/src/runtime.rs | 41 ++--- .../openshell-sandbox/src/boundary_server.rs | 31 ++-- 14 files changed, 330 insertions(+), 209 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 98d143c963..1d6892ee8a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4398,6 +4398,7 @@ dependencies = [ "rustix 1.1.4", "serde", "serde_json", + "sha2 0.10.9", "tokio", ] diff --git a/architecture/sandbox.md b/architecture/sandbox.md index 65f04c8dc6..234c570d44 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -62,7 +62,10 @@ replacement from granting authority. ## Startup Flow 1. The driver resolves the immutable workload identity, installs the outer - network fence, and starts `openshell-sandbox` with one-use bootstrap state. + network fence, validates its native evidence, and starts `openshell-sandbox` + with one-use bootstrap state. Docker inspects container networking, + Kubernetes verifies its NetworkPolicy, and VM drivers inspect the guest + device model; those native schemas remain in their driver crates. 2. The sandbox consumes and unlinks bootstrap material, proves the admitted runtime posture, and listens on the protected driver channel. It does not run untrusted code yet. @@ -80,6 +83,13 @@ replacement from granting authority. 6. Exec, signaling, PTY, DNS, TCP, and loopback-forwarding operations cross the authenticated channel for the lifetime of the sandbox generation. +The shared isolation contract receives only the driver's normalized outer-fence +guarantees: egress is default-deny, there is no unmanaged egress path, the +evidence is bound to the sandbox generation, revocation has been verified, and +controller loss fails closed. A digest commits those guarantees to the native +driver evidence without teaching the shared contract about container networks, +Kubernetes objects, VM devices, or accelerator resources. + When the admitted main process exits, its status and retained terminal output remain available. The confirmed sandbox and supervisor-owned access plane continue to serve policy-authorized exec and loopback forwarding until explicit stop or diff --git a/crates/openshell-driver-docker/src/isolation.rs b/crates/openshell-driver-docker/src/isolation.rs index 5e50a37ad7..b7f75efee4 100644 --- a/crates/openshell-driver-docker/src/isolation.rs +++ b/crates/openshell-driver-docker/src/isolation.rs @@ -11,12 +11,39 @@ use std::collections::{BTreeMap, HashMap}; use std::net::IpAddr; use std::path::PathBuf; -use openshell_isolation_interface::contract::{DriverFenceEvidence, ResolvedWorkloadIdentity}; +use openshell_isolation_interface::contract::{ + BackendError, OuterFenceGuarantees, ResolvedWorkloadIdentity, +}; use openshell_sandbox_backend::GPU_RESOURCE_CLAIM; use openshell_sandbox_backend::boundary_protocol::{ BoundaryConfig, BoundaryListener, GatewayVerificationKey, SandboxRuntimeDescriptor, SandboxTlsClientConfig, SandboxTlsServerConfig, SandboxTransport, }; +use serde::Serialize; + +#[derive(Serialize)] +struct DockerOuterFenceEvidence<'a> { + container_id: &'a str, + network_mode: &'static str, + unexpected_networks: &'a [String], +} + +impl DockerOuterFenceEvidence<'_> { + fn project(&self, generation: &str) -> Result { + if self.container_id.is_empty() + || self.network_mode != "none" + || !self.unexpected_networks.is_empty() + { + return Err(BackendError::Descriptor( + "Docker outer fence evidence is incomplete".to_string(), + )); + } + let encoded = serde_json::to_vec(self).map_err(|error| { + BackendError::Descriptor(format!("encode Docker outer fence evidence: {error}")) + })?; + OuterFenceGuarantees::confirmed(generation, &encoded) + } +} /// Driver-owned inputs that bind one Docker container to one boundary. pub struct DockerBoundarySpec { @@ -48,8 +75,7 @@ pub struct DockerBoundaryProvisioning { impl DockerBoundarySpec { /// Produce both sides of the common protocol from the same immutable /// Docker coordinates so attach cannot bind a different container. - #[must_use] - pub fn provision(self) -> DockerBoundaryProvisioning { + pub fn provision(self) -> Result { let mut resource_claims = BTreeMap::from([ ("docker.container_id".to_string(), self.container_id), ("docker.image_identity".to_string(), self.image_identity), @@ -57,12 +83,14 @@ impl DockerBoundarySpec { if self.gpu_requested { resource_claims.insert(GPU_RESOURCE_CLAIM.to_string(), "true".to_string()); } - let driver_fence = DriverFenceEvidence::Docker { - container_id: resource_claims["docker.container_id"].clone(), - network_mode: "none".to_string(), - unexpected_networks: Vec::new(), - }; - DockerBoundaryProvisioning { + let unexpected_networks = Vec::new(); + let outer_fence = DockerOuterFenceEvidence { + container_id: &resource_claims["docker.container_id"], + network_mode: "none", + unexpected_networks: &unexpected_networks, + } + .project(&self.generation)?; + Ok(DockerBoundaryProvisioning { boundary_config: BoundaryConfig { boundary_id: self.boundary_id.clone(), generation: self.generation.clone(), @@ -78,7 +106,7 @@ impl DockerBoundarySpec { resource_claims: resource_claims.clone(), resource_claim_files: BTreeMap::new(), workload_identity: self.workload_identity.clone(), - driver_fence: driver_fence.clone(), + outer_fence: outer_fence.clone(), child_env: self.child_env, }, runtime_descriptor: SandboxRuntimeDescriptor { @@ -92,9 +120,9 @@ impl DockerBoundarySpec { tls: self.supervisor_tls, host_gateway_ip: self.host_gateway_ip, resource_claims, - driver_fence, + outer_fence, }, - } + }) } } @@ -143,7 +171,8 @@ mod tests { .unwrap(), child_env: HashMap::new(), } - .provision(); + .provision() + .unwrap(); assert_eq!( provisioned.boundary_config.resource_claims, @@ -158,14 +187,14 @@ mod tests { "true" ); assert_eq!( - provisioned.boundary_config.driver_fence, - provisioned.runtime_descriptor.driver_fence + provisioned.boundary_config.outer_fence, + provisioned.runtime_descriptor.outer_fence ); assert!( provisioned .runtime_descriptor - .driver_fence - .validate() + .outer_fence + .validate("generation-1") .is_ok() ); } diff --git a/crates/openshell-driver-docker/src/lib.rs b/crates/openshell-driver-docker/src/lib.rs index 49e25eb737..b0b398832e 100644 --- a/crates/openshell-driver-docker/src/lib.rs +++ b/crates/openshell-driver-docker/src/lib.rs @@ -4426,7 +4426,8 @@ async fn prepare_docker_boundary_files( workload_identity: workload_identity.clone(), child_env: docker_child_environment(sandbox), } - .provision(); + .provision() + .map_err(|error| Status::failed_precondition(error.to_string()))?; let boundary_config = provisioning .boundary_config .encode() diff --git a/crates/openshell-driver-kubernetes/src/driver.rs b/crates/openshell-driver-kubernetes/src/driver.rs index bda6973ba4..6330b1e73f 100644 --- a/crates/openshell-driver-kubernetes/src/driver.rs +++ b/crates/openshell-driver-kubernetes/src/driver.rs @@ -2371,7 +2371,8 @@ impl KubernetesComputeDriver { workload_identity, child_env, } - .provision(); + .provision() + .map_err(|error| KubernetesDriverError::Message(error.to_string()))?; let descriptor = provisioned .runtime_descriptor .backend_descriptor() @@ -2609,7 +2610,8 @@ impl KubernetesComputeDriver { workload_identity, child_env, } - .provision(); + .provision() + .map_err(|error| KubernetesDriverError::Message(error.to_string()))?; let descriptor = provisioned .runtime_descriptor .backend_descriptor() diff --git a/crates/openshell-driver-kubernetes/src/isolation.rs b/crates/openshell-driver-kubernetes/src/isolation.rs index bb9ae8ff66..db82f2adc5 100644 --- a/crates/openshell-driver-kubernetes/src/isolation.rs +++ b/crates/openshell-driver-kubernetes/src/isolation.rs @@ -20,11 +20,42 @@ use k8s_openapi::api::networking::v1::{ use k8s_openapi::apimachinery::pkg::apis::meta::v1::LabelSelector; use k8s_openapi::apimachinery::pkg::util::intstr::IntOrString; use kube::core::ObjectMeta; -use openshell_isolation_interface::contract::{DriverFenceEvidence, ResolvedWorkloadIdentity}; +use openshell_isolation_interface::contract::{ + BackendError, OuterFenceGuarantees, ResolvedWorkloadIdentity, +}; use openshell_sandbox_backend::boundary_protocol::{ BoundaryConfig, BoundaryListener, GatewayVerificationKey, SandboxRuntimeDescriptor, SandboxTlsClientConfig, SandboxTlsServerConfig, SandboxTransport, }; +use serde::Serialize; + +#[derive(Serialize)] +struct KubernetesOuterFenceEvidence<'a> { + network_policy_uid: &'a str, + network_policy_resource_version: &'a str, + ingress_isolated: bool, + egress_isolated: bool, + egress_rule_count: u32, +} + +impl KubernetesOuterFenceEvidence<'_> { + fn project(&self, generation: &str) -> Result { + if self.network_policy_uid.is_empty() + || self.network_policy_resource_version.is_empty() + || !self.ingress_isolated + || !self.egress_isolated + || self.egress_rule_count != 0 + { + return Err(BackendError::Descriptor( + "Kubernetes outer fence evidence is incomplete".to_string(), + )); + } + let encoded = serde_json::to_vec(self).map_err(|error| { + BackendError::Descriptor(format!("encode Kubernetes outer fence evidence: {error}")) + })?; + OuterFenceGuarantees::confirmed(generation, &encoded) + } +} /// Isolation backend implemented by the `OpenShell` sandbox runtime. pub const BACKEND_NAME: &str = openshell_sandbox_backend::BACKEND_NAME; @@ -181,8 +212,7 @@ pub struct KubernetesSandboxRuntimeBoundaryProvisioning { impl KubernetesSandboxRuntimeBoundarySpec { /// Produce both sides of the common protocol from one observed Kubernetes /// resource set so a stale or recreated object cannot be attached. - #[must_use] - pub fn provision(self) -> KubernetesSandboxRuntimeBoundaryProvisioning { + pub fn provision(self) -> Result { let resource_claims = BTreeMap::from([ ("kubernetes.namespace_uid".to_string(), self.namespace_uid), ( @@ -206,15 +236,16 @@ impl KubernetesSandboxRuntimeBoundarySpec { self.egress_policy_resource_version, ), ]); - let driver_fence = DriverFenceEvidence::Kubernetes { - network_policy_uid: resource_claims["kubernetes.egress_policy_uid"].clone(), - network_policy_resource_version: - resource_claims["kubernetes.egress_policy_resource_version"].clone(), + let outer_fence = KubernetesOuterFenceEvidence { + network_policy_uid: &resource_claims["kubernetes.egress_policy_uid"], + network_policy_resource_version: &resource_claims + ["kubernetes.egress_policy_resource_version"], ingress_isolated: true, egress_isolated: true, egress_rule_count: 0, - }; - KubernetesSandboxRuntimeBoundaryProvisioning { + } + .project(&self.generation)?; + Ok(KubernetesSandboxRuntimeBoundaryProvisioning { boundary_config: BoundaryConfig { boundary_id: self.boundary_id.clone(), generation: self.generation.clone(), @@ -233,7 +264,7 @@ impl KubernetesSandboxRuntimeBoundarySpec { self.workload_pod_uid_path, )]), workload_identity: self.workload_identity.clone(), - driver_fence: driver_fence.clone(), + outer_fence: outer_fence.clone(), child_env: self.child_env, }, runtime_descriptor: SandboxRuntimeDescriptor { @@ -248,9 +279,9 @@ impl KubernetesSandboxRuntimeBoundarySpec { tls: self.supervisor_tls, host_gateway_ip: self.host_gateway_ip, resource_claims, - driver_fence, + outer_fence, }, - } + }) } } @@ -303,7 +334,7 @@ mod tests { #[test] fn provisioning_binds_identical_kubernetes_resource_claims() { - let provisioned = spec().provision(); + let provisioned = spec().provision().unwrap(); assert_eq!( provisioned.boundary_config.resource_claims, @@ -318,21 +349,21 @@ mod tests { "1945" ); assert_eq!( - provisioned.boundary_config.driver_fence, - provisioned.runtime_descriptor.driver_fence + provisioned.boundary_config.outer_fence, + provisioned.runtime_descriptor.outer_fence ); assert!( provisioned .runtime_descriptor - .driver_fence - .validate() + .outer_fence + .validate("generation-1") .is_ok() ); } #[test] fn provisioning_uses_one_shared_tcp_protocol_across_pods() { - let provisioned = spec().provision(); + let provisioned = spec().provision().unwrap(); assert_eq!( provisioned.boundary_config.listener, diff --git a/crates/openshell-driver-podman/src/isolation.rs b/crates/openshell-driver-podman/src/isolation.rs index b616fa6474..d66c29b77c 100644 --- a/crates/openshell-driver-podman/src/isolation.rs +++ b/crates/openshell-driver-podman/src/isolation.rs @@ -10,7 +10,7 @@ use std::path::PathBuf; use openshell_core::ComputeDriverError; use openshell_core::proto::compute::v1::DriverSandbox; -use openshell_isolation_interface::contract::{DriverFenceEvidence, ResolvedWorkloadIdentity}; +use openshell_isolation_interface::contract::{OuterFenceGuarantees, ResolvedWorkloadIdentity}; use openshell_sandbox_backend::boundary_protocol::{ BoundaryConfig, BoundaryListener, GatewayVerificationKey, SandboxRuntimeDescriptor, SandboxTlsClientConfig, SandboxTlsServerConfig, SandboxTransport, @@ -27,6 +27,26 @@ pub const AUTH_BUNDLE_PATH: &str = "/.openshell/supervisor/auth.json"; pub const RESTART_METADATA_PATH: &str = "/.openshell/supervisor/restart-metadata.json"; const SOCKET_PATH: &str = "/.openshell/channel/sandbox/control.sock"; +#[derive(Serialize)] +struct PodmanOuterFenceEvidence<'a> { + container_id: &'a str, + network_mode: &'static str, + unexpected_networks: &'a [String], +} + +impl PodmanOuterFenceEvidence<'_> { + fn project(&self, generation: &str) -> Result { + if self.container_id.is_empty() + || self.network_mode != "none" + || !self.unexpected_networks.is_empty() + { + return Err(invalid("Podman outer fence evidence is incomplete")); + } + let encoded = serde_json::to_vec(self).map_err(invalid)?; + OuterFenceGuarantees::confirmed(generation, &encoded).map_err(invalid) + } +} + pub fn supervisor_name(id: &str) -> String { format!("openshell-supervisor-{id}") } @@ -159,15 +179,17 @@ pub fn bootstrap_archives( identity.resource_digest.clone(), ), ]); - let driver_fence = DriverFenceEvidence::Podman { - container_id: container_id.into(), - network_mode: "none".into(), - unexpected_networks: Vec::new(), - }; let runtime_generation = launch_authentication .supervisor .runtime_generation .to_string(); + let unexpected_networks = Vec::new(); + let outer_fence = PodmanOuterFenceEvidence { + container_id, + network_mode: "none", + unexpected_networks: &unexpected_networks, + } + .project(&runtime_generation)?; let verification_keys = launch_authentication .verification_keys .iter() @@ -198,7 +220,7 @@ pub fn bootstrap_archives( resource_claims: resource_claims.clone(), resource_claim_files: BTreeMap::new(), workload_identity: identity.clone(), - driver_fence: driver_fence.clone(), + outer_fence: outer_fence.clone(), child_env: child_env.clone(), }; let runtime_descriptor = SandboxRuntimeDescriptor { @@ -215,7 +237,7 @@ pub fn bootstrap_archives( host_gateway_ip: None, resource_claims, workload_identity: identity.clone(), - driver_fence, + outer_fence, }; // Libpod resolves the requested upload destination once for a stopped // container. Archive entries must be relative to the selected named volume, @@ -440,9 +462,12 @@ mod tests { .unwrap(); assert_eq!(config.boundary_id, runtime_descriptor.boundary_id); assert_eq!(config.session_id, runtime_descriptor.session_id); - assert_eq!(config.driver_fence, runtime_descriptor.driver_fence); + assert_eq!(config.outer_fence, runtime_descriptor.outer_fence); assert_eq!(config.workload_identity, identity); - runtime_descriptor.driver_fence.validate().unwrap(); + runtime_descriptor + .outer_fence + .validate(&runtime_descriptor.generation) + .unwrap(); let restart_metadata: RestartMetadata = serde_json::from_slice( supervisor .get(&PathBuf::from( diff --git a/crates/openshell-driver-vm/src/isolation/mod.rs b/crates/openshell-driver-vm/src/isolation/mod.rs index 49c1135310..6a909cd003 100644 --- a/crates/openshell-driver-vm/src/isolation/mod.rs +++ b/crates/openshell-driver-vm/src/isolation/mod.rs @@ -9,14 +9,35 @@ //! the common control and boundary behavior. use openshell_isolation_interface::contract::{ - BackendError, DriverFenceEvidence, ResolvedWorkloadIdentity, + BackendError, OuterFenceGuarantees, ResolvedWorkloadIdentity, }; use openshell_sandbox_backend::boundary_protocol::{ BoundaryConfig, BoundaryListener, GatewayVerificationKey, SandboxRuntimeDescriptor, SandboxTlsClientConfig, SandboxTlsServerConfig, SandboxTransport, }; +use serde::Serialize; use std::collections::{BTreeMap, HashMap}; +#[derive(Serialize)] +struct VmOuterFenceEvidence<'a> { + generation: &'a str, + network_device_count: u32, +} + +impl VmOuterFenceEvidence<'_> { + fn project(&self) -> Result { + if self.generation.is_empty() || self.network_device_count != 0 { + return Err(BackendError::Descriptor( + "VM outer fence evidence is incomplete".to_string(), + )); + } + let encoded = serde_json::to_vec(self).map_err(|error| { + BackendError::Descriptor(format!("encode VM outer fence evidence: {error}")) + })?; + OuterFenceGuarantees::confirmed(self.generation, &encoded) + } +} + /// Driver-owned inputs that bind one VM generation to one supervisor boundary. pub struct VmBoundarySpec { pub boundary_id: String, @@ -57,10 +78,11 @@ impl VmBoundarySpec { ("vm.generation".to_string(), self.generation.clone()), ("vm.image_identity".to_string(), self.image_identity), ]); - let driver_fence = DriverFenceEvidence::Vm { - generation: self.generation.clone(), + let outer_fence = VmOuterFenceEvidence { + generation: &self.generation, network_device_count: 0, - }; + } + .project()?; Ok(VmBoundaryProvisioning { boundary_config: BoundaryConfig { boundary_id: self.boundary_id.clone(), @@ -77,7 +99,7 @@ impl VmBoundarySpec { resource_claims: resource_claims.clone(), resource_claim_files: BTreeMap::new(), workload_identity: workload_identity.clone(), - driver_fence: driver_fence.clone(), + outer_fence: outer_fence.clone(), child_env: self.child_env, }, runtime_descriptor: SandboxRuntimeDescriptor { @@ -92,7 +114,7 @@ impl VmBoundarySpec { // after crossing the authenticated boundary channel. host_gateway_ip: Some(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)), resource_claims, - driver_fence, + outer_fence, }, }) } @@ -155,14 +177,14 @@ mod tests { Some(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)) ); assert_eq!( - provisioned.boundary_config.driver_fence, - provisioned.runtime_descriptor.driver_fence + provisioned.boundary_config.outer_fence, + provisioned.runtime_descriptor.outer_fence ); assert!( provisioned .runtime_descriptor - .driver_fence - .validate() + .outer_fence + .validate("generation-1") .is_ok() ); } diff --git a/crates/openshell-isolation-interface/Cargo.toml b/crates/openshell-isolation-interface/Cargo.toml index 43affc4927..0426a9011f 100644 --- a/crates/openshell-isolation-interface/Cargo.toml +++ b/crates/openshell-isolation-interface/Cargo.toml @@ -15,6 +15,7 @@ openshell-core = { path = "../openshell-core", default-features = false } async-trait = "0.1" serde = { workspace = true } serde_json = { workspace = true } +sha2 = { workspace = true } tokio = { workspace = true } [target.'cfg(unix)'.dependencies] diff --git a/crates/openshell-isolation-interface/src/contract.rs b/crates/openshell-isolation-interface/src/contract.rs index 27091a7338..ca8ea4762f 100644 --- a/crates/openshell-isolation-interface/src/contract.rs +++ b/crates/openshell-isolation-interface/src/contract.rs @@ -31,7 +31,7 @@ //! The contract is transport-neutral. Compute drivers keep runtime placement //! and coordination details behind these interfaces. -use std::collections::{BTreeMap, HashMap}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::fmt; use std::net::{IpAddr, SocketAddr}; use std::path::PathBuf; @@ -386,89 +386,81 @@ pub trait BoundBoundary: Send { async fn confirm(self: Box) -> Result; } -/// Driver-owned evidence that the mandatory outer network fence is installed. +/// Backend-neutral guarantees established by the compute driver's outer fence. /// -/// The sandbox cannot observe the Docker daemon, Kubernetes API, or VM device -/// model directly. Drivers therefore bind the exact fence they validated into -/// both protected bootstrap halves. The sandbox reports that value back during -/// confirmation, and the supervisor rejects any mismatch before agent launch. +/// Each driver owns its native evidence schema and the code that validates it. +/// After validation, the driver projects that evidence into these guarantees +/// and supplies a digest that binds the original evidence to this generation. +/// The common runtime only validates and compares this projection; it never +/// interprets runtime- or accelerator-specific fields. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum OuterFenceGuarantee { + /// No workload packet can leave without an explicit mediated decision. + DefaultDenyEgress, + /// The driver found no network path outside the mediated boundary. + NoUnmanagedEgressPath, + /// Previously granted access can be revoked by the driver-owned fence. + RevocationVerified, + /// Loss of the driver or its controller does not open network access. + ControllerLossFailsClosed, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "backend", rename_all = "kebab-case", deny_unknown_fields)] -pub enum DriverFenceEvidence { - Docker { - container_id: String, - network_mode: String, - unexpected_networks: Vec, - }, - Podman { - container_id: String, - network_mode: String, - unexpected_networks: Vec, - }, - Kubernetes { - network_policy_uid: String, - network_policy_resource_version: String, - ingress_isolated: bool, - egress_isolated: bool, - egress_rule_count: u32, - }, - Vm { - generation: String, - network_device_count: u32, - }, -} - -impl DriverFenceEvidence { - #[must_use] - pub const fn driver_name(&self) -> &'static str { - match self { - Self::Docker { .. } => "docker", - Self::Podman { .. } => "podman", - Self::Kubernetes { .. } => "kubernetes", - Self::Vm { .. } => "vm", +pub struct OuterFenceGuarantees { + /// Sandbox generation for which the evidence was collected. + pub generation: String, + /// Complete set of normalized guarantees established by the driver. + pub established: BTreeSet, + /// Commitment to the driver-owned native evidence used for this projection. + pub evidence_digest: Sha256Digest, +} + +impl OuterFenceGuarantees { + /// Construct guarantees after the driver has validated its native evidence. + pub fn confirmed( + generation: impl Into, + native_evidence: &[u8], + ) -> Result { + let generation = generation.into(); + if generation.is_empty() || native_evidence.is_empty() { + return Err(BackendError::Descriptor( + "outer fence generation and native evidence are required".to_string(), + )); } + let mut binding = Vec::with_capacity(8 + generation.len() + native_evidence.len()); + binding.extend_from_slice(&(generation.len() as u64).to_be_bytes()); + binding.extend_from_slice(generation.as_bytes()); + binding.extend_from_slice(native_evidence); + Ok(Self { + generation, + established: BTreeSet::from([ + OuterFenceGuarantee::DefaultDenyEgress, + OuterFenceGuarantee::NoUnmanagedEgressPath, + OuterFenceGuarantee::RevocationVerified, + OuterFenceGuarantee::ControllerLossFailsClosed, + ]), + evidence_digest: Sha256Digest::compute(&binding), + }) } - /// Validate the concrete outer-fence properties reported by the compute driver. - pub fn validate(&self) -> Result<(), BackendError> { - let valid = match self { - Self::Docker { - container_id, - network_mode, - unexpected_networks, - } - | Self::Podman { - container_id, - network_mode, - unexpected_networks, - } => { - !container_id.is_empty() && network_mode == "none" && unexpected_networks.is_empty() - } - Self::Kubernetes { - network_policy_uid, - network_policy_resource_version, - ingress_isolated, - egress_isolated, - egress_rule_count, - } => { - !network_policy_uid.is_empty() - && !network_policy_resource_version.is_empty() - && *ingress_isolated - && *egress_isolated - && *egress_rule_count == 0 - } - Self::Vm { - generation, - network_device_count, - } => !generation.is_empty() && *network_device_count == 0, - }; - if valid { + /// Validate the common guarantees against the admitted generation. + pub fn validate(&self, expected_generation: &str) -> Result<(), BackendError> { + let required = BTreeSet::from([ + OuterFenceGuarantee::DefaultDenyEgress, + OuterFenceGuarantee::NoUnmanagedEgressPath, + OuterFenceGuarantee::RevocationVerified, + OuterFenceGuarantee::ControllerLossFailsClosed, + ]); + let complete = !self.generation.is_empty() + && self.generation == expected_generation + && self.established == required; + if complete { Ok(()) } else { - Err(BackendError::Confirm(format!( - "{} driver fence evidence is incomplete", - self.driver_name() - ))) + Err(BackendError::Confirm( + "outer fence guarantees are incomplete or bound to another generation".to_string(), + )) } } } @@ -536,7 +528,7 @@ pub struct BoundaryConfirmation { pub properties: BoundaryProperties, pub authenticated_supervisor: bool, pub session_id: SandboxSessionId, - pub driver_fence: DriverFenceEvidence, + pub outer_fence: OuterFenceGuarantees, /// The driver-owned containment primitive terminates the workload when its /// Sandbox Runtime exits. pub runtime_exit_terminates_workload: bool, @@ -547,7 +539,7 @@ pub struct BoundaryConfirmation { impl BoundaryConfirmation { /// Validate common security properties and immutable launch binding. pub fn validate(&self, expected: &ResolvedWorkloadIdentity) -> Result<(), BackendError> { - self.driver_fence.validate()?; + self.outer_fence.validate(&self.generation)?; self.properties.validate()?; let complete = &self.identity == expected && self.authenticated_supervisor @@ -850,6 +842,12 @@ impl From for String { } impl Sha256Digest { + fn compute(bytes: &[u8]) -> Self { + use sha2::{Digest as _, Sha256}; + + Self(Sha256::digest(bytes).into()) + } + /// Return the raw digest bytes. #[must_use] pub fn as_bytes(&self) -> &[u8; 32] { diff --git a/crates/openshell-isolation-interface/tests/backend_conformance.rs b/crates/openshell-isolation-interface/tests/backend_conformance.rs index d5f6e5b22a..517b494356 100644 --- a/crates/openshell-isolation-interface/tests/backend_conformance.rs +++ b/crates/openshell-isolation-interface/tests/backend_conformance.rs @@ -380,10 +380,8 @@ fn confirmation() -> BoundaryConfirmation { }, authenticated_supervisor: true, session_id: SandboxSessionId::new(), - driver_fence: DriverFenceEvidence::Vm { - generation: "generation-1".to_string(), - network_device_count: 0, - }, + outer_fence: OuterFenceGuarantees::confirmed("generation-1", b"mock-fence-evidence") + .unwrap(), runtime_exit_terminates_workload: true, resource_claims: BTreeMap::new(), backend_audit: serde_json::json!({"backend": "mock"}), @@ -391,34 +389,33 @@ fn confirmation() -> BoundaryConfirmation { } #[test] -fn driver_fence_evidence_is_backend_specific_and_fail_closed() { - let docker = DriverFenceEvidence::Docker { - container_id: "sha256:container".to_string(), - network_mode: "none".to_string(), - unexpected_networks: Vec::new(), - }; - let kubernetes = DriverFenceEvidence::Kubernetes { - network_policy_uid: "policy-uid".to_string(), - network_policy_resource_version: "42".to_string(), - ingress_isolated: true, - egress_isolated: true, - egress_rule_count: 0, - }; - let vm = DriverFenceEvidence::Vm { - generation: "generation-1".to_string(), - network_device_count: 0, - }; +fn outer_fence_guarantees_are_backend_neutral_and_fail_closed() { + let fence = OuterFenceGuarantees::confirmed("generation-1", b"native-driver-evidence").unwrap(); + assert!(fence.validate("generation-1").is_ok()); + assert_ne!( + fence.evidence_digest, + OuterFenceGuarantees::confirmed("generation-2", b"native-driver-evidence") + .unwrap() + .evidence_digest + ); - assert!(docker.validate().is_ok()); - assert!(kubernetes.validate().is_ok()); - assert!(vm.validate().is_ok()); + let mut wrong_generation = fence.clone(); + wrong_generation.generation = "generation-2".to_string(); + assert!(wrong_generation.validate("generation-1").is_err()); - let drifted = DriverFenceEvidence::Docker { - container_id: "sha256:container".to_string(), - network_mode: "bridge".to_string(), - unexpected_networks: vec!["bridge".to_string()], - }; - assert!(drifted.validate().is_err()); + for guarantee in [ + OuterFenceGuarantee::DefaultDenyEgress, + OuterFenceGuarantee::NoUnmanagedEgressPath, + OuterFenceGuarantee::RevocationVerified, + OuterFenceGuarantee::ControllerLossFailsClosed, + ] { + let mut incomplete = fence.clone(); + incomplete.established.remove(&guarantee); + assert!(incomplete.validate("generation-1").is_err()); + } + + assert!(OuterFenceGuarantees::confirmed("", b"evidence").is_err()); + assert!(OuterFenceGuarantees::confirmed("generation-1", b"").is_err()); } /// The backend-independent supervisor sequence. Identical for every backend: diff --git a/crates/openshell-sandbox-backend/src/boundary_protocol.rs b/crates/openshell-sandbox-backend/src/boundary_protocol.rs index 3d9c68f412..ec62813a0e 100644 --- a/crates/openshell-sandbox-backend/src/boundary_protocol.rs +++ b/crates/openshell-sandbox-backend/src/boundary_protocol.rs @@ -24,7 +24,7 @@ use openshell_isolation_interface::AgentSpec; use openshell_isolation_interface::contract::Sha256Digest; use openshell_isolation_interface::contract::{ BackendDescriptor, BackendError, BinaryIdentity, BoundaryConfirmation, BoundaryExitStatus, - BoundaryProperties, BoundarySignal, DriverFenceEvidence, EnforcedProperty, ExecSpec, + BoundaryProperties, BoundarySignal, EnforcedProperty, ExecSpec, OuterFenceGuarantees, ResolveError, }; use rcgen::{CertificateParams, DnType, ExtendedKeyUsagePurpose, IsCa, KeyPair, KeyUsagePurpose}; @@ -426,8 +426,8 @@ pub struct SandboxRuntimeDescriptor { /// example pod UID, VM generation, or container ID). #[serde(default)] pub resource_claims: std::collections::BTreeMap, - /// Concrete outer-fence evidence validated by the driver. - pub driver_fence: DriverFenceEvidence, + /// Backend-neutral projection of the driver-validated outer fence. + pub outer_fence: OuterFenceGuarantees, } impl fmt::Debug for SandboxRuntimeDescriptor { @@ -441,7 +441,7 @@ impl fmt::Debug for SandboxRuntimeDescriptor { .field("tls", &self.tls) .field("host_gateway_ip", &self.host_gateway_ip) .field("resource_claims", &self.resource_claims) - .field("driver_fence", &self.driver_fence) + .field("outer_fence", &self.outer_fence) .finish() } } @@ -494,8 +494,8 @@ pub struct BoundaryConfig { pub resource_claim_files: std::collections::BTreeMap, /// Exact identity already applied by the runtime to the sandbox process. pub workload_identity: openshell_isolation_interface::contract::ResolvedWorkloadIdentity, - /// Concrete outer-fence evidence validated by the driver. - pub driver_fence: DriverFenceEvidence, + /// Backend-neutral projection of the driver-validated outer fence. + pub outer_fence: OuterFenceGuarantees, /// Driver-resolved environment exposed only to workload processes. #[serde(default)] pub child_env: std::collections::HashMap, @@ -523,7 +523,7 @@ impl fmt::Debug for BoundaryConfig { .field("resource_claims", &self.resource_claims) .field("resource_claim_files", &self.resource_claim_files) .field("workload_identity", &self.workload_identity) - .field("driver_fence", &self.driver_fence) + .field("outer_fence", &self.outer_fence) .field("child_env_keys", &self.child_env.keys().collect::>()) .finish() } diff --git a/crates/openshell-sandbox-backend/src/runtime.rs b/crates/openshell-sandbox-backend/src/runtime.rs index 425c5f3740..05d6bf65c8 100644 --- a/crates/openshell-sandbox-backend/src/runtime.rs +++ b/crates/openshell-sandbox-backend/src/runtime.rs @@ -102,7 +102,7 @@ impl IsolationBackend for OpenShellRuntimeBackend { let resource_claims = runtime_descriptor.resource_claims.clone(); let generation = runtime_descriptor.generation.clone(); let session_id = runtime_descriptor.session_id; - let driver_fence = runtime_descriptor.driver_fence.clone(); + let outer_fence = runtime_descriptor.outer_fence.clone(); let client = Arc::new(BoundaryClient::new( runtime_descriptor, self.sandbox_bearer.clone(), @@ -135,7 +135,7 @@ impl IsolationBackend for OpenShellRuntimeBackend { generation, session_id, resource_claims, - driver_fence, + outer_fence, })) } } @@ -167,7 +167,9 @@ fn validate_runtime_descriptor( )); } validate_resource_claims(&runtime_descriptor.resource_claims)?; - runtime_descriptor.driver_fence.validate()?; + runtime_descriptor + .outer_fence + .validate(&runtime_descriptor.generation)?; match &runtime_descriptor.transport { SandboxTransport::Unix { socket_path } => { validate_socket_path(socket_path)?; @@ -278,7 +280,7 @@ struct RemoteBound { generation: String, session_id: openshell_core::SandboxSessionId, resource_claims: std::collections::BTreeMap, - driver_fence: openshell_isolation_interface::contract::DriverFenceEvidence, + outer_fence: openshell_isolation_interface::contract::OuterFenceGuarantees, } #[async_trait] @@ -299,10 +301,10 @@ impl BoundBoundary for RemoteBound { if confirmation.generation != self.generation || confirmation.session_id != self.session_id || confirmation.resource_claims != self.resource_claims - || confirmation.driver_fence != self.driver_fence + || confirmation.outer_fence != self.outer_fence { return Err(BackendError::Confirm( - "sandbox confirmation generation, session, resource claims, or driver fence do not match runtime descriptor" + "sandbox confirmation generation, session, resource claims, or outer fence do not match runtime descriptor" .to_string(), )); } @@ -1855,11 +1857,12 @@ mod tests { FilesystemPolicy, LandlockPolicy, NetworkPolicy, ProcessPolicy, SandboxPolicy, }; - fn test_driver_fence() -> openshell_isolation_interface::contract::DriverFenceEvidence { - openshell_isolation_interface::contract::DriverFenceEvidence::Vm { - generation: "test-generation".to_string(), - network_device_count: 0, - } + fn test_outer_fence() -> openshell_isolation_interface::contract::OuterFenceGuarantees { + openshell_isolation_interface::contract::OuterFenceGuarantees::confirmed( + "test-generation", + b"test-vm-fence", + ) + .unwrap() } #[tokio::test] @@ -2363,7 +2366,7 @@ mod tests { tls, host_gateway_ip: None, resource_claims: std::collections::BTreeMap::new(), - driver_fence: test_driver_fence(), + outer_fence: test_outer_fence(), } } @@ -2486,7 +2489,7 @@ mod tests { properties: audit.properties(), authenticated_supervisor: true, session_id: test_session_id(), - driver_fence: test_driver_fence(), + outer_fence: test_outer_fence(), runtime_exit_terminates_workload: true, resource_claims: std::collections::BTreeMap::new(), backend_audit: serde_json::to_value(audit).expect("serialize audit evidence"), @@ -2507,7 +2510,7 @@ mod tests { tls: certificate.client_tls.clone(), host_gateway_ip: Some(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)), resource_claims: std::collections::BTreeMap::new(), - driver_fence: test_driver_fence(), + outer_fence: test_outer_fence(), }; let debug = format!("{runtime_descriptor:?}"); assert!(debug.contains("")); @@ -2527,7 +2530,7 @@ mod tests { tls: test_certificate().client_tls, host_gateway_ip: None, resource_claims: std::collections::BTreeMap::new(), - driver_fence: test_driver_fence(), + outer_fence: test_outer_fence(), }; assert!(matches!( validate_runtime_descriptor(&runtime_descriptor, &sandbox()), @@ -2549,7 +2552,7 @@ mod tests { tls: test_certificate().client_tls, host_gateway_ip: None, resource_claims: std::collections::BTreeMap::new(), - driver_fence: test_driver_fence(), + outer_fence: test_outer_fence(), }; assert!(matches!( validate_runtime_descriptor(&runtime_descriptor, &sandbox()), @@ -2571,7 +2574,7 @@ mod tests { tls: test_certificate().client_tls, host_gateway_ip: None, resource_claims: std::collections::BTreeMap::new(), - driver_fence: test_driver_fence(), + outer_fence: test_outer_fence(), }; validate_runtime_descriptor(&runtime_descriptor, &sandbox()) .expect("TCP runtime descriptor should be valid"); @@ -2764,7 +2767,7 @@ mod tests { tls: certificate.client_tls, host_gateway_ip: None, resource_claims: std::collections::BTreeMap::new(), - driver_fence: test_driver_fence(), + outer_fence: test_outer_fence(), }, test_bearer(&"a".repeat(32)), )); @@ -2851,7 +2854,7 @@ mod tests { tls: certificate.client_tls, host_gateway_ip: None, resource_claims: std::collections::BTreeMap::new(), - driver_fence: test_driver_fence(), + outer_fence: test_outer_fence(), }, test_bearer(&"a".repeat(32)), ); diff --git a/crates/openshell-sandbox/src/boundary_server.rs b/crates/openshell-sandbox/src/boundary_server.rs index 05aebad0a5..041ae9e625 100644 --- a/crates/openshell-sandbox/src/boundary_server.rs +++ b/crates/openshell-sandbox/src/boundary_server.rs @@ -303,8 +303,8 @@ mod linux { } validate_resource_claims(&config.resource_claims).map_err(|error| error.to_string())?; config - .driver_fence - .validate() + .outer_fence + .validate(&config.generation) .map_err(|error| error.to_string())?; for (claim, path) in &config.resource_claim_files { if !config.resource_claims.contains_key(claim) { @@ -2306,7 +2306,7 @@ mod linux { properties, authenticated_supervisor: true, session_id: self.config.session_id, - driver_fence: self.config.driver_fence.clone(), + outer_fence: self.config.outer_fence.clone(), runtime_exit_terminates_workload: true, resource_claims: self.config.resource_claims.clone(), backend_audit, @@ -3649,7 +3649,7 @@ mod linux { resource_claims: std::collections::BTreeMap::new(), resource_claim_files: std::collections::BTreeMap::new(), workload_identity: test_workload_identity(), - driver_fence: test_driver_fence(), + outer_fence: test_outer_fence(), child_env: std::collections::HashMap::new(), }; let debug = format!("{config:?}"); @@ -3800,7 +3800,7 @@ mod linux { resource_claims: std::collections::BTreeMap::new(), resource_claim_files: std::collections::BTreeMap::new(), workload_identity: test_workload_identity(), - driver_fence: test_driver_fence(), + outer_fence: test_outer_fence(), child_env: std::collections::HashMap::new(), }, tokio::runtime::Handle::current(), @@ -4202,11 +4202,12 @@ mod linux { .unwrap() } - fn test_driver_fence() -> openshell_isolation_interface::contract::DriverFenceEvidence { - openshell_isolation_interface::contract::DriverFenceEvidence::Vm { - generation: "generation-1".to_string(), - network_device_count: 0, - } + fn test_outer_fence() -> openshell_isolation_interface::contract::OuterFenceGuarantees { + openshell_isolation_interface::contract::OuterFenceGuarantees::confirmed( + "generation-1", + b"test-vm-fence", + ) + .unwrap() } fn test_runtime_qualification() -> crate::RuntimeQualification { @@ -4314,7 +4315,7 @@ mod linux { resource_claims: std::collections::BTreeMap::new(), resource_claim_files: std::collections::BTreeMap::new(), workload_identity: test_workload_identity(), - driver_fence: test_driver_fence(), + outer_fence: test_outer_fence(), child_env: std::collections::HashMap::new(), }; @@ -4349,7 +4350,7 @@ mod linux { pod_uid_path, )]), workload_identity: test_workload_identity(), - driver_fence: test_driver_fence(), + outer_fence: test_outer_fence(), child_env: std::collections::HashMap::new(), }; @@ -4389,7 +4390,7 @@ mod linux { resource_claims: std::collections::BTreeMap::new(), resource_claim_files: std::collections::BTreeMap::new(), workload_identity: test_workload_identity(), - driver_fence: test_driver_fence(), + outer_fence: test_outer_fence(), child_env: std::collections::HashMap::new(), }, tokio::runtime::Handle::current(), @@ -4564,7 +4565,7 @@ mod linux { resource_claims: std::collections::BTreeMap::new(), resource_claim_files: std::collections::BTreeMap::new(), workload_identity: test_workload_identity(), - driver_fence: test_driver_fence(), + outer_fence: test_outer_fence(), child_env: std::collections::HashMap::new(), }, process_runtime.handle().clone(), @@ -4837,7 +4838,7 @@ mod linux { resource_claims: std::collections::BTreeMap::new(), resource_claim_files: std::collections::BTreeMap::new(), workload_identity: test_workload_identity(), - driver_fence: test_driver_fence(), + outer_fence: test_outer_fence(), child_env: std::collections::HashMap::new(), }, process_runtime.handle().clone(), From 7228e693642e25b4d2bbb64d19b94d767af87ba5 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Thu, 17 Sep 2026 11:01:32 -0700 Subject: [PATCH 26/26] refactor(mxc): adopt RFC 0012 sandbox runtime Signed-off-by: Drew Newberry --- .../build-openshell-mxc-windows/SKILL.md | 20 +- .../build-openshell-mxc-windows/reference.md | 16 +- AGENTS.md | 3 +- CONTRIBUTING.md | 2 +- Cargo.lock | 18 +- Cargo.toml | 2 +- architecture/compute-runtimes.md | 29 +- architecture/windows-msvc-build.md | 19 +- .../openshell-driver-docker/src/isolation.rs | 2 + .../src/isolation.rs | 2 + crates/openshell-driver-mxc/Cargo.toml | 14 +- crates/openshell-driver-mxc/README.md | 290 +- .../examples/README-openclaw-forward.txt | 140 - .../e2e-policies/openclaw-gateway.yaml | 49 - .../examples/e2e-policies/ws-agent.yaml | 42 - .../examples/mxc-gateway.toml | 12 +- .../examples/mxc-github-provider-profile.yml | 29 - .../examples/mxc-ocsf-audit.toml | 17 +- .../examples/mxc-openclaw-gateway.toml | 55 - .../examples/mxc-openclaw-isolation.toml | 44 - .../examples/mxc-openclaw-localnet.toml | 33 - .../mxc-provider-credential-policy.yaml | 36 - .../mxc-provider-credential-probe.ps1 | 175 - .../examples/mxc-provider-credential.toml | 18 - .../examples/mxc-ws-agent.rs | 622 --- .../examples/mxc-ws-gateway.toml | 66 - .../examples/ocsf-audit.yaml | 6 +- .../examples/openclaw-capture.mjs | 202 - .../examples/run-ocsf-audit.ps1 | 18 +- .../examples/run-openclaw-forward-test.ps1 | 722 --- .../examples/run-provider-credential-test.ps1 | 461 -- .../examples/run-ws-agent-test.ps1 | 747 ---- .../src/control_channel.rs | 412 -- crates/openshell-driver-mxc/src/driver.rs | 3936 ++++------------- crates/openshell-driver-mxc/src/isolation.rs | 141 + crates/openshell-driver-mxc/src/lib.rs | 22 +- crates/openshell-driver-mxc/src/mxc.rs | 618 +-- crates/openshell-driver-mxc/src/policy.rs | 12 +- .../src/policy_map/map.rs | 21 +- crates/openshell-driver-mxc/src/relay.rs | 740 ---- .../tests/openclaw_appcontainer_compat.rs | 58 - .../tests/openclaw_environment_cleanup.ps1 | 68 - .../tests/wxc_exec_real.rs | 2 - .../openshell-driver-podman/src/isolation.rs | 2 + .../openshell-driver-vm/src/isolation/mod.rs | 2 + crates/openshell-gateway/src/lib.rs | 104 +- .../src/contract.rs | 36 +- .../src/boundary_protocol.rs | 102 + .../openshell-sandbox-backend/src/runtime.rs | 30 +- crates/openshell-sandbox/Cargo.toml | 4 + .../openshell-sandbox/src/boundary_server.rs | 35 +- .../src/boundary_server/windows.rs | 1410 ++++++ crates/openshell-sandbox/src/lib.rs | 6 + crates/openshell-sandbox/src/main.rs | 46 +- crates/openshell-server/src/compute/mod.rs | 22 - crates/openshell-server/src/grpc/sandbox.rs | 63 +- crates/openshell-server/src/lib.rs | 107 +- .../openshell-supervisor-network/src/run.rs | 22 +- .../src/delegated.rs | 194 +- .../openshell-supervisor-process/src/lib.rs | 2 + .../src/main_session.rs | 80 +- .../openshell-supervisor-process/src/ssh.rs | 9 +- .../src/supervisor_session.rs | 23 +- crates/openshell-supervisor-relay/Cargo.toml | 42 - crates/openshell-supervisor-relay/src/imp.rs | 1111 ----- crates/openshell-supervisor-relay/src/main.rs | 32 - .../tests/control_channel_contract.rs | 788 ---- crates/openshell-supervisor/src/lib.rs | 30 +- docs/reference/gateway-config.mdx | 52 +- .../gateway/schema-v2-capability-parity.toml | 2 +- rfc/0013-native-windows-mxc/README.md | 584 +-- tasks/scripts/windows-msvc.ps1 | 6 +- 72 files changed, 3295 insertions(+), 11592 deletions(-) delete mode 100644 crates/openshell-driver-mxc/examples/README-openclaw-forward.txt delete mode 100644 crates/openshell-driver-mxc/examples/e2e-policies/openclaw-gateway.yaml delete mode 100644 crates/openshell-driver-mxc/examples/e2e-policies/ws-agent.yaml delete mode 100644 crates/openshell-driver-mxc/examples/mxc-github-provider-profile.yml delete mode 100644 crates/openshell-driver-mxc/examples/mxc-openclaw-gateway.toml delete mode 100644 crates/openshell-driver-mxc/examples/mxc-openclaw-isolation.toml delete mode 100644 crates/openshell-driver-mxc/examples/mxc-openclaw-localnet.toml delete mode 100644 crates/openshell-driver-mxc/examples/mxc-provider-credential-policy.yaml delete mode 100644 crates/openshell-driver-mxc/examples/mxc-provider-credential-probe.ps1 delete mode 100644 crates/openshell-driver-mxc/examples/mxc-provider-credential.toml delete mode 100644 crates/openshell-driver-mxc/examples/mxc-ws-agent.rs delete mode 100644 crates/openshell-driver-mxc/examples/mxc-ws-gateway.toml delete mode 100644 crates/openshell-driver-mxc/examples/openclaw-capture.mjs delete mode 100644 crates/openshell-driver-mxc/examples/run-openclaw-forward-test.ps1 delete mode 100644 crates/openshell-driver-mxc/examples/run-provider-credential-test.ps1 delete mode 100644 crates/openshell-driver-mxc/examples/run-ws-agent-test.ps1 delete mode 100644 crates/openshell-driver-mxc/src/control_channel.rs create mode 100644 crates/openshell-driver-mxc/src/isolation.rs delete mode 100644 crates/openshell-driver-mxc/src/relay.rs delete mode 100644 crates/openshell-driver-mxc/tests/openclaw_appcontainer_compat.rs delete mode 100644 crates/openshell-driver-mxc/tests/openclaw_environment_cleanup.ps1 create mode 100644 crates/openshell-sandbox/src/boundary_server/windows.rs delete mode 100644 crates/openshell-supervisor-relay/Cargo.toml delete mode 100644 crates/openshell-supervisor-relay/src/imp.rs delete mode 100644 crates/openshell-supervisor-relay/src/main.rs delete mode 100644 crates/openshell-supervisor-relay/tests/control_channel_contract.rs diff --git a/.agents/skills/build-openshell-mxc-windows/SKILL.md b/.agents/skills/build-openshell-mxc-windows/SKILL.md index 67aac83c54..f8e672bb4f 100644 --- a/.agents/skills/build-openshell-mxc-windows/SKILL.md +++ b/.agents/skills/build-openshell-mxc-windows/SKILL.md @@ -1,6 +1,6 @@ --- name: build-openshell-mxc-windows -description: Maintain and validate OpenShell's build-only Windows MSVC lane for x64 and ARM64. Use when working on Windows compilation, `windows:*` mise tasks, unsupported Windows compute-driver contracts, or Windows build reports. This skill does not implement Docker, Kubernetes, Podman, VM, MXC driver, policy translation, MSI, service, or supervisor runtime support on Windows. +description: Maintain and validate OpenShell's 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. | diff --git a/.agents/skills/build-openshell-mxc-windows/reference.md b/.agents/skills/build-openshell-mxc-windows/reference.md index 1045c15950..7ab02aa242 100644 --- a/.agents/skills/build-openshell-mxc-windows/reference.md +++ b/.agents/skills/build-openshell-mxc-windows/reference.md @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 08eb3a489f..a18fd2243a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 29e90769f8..283a286656 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 | diff --git a/Cargo.lock b/Cargo.lock index 1d6892ee8a..e098e9cccd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index ebc0485a97..4381ff7875 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 diff --git a/architecture/compute-runtimes.md b/architecture/compute-runtimes.md index 1a2433e9a2..a2fbeaefec 100644 --- a/architecture/compute-runtimes.md +++ b/architecture/compute-runtimes.md @@ -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. - -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. +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. + +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: diff --git a/architecture/windows-msvc-build.md b/architecture/windows-msvc-build.md index 9093a76a52..5dd607dba9 100644 --- a/architecture/windows-msvc-build.md +++ b/architecture/windows-msvc-build.md @@ -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. diff --git a/crates/openshell-driver-docker/src/isolation.rs b/crates/openshell-driver-docker/src/isolation.rs index b7f75efee4..75bd01c542 100644 --- a/crates/openshell-driver-docker/src/isolation.rs +++ b/crates/openshell-driver-docker/src/isolation.rs @@ -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, }, diff --git a/crates/openshell-driver-kubernetes/src/isolation.rs b/crates/openshell-driver-kubernetes/src/isolation.rs index db82f2adc5..dbf89d2db6 100644 --- a/crates/openshell-driver-kubernetes/src/isolation.rs +++ b/crates/openshell-driver-kubernetes/src/isolation.rs @@ -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, }, diff --git a/crates/openshell-driver-mxc/Cargo.toml b/crates/openshell-driver-mxc/Cargo.toml index a85e709d4e..9d359e4953 100644 --- a/crates/openshell-driver-mxc/Cargo.toml +++ b/crates/openshell-driver-mxc/Cargo.toml @@ -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 diff --git a/crates/openshell-driver-mxc/README.md b/crates/openshell-driver-mxc/README.md index b8c52f9898..af6f401514 100644 --- a/crates/openshell-driver-mxc/README.md +++ b/crates/openshell-driver-mxc/README.md @@ -1,222 +1,114 @@ # openshell-driver-mxc -OpenShell compute driver backed by **Microsoft MXC** (`wxc-exec`) on Windows. - -## Design - -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. - -## Capability Matrix - -| Capability | MXC driver | -|---|---| -| Filesystem policy | Read-only/read-write grants come only from `SandboxPolicy`. `process_container` enforces default-deny; `isolation_session` is an explicit grant-only compatibility mode. | -| UI policy | `process_container` advertises complete support and maps portable graphical UI, clipboard-direction, and input-injection controls to MXC; omitted fields inside an explicit section deny. `isolation_session` advertises no support, so the gateway rejects any explicit section before provisioning. | -| Network policy | With `egress_proxy = true` on `process_container`, split into MXC 0.8 loopback-only egress plus the full policy enforced by a per-sandbox OpenShell host CONNECT proxy. The driver injects proxy environment variables for proxy-aware clients; direct Internet access remains denied by MXC. Otherwise rejected synchronously. `isolation_session` remains fail-closed. | -| Provider credentials | The child receives revision-scoped placeholders and non-secret provider environment only. The per-sandbox host proxy retains the resolver and substitutes credentials only for their bound endpoints. | -| Process policy | Unsupported; MXC supplies OS isolation only. | -| Dynamic forwarding | Supported through `openshell-supervisor-relay`; interactive exec/connect remain unsupported. | -| Network middleware | Rejected before launch until the host proxy receives the gateway middleware registry. | -| ETW/OCSF audit | Optional Windows Sandboxing ETW consumer attributes host events to OpenShell sandboxes and emits OCSF records. | -| Restart durability | Unsupported; the in-memory registry cannot recover live sessions. | - -The filesystem enforcement proof has two paths: - -- 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]`) +The Windows-only MXC compute driver runs each workload in a Microsoft MXC +ProcessContainer while preserving OpenShell's standard RFC 0012 runtime split: + +```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 +``` -Gateway configuration contains only host runtime settings: +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. + +## Enforcement boundaries + +- 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. + +## Configuration + +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 `/.openshell-proxy//`, and injects `HTTP_PROXY`/`HTTPS_PROXY` while clearing `NO_PROXY` so inherited bypass rules cannot skip policy enforcement. - -UI capability (Win32k syscalls, clipboard, input injection) is a `SandboxPolicy` concern, not gateway TOML -- see the Capability Matrix above and `docs/reference/policy-schema.mdx`'s `ui` section. Defaults to disabled (Win32k syscall lockdown) when a policy has no explicit `ui:` section; set `allow_graphical_ui: true` for agents that touch user32/gdi32 at startup even without opening a real window (e.g. Node.js-based targets like OpenClaw's gateway -- see `examples/e2e-policies/openclaw-gateway.yaml`). - -`egress_proxy_addr` must be a `127.0.0.1:PORT` address. The port acts only as a configuration seed: the driver reserves a unique ephemeral loopback port for every sandbox. MXC 0.8 denies direct Internet egress and permits `127.0.0.1/32`; the driver points proxy-aware clients at the per-sandbox listener using environment variables. The current policy permits all loopback ports, so sandboxes can also reach unrelated host services bound to loopback. Control-channel forwarding does not require the legacy reverse-WebSocket connections to fresh host ports; restricting the generated policy is separate hardening work. Do not treat this path as loopback-service isolation. Live policy replacement or merge updates remain unsupported; delete and recreate the sandbox to apply a different policy. - -When `etw_audit` is enabled, each gateway process owns a distinct real-time ETW -session named from the stable `OpenShell-MXC-ETW` prefix, its process ID, and a -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. - -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. - -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 `/.openshell-proxy/` directory. Other -environment modes grant the sandbox's unique public-CA directory as an internal -read-write share. The directory contains only public CA certificates; -the ephemeral CA private key remains in the host proxy's memory. The driver -seeds only `SYSTEMROOT`, `WINDIR`, `PATH`, `COMSPEC`, and `LOCALAPPDATA` from the -gateway host before applying sandbox and TLS overrides, so required Windows -bootstrap values remain available without exposing the gateway's full -environment 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. +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. -```powershell -powershell -NoProfile -ExecutionPolicy Bypass ` - -File crates/openshell-driver-mxc/examples/probe-mxc-host.ps1 -``` +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. -**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. +## Capabilities -## Deferred work +| 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 | + +## Validation + +Run the Windows build lane on a native Windows MSVC host: + +```powershell +mise run windows:check:x64 +mise run windows:lint:x64 +mise run windows:build:x64 +mise run windows:test:mxc-real:x64 +``` -- **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. diff --git a/crates/openshell-driver-mxc/examples/README-openclaw-forward.txt b/crates/openshell-driver-mxc/examples/README-openclaw-forward.txt deleted file mode 100644 index c3aa149896..0000000000 --- a/crates/openshell-driver-mxc/examples/README-openclaw-forward.txt +++ /dev/null @@ -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-.zip - Hand that zip back. It contains the transcript, gateway logs (including the - sandbox's own forwarded stdout/stderr), the `openshell forward service` - output, the raw OpenClaw health-check response, OpenClaw's own captured - log, and the exact config + policy used. - - The capture wrapper also makes one credential-free WebSocket handshake to - OpenClaw from inside the sandbox after the gateway reports ready. It records - only an outcome and response-byte count, never response content. This is a - diagnostic boundary check: a local response with a failed host-side health - check points at the sandbox-boundary/forward path; no local response points - at the sandboxed OpenClaw target. A `started-no-completion` outcome means - even the probe's bounded socket/timer callbacks stopped progressing after - OpenClaw reported ready, which is evidence of a blocked target event loop. - The diagnostic never changes the PASS/FAIL verdict, which still requires - the authenticated host-side OpenClaw client. - -FILES IN THIS PACKAGE - openshell-gateway.exe the gateway (self-contained; needs only VC++ runtime) - openshell.exe the CLI - openshell-supervisor-relay.exe generic spawn+relay-bridge binary the driver - launches inside the sandbox in place of - OpenClaw directly (OpenClaw itself has no - relay awareness) - openclaw-capture.mjs thin Node.js wrapper that appends the - sandboxed process's stdout/stderr to a log - file in share_dir and records only the - outcome/byte count of a credential-free - target-side self-probe (OpenShell's own - adapter code, not OpenClaw's) - mxc-openclaw-gateway.toml gateway/driver config (process_container, default) - mxc-openclaw-isolation.toml gateway/driver config (-Backend isolation_session) - mxc-openclaw-localnet.toml experimental alternate process_container config - (-UseLocalNetwork; currently non-functional, - see run-openclaw-forward-test.ps1's own comment) - openclaw-gateway.yaml sandbox policy (read-write grant to share_dir - only -- see the comment at its top for why) - run-openclaw-forward-test.ps1 the orchestrator you run - install-nodejs-openclaw.ps1 optional prerequisite: fetches Node.js + - OpenClaw if you don't already have them - README-openclaw-forward.txt this file - -NOTES - - The control plane between CLI and gateway runs with --disable-tls on - loopback (that's a separate test point, T2). This test's relay traffic - (host <-> sandbox) is a separate, unrelated WebSocket tunnel. - - A "supervisor session not connected" / ssh 255 message during sandbox - create is EXPECTED on MXC and harmless - the agent already ran in-driver. - - `pc_minimal_env = true` in mxc-openclaw-gateway.toml (process_container - only) means the sandboxed process gets ONLY the env vars passed by - run-openclaw-forward-test.ps1 to `sandbox create`. That includes the - non-obvious minimum Windows values needed for CreateProcessW, independent - of anything Node.js-specific. isolation_session does not need this mode. - - The relay is entirely on-demand: nothing is listening on any fixed host - port before you run `openshell forward service`, and nothing is left - listening after the forward process exits. diff --git a/crates/openshell-driver-mxc/examples/e2e-policies/openclaw-gateway.yaml b/crates/openshell-driver-mxc/examples/e2e-policies/openclaw-gateway.yaml deleted file mode 100644 index a41ddb21d6..0000000000 --- a/crates/openshell-driver-mxc/examples/e2e-policies/openclaw-gateway.yaml +++ /dev/null @@ -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" diff --git a/crates/openshell-driver-mxc/examples/e2e-policies/ws-agent.yaml b/crates/openshell-driver-mxc/examples/e2e-policies/ws-agent.yaml deleted file mode 100644 index 6160be6f5c..0000000000 --- a/crates/openshell-driver-mxc/examples/e2e-policies/ws-agent.yaml +++ /dev/null @@ -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: [] diff --git a/crates/openshell-driver-mxc/examples/mxc-gateway.toml b/crates/openshell-driver-mxc/examples/mxc-gateway.toml index 4fbd2c41f5..f8c1368e24 100644 --- a/crates/openshell-driver-mxc/examples/mxc-gateway.toml +++ b/crates/openshell-driver-mxc/examples/mxc-gateway.toml @@ -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 diff --git a/crates/openshell-driver-mxc/examples/mxc-github-provider-profile.yml b/crates/openshell-driver-mxc/examples/mxc-github-provider-profile.yml deleted file mode 100644 index 49f8ee64e1..0000000000 --- a/crates/openshell-driver-mxc/examples/mxc-github-provider-profile.yml +++ /dev/null @@ -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" diff --git a/crates/openshell-driver-mxc/examples/mxc-ocsf-audit.toml b/crates/openshell-driver-mxc/examples/mxc-ocsf-audit.toml index a828264a5e..ac42b57fd9 100644 --- a/crates/openshell-driver-mxc/examples/mxc-ocsf-audit.toml +++ b/crates/openshell-driver-mxc/examples/mxc-ocsf-audit.toml @@ -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. diff --git a/crates/openshell-driver-mxc/examples/mxc-openclaw-gateway.toml b/crates/openshell-driver-mxc/examples/mxc-openclaw-gateway.toml deleted file mode 100644 index b85617a216..0000000000 --- a/crates/openshell-driver-mxc/examples/mxc-openclaw-gateway.toml +++ /dev/null @@ -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 diff --git a/crates/openshell-driver-mxc/examples/mxc-openclaw-isolation.toml b/crates/openshell-driver-mxc/examples/mxc-openclaw-isolation.toml deleted file mode 100644 index b81f5b5565..0000000000 --- a/crates/openshell-driver-mxc/examples/mxc-openclaw-isolation.toml +++ /dev/null @@ -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 diff --git a/crates/openshell-driver-mxc/examples/mxc-openclaw-localnet.toml b/crates/openshell-driver-mxc/examples/mxc-openclaw-localnet.toml deleted file mode 100644 index f0e4c578d9..0000000000 --- a/crates/openshell-driver-mxc/examples/mxc-openclaw-localnet.toml +++ /dev/null @@ -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 diff --git a/crates/openshell-driver-mxc/examples/mxc-provider-credential-policy.yaml b/crates/openshell-driver-mxc/examples/mxc-provider-credential-policy.yaml deleted file mode 100644 index cc6809fe70..0000000000 --- a/crates/openshell-driver-mxc/examples/mxc-provider-credential-policy.yaml +++ /dev/null @@ -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" diff --git a/crates/openshell-driver-mxc/examples/mxc-provider-credential-probe.ps1 b/crates/openshell-driver-mxc/examples/mxc-provider-credential-probe.ps1 deleted file mode 100644 index 56477c2342..0000000000 --- a/crates/openshell-driver-mxc/examples/mxc-provider-credential-probe.ps1 +++ /dev/null @@ -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, "") - } - $safe = [regex]::Replace( - $safe, - '(?i)(authorization:\s*bearer\s+)\S+', - '$1' - ) - $safe = ($safe -replace '\r?\n', ' | ').Trim() - if ($safe.Length -gt 512) { - $safe = $safe.Substring(0, 512) + "..." - } - return $safe -} - -function Invoke-CurlProbe( - [string] $CurlPath, - [string] $Url, - [string] $BodyPath, - [string] $CaBundle, - [string] $Token -) { - Remove-Item $BodyPath -Force -ErrorAction SilentlyContinue - - # Windows inbox curl uses Schannel, which ignores CURL_CA_BUNDLE as an - # environment variable. Pass the host proxy's generated bundle explicitly. - # Windows PowerShell also turns native stderr into ErrorRecord objects; use - # Continue locally and merge stderr into memory so curl failures become - # structured probe results. Avoid redirecting stderr to the shared folder: - # a file created by an earlier sandbox can carry a different AppContainer - # SID and cause PowerShell to throw UnauthorizedAccessException before curl - # starts. - $nativeOutput = @() - $exitCode = -1 - $previous = $ErrorActionPreference - $ErrorActionPreference = "Continue" - try { - $nativeOutput = @(& $CurlPath ` - --silent ` - --show-error ` - --connect-timeout 15 ` - --max-time 60 ` - --cacert $CaBundle ` - --ssl-revoke-best-effort ` - --output $BodyPath ` - --write-out "%{http_code}" ` - --header "Authorization: Bearer $Token" ` - --header "Accept: application/vnd.github+json" ` - --header "User-Agent: openshell-mxc-provider-credential-example" ` - $Url 2>&1) - $exitCode = $LASTEXITCODE - } finally { - $ErrorActionPreference = $previous - } - $stdout = @($nativeOutput | Where-Object { - $_ -isnot [System.Management.Automation.ErrorRecord] - } | ForEach-Object { $_.ToString() }) - $nativeErrors = @($nativeOutput | Where-Object { - $_ -is [System.Management.Automation.ErrorRecord] - } | ForEach-Object { $_.Exception.Message }) - $httpCode = ($stdout -join "").Trim() - $stderr = Get-SafeProbeText ` - -Text ($nativeErrors -join [Environment]::NewLine) ` - -Token $Token - $body = if (Test-Path $BodyPath) { - [System.IO.File]::ReadAllText($BodyPath, [System.Text.Encoding]::UTF8) - } else { - "" - } - $errorText = if ($exitCode -eq 0) { - $null - } elseif ([string]::IsNullOrWhiteSpace($stderr)) { - "curl exited $exitCode" - } else { - $stderr - } - - return [pscustomobject]@{ - HttpCode = $httpCode - Body = $body - Error = $errorText - } -} - -$checks = @() -$stage = "environment validation" -try { - $token = $env:GITHUB_TOKEN - if ([string]::IsNullOrWhiteSpace($token)) { - $checks += "[FAIL] GITHUB_TOKEN is unavailable" - Complete-Probe $checks - } - if ($token -notmatch '^openshell:resolve:env:v[0-9]+_GITHUB_TOKEN$') { - $checks += "[FAIL] MXC did not receive a revision-scoped GITHUB_TOKEN placeholder" - $checks += "[INFO] no network request was attempted" - Complete-Probe $checks - } - - $checks += "[PASS] MXC received only a revision-scoped GITHUB_TOKEN placeholder" - $curlPath = Join-Path $env:SystemRoot "System32\curl.exe" - if (-not (Test-Path $curlPath)) { - $checks += "[FAIL] inbox curl.exe is unavailable" - Complete-Probe $checks - } - $caBundle = $env:CURL_CA_BUNDLE - if ([string]::IsNullOrWhiteSpace($caBundle) -or -not (Test-Path $caBundle)) { - $checks += "[FAIL] host proxy CA bundle is unavailable" - Complete-Probe $checks - } - $checks += "[PASS] host proxy CA bundle is available to inbox curl" - - $stage = "api.github.com request" - $github = Invoke-CurlProbe ` - -CurlPath $curlPath ` - -Url "https://api.github.com/user" ` - -BodyPath (Join-Path $OutputDir "github-user-response.json") ` - -CaBundle $caBundle ` - -Token $token - if ($null -eq $github.Error -and $github.HttpCode -eq "200" -and $github.Body.Contains('"login"')) { - $checks += "[PASS] GitHub accepted the credential rewritten by the host CONNECT proxy (HTTP 200)" - } else { - $errorText = if ($null -eq $github.Error) { "none" } else { $github.Error } - $checks += "[FAIL] authenticated GitHub request failed (http=$($github.HttpCode), error=$errorText)" - } - - $stage = "github.com endpoint-mismatch request" - $mismatch = Invoke-CurlProbe ` - -CurlPath $curlPath ` - -Url "https://github.com/" ` - -BodyPath (Join-Path $OutputDir "credential-mismatch-response.json") ` - -CaBundle $caBundle ` - -Token $token - if ($null -eq $mismatch.Error -and $mismatch.HttpCode -eq "403" -and $mismatch.Body.Contains("credential_endpoint_mismatch")) { - $checks += "[PASS] proxy rejected placeholder use outside the GitHub binding (HTTP 403 credential_endpoint_mismatch)" - } else { - $errorText = if ($null -eq $mismatch.Error) { "none" } else { $mismatch.Error } - $checks += "[FAIL] endpoint-mismatch request was not rejected as expected (http=$($mismatch.HttpCode), error=$errorText)" - } -} catch { - # Report only the stage and exception type. Exception messages can echo - # native command arguments, and this probe must never persist credentials. - $checks += "[FAIL] probe encountered an unexpected error during $stage ($($_.Exception.GetType().Name))" -} - -Complete-Probe $checks diff --git a/crates/openshell-driver-mxc/examples/mxc-provider-credential.toml b/crates/openshell-driver-mxc/examples/mxc-provider-credential.toml deleted file mode 100644 index 65cd4a21c1..0000000000 --- a/crates/openshell-driver-mxc/examples/mxc-provider-credential.toml +++ /dev/null @@ -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 diff --git a/crates/openshell-driver-mxc/examples/mxc-ws-agent.rs b/crates/openshell-driver-mxc/examples/mxc-ws-agent.rs deleted file mode 100644 index dbd63f1426..0000000000 --- a/crates/openshell-driver-mxc/examples/mxc-ws-agent.rs +++ /dev/null @@ -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 : start the command in agent-cmd.txt as a child - // process, wait for it to bind , connect outward to the - // gateway relay, and bridge bidirectionally. Used to expose an - // arbitrary WebSocket server (e.g. openclaw gateway) to host - // clients via the OpenShell relay without modifying that server. - let port = std::env::args() - .nth(2) - .and_then(|s| s.parse::().ok()) - .expect("Usage: mxc-ws-agent proxy-for "); - proxy_for(port).await - } - other => { - eprintln!("mxc-ws-agent: unknown mode {other:?}. Use 'spawner' or 'server'."); - std::process::exit(2); - } - } -} - -// ── AppContainer SID (Windows only) ────────────────────────────────────────── - -/// Returns the `AppContainer` SID string of the current process, or `None` if -/// not running in an `AppContainer`. Written to `appcontainer-sid.txt` in the -/// share dir as a diagnostic aid. -#[cfg(windows)] -#[allow(unsafe_code)] // Windows token-query FFI is confined to this diagnostic helper. -fn appcontainer_sid() -> Option { - use std::ptr; - - #[link(name = "advapi32")] - unsafe extern "system" { - fn OpenProcessToken(process: isize, access: u32, token: *mut isize) -> i32; - fn GetTokenInformation( - token: isize, - class: i32, - info: *mut u8, - len: u32, - ret_len: *mut u32, - ) -> i32; - fn ConvertSidToStringSidW(sid: *const u8, str_sid: *mut *mut u16) -> i32; - } - #[link(name = "kernel32")] - unsafe extern "system" { - fn GetCurrentProcess() -> isize; - fn LocalFree(mem: *mut u8) -> *mut u8; - fn CloseHandle(handle: isize) -> i32; - } - - // SAFETY: output buffers remain alive through each call; successful token - // information contains a SID pointer into that buffer. The SID conversion - // returns a NUL-terminated allocation freed with LocalFree, and the owned - // token handle is closed on every return path. - unsafe { - let proc = GetCurrentProcess(); - let mut token: isize = 0; - if OpenProcessToken(proc, 0x0008, &raw mut token) == 0 { - return None; - } - let mut buf = [0u8; 256]; - let mut ret_len: u32 = 0; - let ok = GetTokenInformation(token, 31, buf.as_mut_ptr(), 256, &raw mut ret_len); - if ok == 0 { - CloseHandle(token); - return None; - } - let sid_ptr = ptr::read_unaligned(buf.as_ptr().cast::<*const u8>()); - if sid_ptr.is_null() { - CloseHandle(token); - return None; - } - let mut wide_ptr: *mut u16 = ptr::null_mut(); - if ConvertSidToStringSidW(sid_ptr, &raw mut wide_ptr) == 0 || wide_ptr.is_null() { - CloseHandle(token); - return None; - } - let mut len = 0; - while *wide_ptr.add(len) != 0 { - len += 1; - } - let slice = std::slice::from_raw_parts(wide_ptr, len); - let result = String::from_utf16_lossy(slice); - LocalFree(wide_ptr.cast()); - CloseHandle(token); - if result.is_empty() { - None - } else { - Some(result) - } - } -} - -#[cfg(not(windows))] -fn appcontainer_sid() -> Option { - None -} - -// ── Signal / relay address helpers ──────────────────────────────────────────── - -fn exe_dir() -> anyhow::Result { - Ok(std::env::current_exe()? - .parent() - .ok_or_else(|| anyhow::anyhow!("exe has no parent dir"))? - .to_path_buf()) -} - -fn signal_file_path() -> anyhow::Result { - Ok(exe_dir()?.join("openshell-shutdown.signal")) -} - -/// Read a file written by the host (ASCII, possibly with UTF-8 BOM from -/// `PowerShell` Set-Content -Encoding UTF8) and return the trimmed string. -fn read_host_file(path: &std::path::Path) -> Option { - std::fs::read_to_string(path) - .ok() - .map(|s| s.trim_start_matches('\u{FEFF}').trim().to_string()) - .filter(|s| !s.is_empty()) -} - -// ── Spawner ─────────────────────────────────────────────────────────────────── - -/// Process #1 — the sandbox `agent_command`. -/// -/// Responsibilities: -/// 1. Spawns the server subprocess and holds its stdin pipe. -/// 2. Waits for the server to bind its port (ws-server-started.txt). -/// 3. Reads reverse-relay-addr.txt and starts the relay proxy bridge. -/// 4. Polls for the shutdown signal file; on detection kills the server and exits. -/// -/// The server is a plain WebSocket application with no relay knowledge. -async fn spawner() -> anyhow::Result<()> { - let exe = std::env::current_exe()?; - let dir = exe_dir()?; - let signal = signal_file_path()?; - - // Remove stale files from a previous run. ws-server-started.txt in - // particular must go too: it encodes the server's port, and a stale copy - // would let the readiness wait below observe an old run's port instead - // of actually waiting for this run's server to (re)bind. - let _ = std::fs::remove_file(&signal); - if let Ok(dir) = exe_dir() { - let _ = std::fs::remove_file(dir.join("relay-ready.txt")); - let _ = std::fs::remove_file(dir.join("ws-server-started.txt")); - } - - // Write AppContainer SID for diagnostic use. - if let Some(sid) = appcontainer_sid() { - let _ = std::fs::write(dir.join("appcontainer-sid.txt"), &sid); - eprintln!("[spawner] AppContainer SID: {sid}"); - } else { - eprintln!("[spawner] not running in an AppContainer (no SID)"); - } - - // Spawn the server. - let mut cmd = tokio::process::Command::new(&exe); - cmd.arg("server") - .stdin(std::process::Stdio::piped()) - .stdout(std::process::Stdio::inherit()) - .stderr(std::process::Stdio::inherit()); - - let mut child = cmd.spawn()?; - let _pipe = child.stdin.take(); // keep write-end alive - eprintln!("[spawner] server started (pid {:?})", child.id()); - - // Wait up to 30 s for the server to write its startup marker, then start - // the relay proxy. We launch the proxy in a background task so the main - // lifecycle loop can still react to shutdown signals and server exit. - let relay_addr_file = dir.join("reverse-relay-addr.txt"); - let server_marker = dir.join("ws-server-started.txt"); - - let server_ready_deadline = tokio::time::Instant::now() + Duration::from_secs(30); - loop { - if server_marker.exists() { - break; - } - if tokio::time::Instant::now() >= server_ready_deadline { - eprintln!("[spawner] server did not start within 30 s; relay proxy skipped"); - break; - } - tokio::time::sleep(Duration::from_millis(200)).await; - } - - // Read the local server port from ws-server-started.txt ("port="). - let local_ws_port = read_host_file(&server_marker) - .and_then(|s| s.strip_prefix("port=").and_then(|n| n.parse::().ok())) - .unwrap_or(WS_PORT); - - // Start the relay proxy bridge if the gateway relay address is available. - let mut relay_task = read_host_file(&relay_addr_file).map_or_else( - || { - eprintln!("[spawner] no reverse-relay-addr.txt; relay proxy disabled"); - None - }, - |relay_addr| { - let local_url = format!("ws://127.0.0.1:{local_ws_port}"); - let relay_url = format!("ws://{relay_addr}"); - eprintln!("[spawner] starting relay proxy: {relay_url} <-> {local_url}"); - - let (bridge_stop_tx, bridge_stop_rx) = oneshot::channel::<()>(); - tokio::spawn(run_relay_proxy(relay_url, local_url, bridge_stop_rx)); - Some(bridge_stop_tx) - }, - ); - - // Main lifecycle loop: server exit or shutdown signal. - loop { - tokio::select! { - status = child.wait() => { - let code = status.map_or(1, |s| s.code().unwrap_or(1)); - eprintln!("[spawner] server exited with code {code}"); - let _ = std::fs::remove_file(&signal); - std::process::exit(code); - } - () = tokio::time::sleep(Duration::from_millis(500)) => { - if signal.exists() { - eprintln!("[spawner] shutdown signal -- killing server"); - // Stop the relay proxy first so the relay connection closes - // cleanly before we tear down the server. - if let Some(tx) = relay_task.take() { let _ = tx.send(()); } - let _ = child.kill().await; - let _ = child.wait().await; - let _ = std::fs::remove_file(&signal); - eprintln!("[spawner] done"); - std::process::exit(0); - } - } - } - } -} - -// ── Relay proxy bridge ──────────────────────────────────────────────────────── - -/// Connect outward to the gateway relay and inward to the local server, then -/// bridge WebSocket messages bidirectionally until either side closes or the -/// shutdown signal fires. -/// -/// The local server is a plain WebSocket application. The spawner acts as a -/// transparent proxy between the gateway relay and the local server, so the -/// server needs no knowledge of the relay. -async fn run_relay_proxy(relay_url: String, local_url: String, mut stop_rx: oneshot::Receiver<()>) { - const LOCAL_CONNECT_ATTEMPTS: u32 = 15; - const LOCAL_CONNECT_TIMEOUT: Duration = Duration::from_millis(500); - const LOCAL_CONNECT_BACKOFF: Duration = Duration::from_millis(300); - // Connect to the gateway relay (outbound via egress_proxy). - let relay_ws = match tokio_tungstenite::connect_async(&relay_url).await { - Ok((ws, _)) => { - eprintln!("[spawner] relay connected: {relay_url}"); - ws - } - Err(e) => { - let msg = format!("relay connect failed: {e}"); - eprintln!("[spawner] {msg}"); - if let Ok(dir) = exe_dir() { - let _ = std::fs::write(dir.join("relay-debug.txt"), &msg); - } - return; - } - }; - - // Connect to the local server (AppContainer-internal loopback), with - // retries. The in-sandbox server and the spawner's own AppContainer - // network-permission state can still be settling when this fires, so the - // first attempt can race the server's listen() call or a brief - // AppContainer network-policy warmup window. Without retry, a lost race - // manifests as a ~20s OS-level connect timeout (os error 10060) rather - // than an instant refusal, because the SYN is silently dropped, not - // rejected -- so each retry attempt uses a short timeout instead of - // waiting out that OS timeout on every try. - let mut local_ws = None; - let mut last_err = String::new(); - for attempt in 1..=LOCAL_CONNECT_ATTEMPTS { - match tokio::time::timeout( - LOCAL_CONNECT_TIMEOUT, - tokio_tungstenite::connect_async(&local_url), - ) - .await - { - Ok(Ok((ws, _))) => { - eprintln!( - "[spawner] local server connected: {local_url} (attempt {attempt}/{LOCAL_CONNECT_ATTEMPTS})" - ); - local_ws = Some(ws); - break; - } - Ok(Err(e)) => last_err = e.to_string(), - Err(_) => last_err = format!("timed out after {LOCAL_CONNECT_TIMEOUT:?}"), - } - eprintln!( - "[spawner] local server connect attempt {attempt}/{LOCAL_CONNECT_ATTEMPTS} failed ({last_err}); retrying" - ); - if attempt < LOCAL_CONNECT_ATTEMPTS { - tokio::time::sleep(LOCAL_CONNECT_BACKOFF).await; - } - } - let Some(local_ws) = local_ws else { - let msg = format!( - "local server connect failed after {LOCAL_CONNECT_ATTEMPTS} attempts ({local_url}): {last_err}" - ); - eprintln!("[spawner] {msg}"); - if let Ok(dir) = exe_dir() { - let _ = std::fs::write(dir.join("relay-debug.txt"), &msg); - } - return; - }; - - let (mut relay_write, mut relay_read) = relay_ws.split(); - let (mut local_write, mut local_read) = local_ws.split(); - - eprintln!("[spawner] relay proxy bridge active"); - - // Write a marker so the host can wait until the bridge is fully connected - // before sending the first message. - if let Ok(dir) = exe_dir() { - let _ = std::fs::write(dir.join("relay-ready.txt"), b"ok"); - } - - loop { - tokio::select! { - // Relay -> local server - msg = relay_read.next() => match msg { - Some(Ok(Message::Text(t))) => { - if local_write.send(Message::Text(t)).await.is_err() { break; } - } - Some(Ok(Message::Binary(b))) => { - if local_write.send(Message::Binary(b)).await.is_err() { break; } - } - Some(Ok(Message::Close(_))) | None => { - eprintln!("[spawner] relay closed"); - break; - } - Some(Ok(_)) => {} // ping/pong - Some(Err(e)) => { - eprintln!("[spawner] relay read error: {e}"); - break; - } - }, - // Local server -> relay - msg = local_read.next() => match msg { - Some(Ok(Message::Text(t))) => { - if relay_write.send(Message::Text(t)).await.is_err() { break; } - } - Some(Ok(Message::Binary(b))) => { - if relay_write.send(Message::Binary(b)).await.is_err() { break; } - } - Some(Ok(Message::Close(_))) | None => { - eprintln!("[spawner] local server closed"); - break; - } - Some(Ok(_)) => {} - Some(Err(e)) => { - eprintln!("[spawner] local server read error: {e}"); - break; - } - }, - _ = &mut stop_rx => { - eprintln!("[spawner] relay proxy stopped by shutdown"); - break; - } - } - } - - eprintln!("[spawner] relay proxy bridge exited"); -} - -// ── Server ──────────────────────────────────────────────────────────────────── - -/// Process #2 — a plain WebSocket echo server. -/// -/// This is a stand-in for any real application. It has no knowledge of any -/// relay -- launched directly as the `agent_command` `openshell-supervisor- -/// relay.exe` wraps (see mxc-ws-gateway.toml's `pc_relay_spawner_path`/ -/// `pc_relay_target_port`), the same way `OpenClaw`'s gateway is. Runs until -/// killed; there is no cooperative shutdown protocol to implement (the -/// generic spawner just kills its target on shutdown, same as any other -/// wrapped process), so this loops on `listener.accept()` alone. -async fn server() -> anyhow::Result<()> { - let listener = TcpListener::bind(("0.0.0.0", WS_PORT)).await?; - eprintln!("[server] WebSocket listening on 0.0.0.0:{WS_PORT}"); - - let active = Arc::new(AtomicUsize::new(0)); - - loop { - let (stream, addr) = listener.accept().await?; - let active2 = active.clone(); - active2.fetch_add(1, Ordering::Relaxed); - tokio::spawn(async move { - handle_connection(stream, addr).await; - active2.fetch_sub(1, Ordering::Relaxed); - }); - } -} - -// ── WebSocket connection handler ────────────────────────────────────────────── - -async fn handle_connection(stream: TcpStream, addr: SocketAddr) { - eprintln!("[server] new connection from {addr}"); - - let ws = match tokio_tungstenite::accept_async(stream).await { - Ok(ws) => ws, - Err(e) => { - eprintln!("[server] handshake failed from {addr}: {e}"); - return; - } - }; - - let (mut write, mut read) = ws.split(); - - while let Some(msg) = read.next().await { - match msg { - Ok(Message::Text(text)) => { - eprintln!("[server] {addr} recv: {text}"); - if write.send(Message::Text(text)).await.is_err() { - break; - } - } - Ok(Message::Binary(bin)) => { - if write.send(Message::Binary(bin)).await.is_err() { - break; - } - } - Ok(Message::Close(_)) => break, - Ok(_) => {} - Err(e) => { - eprintln!("[server] {addr} error: {e}"); - break; - } - } - } - - eprintln!("[server] connection from {addr} ended"); -} - -// ── proxy-for mode ──────────────────────────────────────────────────────────── - -/// Start the command listed in `agent-cmd.txt` in the share dir as a child -/// process, wait for it to accept TCP on `port`, then connect outward to the -/// gateway relay and bridge all WebSocket traffic to/from the local server. -/// -/// This lets any WebSocket server (e.g. openclaw gateway) be exposed to host -/// clients via the `OpenShell` relay without any changes to that server. -async fn proxy_for(port: u16) -> anyhow::Result<()> { - // Early sentinel: write to exe_dir so it works in any container directory. - if let Ok(exe) = std::env::current_exe() - && let Some(d) = exe.parent() - { - let _ = std::fs::write( - d.join("proxy-for-started.txt"), - format!("port={port} exe={}", exe.display()), - ); - } - - let dir = match exe_dir() { - Ok(d) => d, - Err(e) => { - let _ = std::fs::write( - "C:\\work\\openshell-mxc-openclaw\\proxy-for-error.txt", - format!("exe_dir failed: {e}"), - ); - return Err(e); - } - }; - let signal = signal_file_path()?; - let _ = std::fs::remove_file(&signal); - if let Ok(d) = exe_dir() { - let _ = std::fs::remove_file(d.join("relay-ready.txt")); - } - - // Read the command to launch from agent-cmd.txt in the share dir. - // Each line is one argument; the first line is the executable. - let cmd_file = dir.join("agent-cmd.txt"); - let mut child = if cmd_file.exists() { - let lines: Vec = std::fs::read_to_string(&cmd_file)? - .lines() - .map(|l| l.trim_start_matches('\u{FEFF}').trim().to_string()) - .filter(|l| !l.is_empty()) - .collect(); - if lines.is_empty() { - anyhow::bail!("agent-cmd.txt is empty"); - } - let stdout_file = std::fs::File::create(dir.join("agent-stdout.txt")).ok(); - let stderr_file = std::fs::File::create(dir.join("agent-stderr.txt")).ok(); - // If agent-env.txt exists in the share dir, set the child process env - // explicitly (clear parent env, then set only those vars). This allows - // running runtimes like node.js that fail with STATUS_DLL_INIT_FAILED - // when presented with the full host env, while mxc-ws-agent itself - // (the parent) still runs with the full env it needs. - // agent-env.txt format: one KEY=VALUE per line. - let env_file = dir.join("agent-env.txt"); - let mut cmd = tokio::process::Command::new(&lines[0]); - cmd.args(&lines[1..]); - if env_file.exists() - && let Ok(content) = std::fs::read_to_string(&env_file) - { - let child_env: Vec<(String, String)> = content - .lines() - .map(|l| l.trim_start_matches('\u{FEFF}').trim().to_string()) - .filter(|l| !l.is_empty() && l.contains('=')) - .filter_map(|l| { - let pos = l.find('=')?; - Some((l[..pos].to_string(), l[pos + 1..].to_string())) - }) - .collect(); - eprintln!( - "[proxy-for] using {} child env vars from agent-env.txt", - child_env.len() - ); - cmd.env_clear().envs(child_env); - } - cmd.stdout( - stdout_file.map_or_else(std::process::Stdio::inherit, std::process::Stdio::from), - ) - .stderr(stderr_file.map_or_else(std::process::Stdio::inherit, std::process::Stdio::from)); - eprintln!("[proxy-for] starting: {}", lines.join(" ")); - Some(cmd.spawn()?) - } else { - eprintln!("[proxy-for] no agent-cmd.txt; assuming server already running on port {port}"); - None - }; - - // Wait up to 60 s for the server to accept TCP on `port`. - eprintln!("[proxy-for] waiting for server on 127.0.0.1:{port} ..."); - let deadline = tokio::time::Instant::now() + Duration::from_mins(1); - loop { - if TcpStream::connect(format!("127.0.0.1:{port}")) - .await - .is_ok() - { - eprintln!("[proxy-for] server is up on port {port}"); - break; - } - if tokio::time::Instant::now() >= deadline { - anyhow::bail!("[proxy-for] timeout waiting for server on port {port}"); - } - tokio::time::sleep(Duration::from_millis(500)).await; - // Also check for early child exit - if let Some(ref mut c) = child - && let Ok(Some(status)) = c.try_wait() - { - anyhow::bail!("[proxy-for] child exited early: {status}"); - } - } - - // Connect relay and run bridge. - let relay_addr_file = dir.join("reverse-relay-addr.txt"); - if let Some(relay_addr) = read_host_file(&relay_addr_file) { - let relay_url = format!("ws://{relay_addr}"); - let local_url = format!("ws://127.0.0.1:{port}"); - eprintln!("[proxy-for] relay bridge: {relay_url} <-> {local_url}"); - let (bridge_stop_tx, bridge_stop_rx) = oneshot::channel::<()>(); - tokio::spawn(run_relay_proxy(relay_url, local_url, bridge_stop_rx)); - - // Main lifecycle: child exit or shutdown signal. - loop { - tokio::select! { - status = async { - if let Some(ref mut c) = child { c.wait().await.ok() } else { std::future::pending().await } - } => { - eprintln!("[proxy-for] child exited: {status:?}"); - let _ = std::fs::remove_file(&signal); - break; - } - () = tokio::time::sleep(Duration::from_millis(500)) => { - if signal.exists() { - eprintln!("[proxy-for] shutdown signal -- stopping"); - let _ = bridge_stop_tx.send(()); - if let Some(ref mut c) = child { let _ = c.kill().await; let _ = c.wait().await; } - let _ = std::fs::remove_file(&signal); - break; - } - } - } - } - } else { - eprintln!("[proxy-for] no reverse-relay-addr.txt; relay bridge disabled"); - // Still manage child lifecycle. - if let Some(mut c) = child { - let _ = c.wait().await; - } - } - Ok(()) -} diff --git a/crates/openshell-driver-mxc/examples/mxc-ws-gateway.toml b/crates/openshell-driver-mxc/examples/mxc-ws-gateway.toml deleted file mode 100644 index afd04a44cd..0000000000 --- a/crates/openshell-driver-mxc/examples/mxc-ws-gateway.toml +++ /dev/null @@ -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 diff --git a/crates/openshell-driver-mxc/examples/ocsf-audit.yaml b/crates/openshell-driver-mxc/examples/ocsf-audit.yaml index 13e7cd571e..784ac77fb0 100644 --- a/crates/openshell-driver-mxc/examples/ocsf-audit.yaml +++ b/crates/openshell-driver-mxc/examples/ocsf-audit.yaml @@ -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: diff --git a/crates/openshell-driver-mxc/examples/openclaw-capture.mjs b/crates/openshell-driver-mxc/examples/openclaw-capture.mjs deleted file mode 100644 index eb32f624c0..0000000000 --- a/crates/openshell-driver-mxc/examples/openclaw-capture.mjs +++ /dev/null @@ -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.mjs) -// NEMOCLAW_MXC_CAPTURE_LOG absolute path to append captured output to -// -// Usage: node openclaw-capture.mjs -// Equivalent to: node , except stdout -// and stderr are also appended to NEMOCLAW_MXC_CAPTURE_LOG as they're written. - -import fs, { appendFileSync } from "node:fs"; -import { syncBuiltinESMExports } from "node:module"; -import { createConnection } from "node:net"; -import { pathToFileURL } from "node:url"; -import { promisify } from "node:util"; - -// Node's promises realpath implementation uses a native Windows binding that -// requests privileges unavailable to AppContainer tokens. The callback -// implementation has the same realpath contract without those privileges. -// Patch before importing OpenClaw so node:fs/promises consumers see it too. -if (process.platform === "win32") { - fs.promises.realpath = promisify(fs.realpath); - syncBuiltinESMExports(); -} - -const required = (name) => { - const value = process.env[name]; - if (!value) throw new Error(name + " is required"); - return value; -}; - -const entry = required("NEMOCLAW_MXC_CAPTURE_ENTRY"); -const logPath = required("NEMOCLAW_MXC_CAPTURE_LOG"); -const selfProbePort = process.env.NEMOCLAW_MXC_CAPTURE_SELF_PROBE_PORT; - -const append = (label, value) => { - try { - appendFileSync(logPath, "[" + label + "] " + String(value), "utf8"); - } catch { - // best-effort; never let logging failure take down the wrapped process - } -}; - -const connectProbe = (host, port, timeoutMs = 5000) => - new Promise((resolve) => { - let settled = false; - const socket = createConnection({ host, port: Number(port) }); - const finish = (connected, detail) => { - if (settled) return; - settled = true; - clearTimeout(deadline); - socket.destroy(); - resolve({ connected, detail }); - }; - const deadline = setTimeout(() => finish(false, "timeout"), timeoutMs); - socket.once("connect", () => finish(true, "connected")); - socket.once("error", (error) => - finish(false, String(error?.code || error?.message || error)), - ); - }); - -const fetchProbe = async (url, timeoutMs = 15000) => { - try { - const response = await fetch(url, { - redirect: "manual", - signal: AbortSignal.timeout(timeoutMs), - }); - await response.body?.cancel(); - return { connected: true, detail: "status=" + response.status }; - } catch (error) { - return { - connected: false, - detail: String(error?.cause?.code || error?.code || error?.message || error), - }; - } -}; - -const runEgressProof = async () => { - if (process.env.NEMOCLAW_MXC_EGRESS_PROOF !== "1") return; - const result = { - proxyConfigured: Boolean(process.env.HTTPS_PROXY || process.env.https_proxy), - allowedViaProxy: await fetchProbe(required("NEMOCLAW_MXC_EGRESS_ALLOWED_URL")), - deniedViaProxy: await fetchProbe(required("NEMOCLAW_MXC_EGRESS_DENIED_URL")), - directInternetBypass: await connectProbe( - required("NEMOCLAW_MXC_EGRESS_DIRECT_HOST"), - 443, - ), - unrelatedHostLoopback: await connectProbe( - "127.0.0.1", - required("NEMOCLAW_MXC_EGRESS_LOOPBACK_PORT"), - ), - }; - append("egress-proof", JSON.stringify(result) + "\n"); - console.log("[egress-proof] " + JSON.stringify(result)); -}; - -let selfProbeStarted = false; -let readinessWindow = ""; - -const startSelfProbe = () => { - if (selfProbeStarted || !selfProbePort) return; - selfProbeStarted = true; - append("self-probe-attempt", "started\n"); - - const port = Number(selfProbePort); - if (!Number.isSafeInteger(port) || port < 1 || port > 65535) { - append("self-probe", "invalid_port\n"); - return; - } - - const maxAttempts = 6; - let attempt = 0; - const probe = () => { - attempt += 1; - let responseBytes = 0; - let settled = false; - const socket = createConnection({ host: "127.0.0.1", port }); - const finish = (outcome, errorCode = "none") => { - if (settled) return; - settled = true; - clearTimeout(deadline); - socket.destroy(); - if (responseBytes > 0 || attempt >= maxAttempts) { - append( - "self-probe", - `outcome=${responseBytes > 0 ? "response" : outcome} response_bytes=${responseBytes} attempts=${attempt} error_code=${errorCode}\n`, - ); - } else { - setTimeout(probe, 1000); - } - }; - const deadline = setTimeout(() => finish("timeout"), 2000); - - socket.once("connect", () => { - // This is the same protocol boundary exercised by the host-side health - // client, but it intentionally sends no token or other credential. Any - // HTTP or WebSocket response proves the target can service a connection - // from inside the ProcessContainer; payload content is never recorded. - socket.write( - `GET / HTTP/1.1\r\nHost: 127.0.0.1:${port}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: b3BlbnNoZWxsLW14YyE=\r\nSec-WebSocket-Version: 13\r\n\r\n`, - ); - }); - socket.on("data", (chunk) => { - responseBytes += chunk.length; - finish("response"); - }); - socket.once("error", (error) => - finish("error", String(error?.code || "unknown").replace(/[^A-Z0-9_-]/gi, "_")), - ); - socket.once("close", () => { - if (!settled) finish(responseBytes > 0 ? "response" : "closed"); - }); - }; - probe(); -}; - -const wrap = (stream, label) => { - const original = stream.write.bind(stream); - stream.write = (chunk, encoding, callback) => { - const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk); - append(label, text); - // Logging libraries may split one rendered line across multiple writes - // (and may insert ANSI sequences), so detect readiness across chunk - // boundaries rather than requiring one exact write call. - readinessWindow = (readinessWindow + text).slice(-512); - if (/\[gateway\][\s\S]{0,256}ready/.test(readinessWindow)) startSelfProbe(); - return original(chunk, encoding, callback); - }; -}; - -wrap(process.stdout, "stdout"); -wrap(process.stderr, "stderr"); -append("self-probe", selfProbePort ? "configured\n" : "disabled\n"); -if (selfProbePort) { - // Readiness normally triggers the probe immediately. Keep a delayed - // fallback because some logging stacks bypass or split stdout writes in a - // way the wrapper cannot observe reliably. - setTimeout(startSelfProbe, 10000); -} -process.on("uncaughtExceptionMonitor", (error) => - append("uncaught", String(error?.stack || error) + "\n"), -); -process.on("unhandledRejection", (error) => - append("rejection", String(error?.stack || error) + "\n"), -); - -process.argv = [process.execPath, entry, ...process.argv.slice(2)]; -await runEgressProof(); -await import(pathToFileURL(entry).href); diff --git a/crates/openshell-driver-mxc/examples/run-ocsf-audit.ps1 b/crates/openshell-driver-mxc/examples/run-ocsf-audit.ps1 index 591c38bf24..b7747cf664 100644 --- a/crates/openshell-driver-mxc/examples/run-ocsf-audit.ps1 +++ b/crates/openshell-driver-mxc/examples/run-ocsf-audit.ps1 @@ -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..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. diff --git a/crates/openshell-driver-mxc/examples/run-openclaw-forward-test.ps1 b/crates/openshell-driver-mxc/examples/run-openclaw-forward-test.ps1 deleted file mode 100644 index 6935b68754..0000000000 --- a/crates/openshell-driver-mxc/examples/run-openclaw-forward-test.ps1 +++ /dev/null @@ -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\] (?\{.*\})$' -ErrorAction SilentlyContinue | Select-Object -Last 1 - $proof = $null - if ($proofMatch) { - try { $proof = $proofMatch.Matches[0].Groups['json'].Value | ConvertFrom-Json } - catch { Info "could not parse egress proof JSON: $($_.Exception.Message)" } - } - $proofPassed = $proof -and - $proof.proxyConfigured -eq $true -and - $proof.allowedViaProxy.connected -eq $true -and - $proof.deniedViaProxy.connected -eq $false -and - $proof.directInternetBypass.connected -eq $false - if ($proofPassed) { - Ok "allowed host passed proxy; denied host and direct Internet bypass were blocked" - Info "unrelated host loopback reachable: $($proof.unrelatedHostLoopback.connected) (known limitation)" - } else { - $passed = $false - Bad "governed egress proof failed or was not recorded" - } - } finally { - if ($null -eq $savedOpenClawConfigPath) { - Remove-Item Env:OPENCLAW_CONFIG_PATH -ErrorAction SilentlyContinue - } else { - $env:OPENCLAW_CONFIG_PATH = $savedOpenClawConfigPath - } - if ($null -eq $savedOpenClawStateDir) { - Remove-Item Env:OPENCLAW_STATE_DIR -ErrorAction SilentlyContinue - } else { - $env:OPENCLAW_STATE_DIR = $savedOpenClawStateDir - } - } -} -catch { - Bad $_.Exception.Message -} -finally { - # Stop the forward before the sandbox so its relay tears down cleanly. - if ($fwdProc -and -not $fwdProc.HasExited) { - try { Stop-Process -Id $fwdProc.Id -Force -ErrorAction SilentlyContinue } catch {} - } - - # Tear down the sandbox while the gateway is still up (delete needs it). - if ($cli -and $SandboxName) { - try { & $cli sandbox delete $SandboxName 2>&1 | Out-Null } - catch { Info "sandbox teardown '$SandboxName': $($_.Exception.Message) (continuing)" } - } - - if ($KeepRunning -and $gw -and -not $gw.HasExited) { - Info "leaving gateway pid $($gw.Id) running (-KeepRunning); stop with: Stop-Process -Id $($gw.Id) -Force" - } elseif ($gw -and -not $gw.HasExited) { - Step "Cleanup"; Stop-Process -Id $gw.Id -Force -ErrorAction SilentlyContinue - try { $gw.WaitForExit(5000) | Out-Null } catch {} - Info "stopped gateway pid $($gw.Id)" - } - - Step "Gateway log (tail)" - if (Test-Path $gwLog) { - Get-Content $gwLog -Tail 30 -Encoding UTF8 -ErrorAction SilentlyContinue | ForEach-Object { Info $_ } - } - - # Copy the OpenClaw capture log (if it made it far enough to write one) for - # post-hoc debugging, then extract only the credential-free target-side - # self-probe outcome. The probe records a byte count, never response data. - # It is diagnostic and cannot make the end-to-end verdict pass: only the - # authenticated host-side OpenClaw client above owns that verdict. - $captureLog = Join-Path $shareDirNorm "openclaw-capture.log" - if (Test-Path $captureLog) { - Copy-Item $captureLog (Join-Path $resultDir "openclaw-capture.log") -Force -ErrorAction SilentlyContinue - $selfProbeLine = Select-String -Path $captureLog -Pattern '\[self-probe\] outcome=([^ ]+) response_bytes=([0-9]+)' -AllMatches -ErrorAction SilentlyContinue | Select-Object -Last 1 - if ($selfProbeLine -and $selfProbeLine.Matches.Count -gt 0) { - $selfProbeOutcome = $selfProbeLine.Matches[0].Groups[1].Value - $selfProbeResponseBytes = [int64]$selfProbeLine.Matches[0].Groups[2].Value - } elseif (Select-String -Path $captureLog -Pattern '\[self-probe\] invalid_port' -Quiet -ErrorAction SilentlyContinue) { - $selfProbeOutcome = "invalid-port" - } elseif (Select-String -Path $captureLog -Pattern '\[self-probe-attempt\] started' -Quiet -ErrorAction SilentlyContinue) { - $selfProbeOutcome = "started-no-completion" - } - } - if ($selfProbeOutcome -eq "response" -and $selfProbeResponseBytes -gt 0) { - Info "target-side self-probe: response ($selfProbeResponseBytes bytes); OpenClaw serviced a local sandbox connection" - } else { - Info "target-side self-probe: $selfProbeOutcome ($selfProbeResponseBytes bytes); inspect the sandboxed OpenClaw target/event loop" - } - - Step "RESULT" - $verdict = if ($passed) { "PASS" } else { "FAIL" } - $summary = @" -OpenShell MXC OpenClaw + dynamic forward test -===================================================================== -timestamp : $stamp -machine : $env:COMPUTERNAME -verdict : $verdict -sandbox : $SandboxName -backend : $Backend -config : $tomlName -target_port : $TargetPort (inside sandbox) -forward_local_port : $ForwardLocalPort (on this host) -wxc_exec : $WxcExecPath -node_exe : $NodeExePath -openclaw_install : $OpenClawInstallDir -target_self_probe : $selfProbeOutcome ($selfProbeResponseBytes response bytes; diagnostic only) - -What PASS means: the gateway created a sandbox on the $Backend backend (no -in-sandbox supervisor process; ProcessContainer also has no inbound network -capability at all); openshell-supervisor-relay launched OpenClaw's gateway -inside it via the driver's control channel; -`openshell forward service` opened a fresh, on-demand WebSocket relay for -this one call (nothing pre-declared beyond the startup liveness port); and a -REAL OpenClaw client running on this host, talking only through that -forwarded port, authenticated with a token and got back a real 'ok: true' -health response. The egress proof also required an allowed HTTPS request to -pass through the OpenShell proxy while a denied host and direct Internet -bypass were blocked. Host loopback remains broadly reachable because dynamic -forwarding uses ephemeral loopback ports. - -Files in this bundle: - transcript.txt full console transcript - gateway.log/.err.log gateway stdout/stderr (includes - forwarded sandbox stdout/stderr, tagged - "wxc-exec stdout:"/"wxc-exec stderr:") - forward.log/.err.log `openshell forward service` stdout/stderr - openclaw-health-raw.txt raw output of the OpenClaw health client - openclaw-capture.log OpenClaw's own captured stdout/stderr - plus credential-free target self-probe - outcome/byte count (no response payload) - (if the sandbox got far enough to write it) - ${tomlBaseName}.used.toml exact config used (wxc_exec_path patched) - openclaw-gateway.used.yaml exact policy used -"@ - Set-Content -Path (Join-Path $resultDir "summary.txt") -Value $summary -Encoding UTF8 - Write-Host $summary -ForegroundColor ($(if ($passed) { "Green" } else { "Red" })) - - try { Stop-Transcript | Out-Null } catch {} - try { - $zip = Join-Path $here "results-openclaw-forward-$stamp.zip" - if (Test-Path $zip) { Remove-Item $zip -Force } - Compress-Archive -Path (Join-Path $resultDir "*") -DestinationPath $zip -Force - Write-Host "`nBUNDLE: $zip" -ForegroundColor Yellow - Write-Host "Hand that zip back for evaluation." -ForegroundColor Yellow - } catch { Write-Host "zip failed: $($_.Exception.Message)" -ForegroundColor Red } -} - -if ($passed) { exit 0 } else { exit 1 } diff --git a/crates/openshell-driver-mxc/examples/run-provider-credential-test.ps1 b/crates/openshell-driver-mxc/examples/run-provider-credential-test.ps1 deleted file mode 100644 index daffcbe424..0000000000 --- a/crates/openshell-driver-mxc/examples/run-provider-credential-test.ps1 +++ /dev/null @@ -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 } diff --git a/crates/openshell-driver-mxc/examples/run-ws-agent-test.ps1 b/crates/openshell-driver-mxc/examples/run-ws-agent-test.ps1 deleted file mode 100644 index 0564d17fc5..0000000000 --- a/crates/openshell-driver-mxc/examples/run-ws-agent-test.ps1 +++ /dev/null @@ -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: " (see run.rs); - # require Ready, not just presence -- a sandbox that exists but is stuck - # Provisioning/Error is not actually usable for the WebSocket check below. - $createOk = ($getExitCode -eq 0) -and ($getStr -notmatch 'not found|does not exist') -and ($getStr -match '(?m)^\s*Phase:\s*Ready\s*$') - Record "sandbox-create" $createOk "sandbox $sandboxName $(if ($createOk) {'exists and is Ready'} else {'not found or not Ready after create'})" - - if (-not $createOk) { - Info "create output: $createStr" - Info "get output: $getStr" - throw "sandbox create failed: sandbox does not exist or is not Ready after create" - } - - # --- WebSocket connectivity (real mode only) ------------------------------ - - if (-not $Mock) { - - Step "Wait for WebSocket server on port $WsPort" - $serverUp = Wait-PortOpen -port $WsPort -seconds 30 - if ($serverUp) { - Record "server-port-open" $true "port $WsPort is listening" - } else { - $gwText = (Get-Content $gwLog, $gwErrLog -Raw -ErrorAction SilentlyContinue) -join "`n" - $detail = "port $WsPort did NOT open within 30 s" - if ($gwText -match 'CreateProcessW failed') { - $detail = $detail + " (gateway log: agent launch failure)" - } - Record "server-port-open" $false $detail - } - - # Cross-check server-port-open against openshell-supervisor-relay's own - # confirmation, from inside the sandbox: it detects target-port - # readiness itself (wait_for_port_ready, gated by the "launch" - # handshake) and logs it, forwarded into the gateway log the same way - # as every other wxc-exec stdout/stderr line. No share_dir file - # needed -- this is the same information the marker file used to - # carry, just sourced from the spawner's own diagnostic instead. - if ($serverUp) { - Step "Verify spawner's own port-ready confirmation (gateway log)" - # The spawner's own polling (wait_for_port_ready, 300ms interval) - # runs independently of this script's Wait-PortOpen above -- its - # log line can land a couple of seconds after the raw TCP connect - # already succeeded (observed up to ~2.3s). Poll for it rather - # than checking once immediately, or this races and fails spuriously. - $readyPattern = "port $WsPort ready after" - $readyDeadline = (Get-Date).AddSeconds(15) - $readyOk = $false - while ((Get-Date) -lt $readyDeadline -and -not $readyOk) { - $readyOk = (Test-Path $gwLog -PathType Leaf) -and (Select-String -Path $gwLog -Pattern $readyPattern -Quiet -ErrorAction SilentlyContinue) - if (-not $readyOk) { - $readyOk = (Test-Path $gwErrLog -PathType Leaf) -and (Select-String -Path $gwErrLog -Pattern $readyPattern -Quiet -ErrorAction SilentlyContinue) - } - if (-not $readyOk) { Start-Sleep -Milliseconds 300 } - } - if ($readyOk) { - Record "ws-server-marker" $true "spawner logged '$readyPattern' in the gateway log" - } else { - Record "ws-server-marker" $false "spawner's port-ready log line not found in gateway.log/gateway.err.log within 15 s" - } - } else { - Warn "skipping ws-server-marker: server did not start" - } - - # `openshell forward service` opens a fresh, on-demand relay for this - # one call, bridging $WsPort (inside the sandbox) to $RelayPort (on - # this host). No port needs to be pre-declared anywhere except - # pc_relay_target_port's own startup liveness check. Mirrors - # run-openclaw-forward-test.ps1's step 10. - if ($serverUp) { - Step "openshell forward service --target-port $WsPort --local $RelayPort" - $script:fwdProc = Start-Process -FilePath $cli ` - -ArgumentList @("forward", "service", "--target-port", "$WsPort", "--local", "$RelayPort", $sandboxName) ` - -WorkingDirectory $here -PassThru -NoNewWindow ` - -RedirectStandardOutput $fwdLog -RedirectStandardError $fwdErrLog - Info "forward pid $($script:fwdProc.Id)" - $fwdDeadline = (Get-Date).AddSeconds(20); $fwdUp = $false - while ((Get-Date) -lt $fwdDeadline) { - if ($script:fwdProc.HasExited) { break } - if ((Test-Path $fwdLog) -and (Select-String -Path $fwdLog -Pattern 'Forwarding' -Quiet -ErrorAction SilentlyContinue)) { $fwdUp = $true; break } - if ((Test-Path $fwdErrLog) -and (Select-String -Path $fwdErrLog -Pattern 'Forwarding' -Quiet -ErrorAction SilentlyContinue)) { $fwdUp = $true; break } - Start-Sleep -Milliseconds 500 - } - - if ($fwdUp) { - Ok "forward active: 127.0.0.1:$RelayPort -> sandbox:$WsPort" - - Step "WebSocket echo test via forwarded port (ws://127.0.0.1:$RelayPort)" - $echoOk = Test-WsEcho -wsHost "127.0.0.1" -port $RelayPort -msg $WsMessage - if ($echoOk) { - Record "ws-echo" $true ("'" + $WsMessage + "' echoed via forwarded port $RelayPort") - } else { - Record "ws-echo" $false "echo failed via forwarded port $RelayPort -- see transcript" - } - } else { - $exitDetail = if ($script:fwdProc.HasExited) { " (forward process exited early, code $($script:fwdProc.ExitCode))" } else { "" } - Record "ws-echo" $false "forward did not report 'Forwarding ...' within 20 s$exitDetail -- see forward.log/forward.err.log" - } - } else { - Warn "skipping ws-echo: server did not start" - } - - } else { - Info "[mock] skipping server-port-open and ws-echo" - } - - # Stop the forward before deleting the sandbox it points at. - if ($script:fwdProc -and -not $script:fwdProc.HasExited) { - try { Stop-Process -Id $script:fwdProc.Id -Force -ErrorAction SilentlyContinue } catch {} - } - - # --- Delete sandbox ------------------------------------------------------- - - Step "Delete sandbox '$sandboxName'" - $deleteOut = $null; $deleteExitCode = 0 - try { - $deleteOut = & $cli sandbox delete $sandboxName 2>&1 - $deleteExitCode = $LASTEXITCODE - } catch { - $deleteOut = $_.Exception.Message; $deleteExitCode = 1 - } - $deleteStr = ($deleteOut -join "`n") - Info "delete exit: $deleteExitCode" - if ($deleteExitCode -ne 0) { Info "output: $deleteStr" } - Record "sandbox-delete" ($deleteExitCode -eq 0) "exit $deleteExitCode" - - # --- Port freed (real mode only) ------------------------------------------ - - if (-not $Mock) { - Step "Verify port $WsPort is released after delete" - # The driver sends a "shutdown" control-channel request (openshell- - # supervisor-relay kills the server directly) and kills wxc-exec as a - # backstop regardless. Allow 30 s for that plus the OS to release the - # port. - $portClosed = Wait-PortClosed -port $WsPort -seconds 30 - if ($portClosed) { - Record "port-freed" $true "port $WsPort released within 30 s" - } else { - Record "port-freed" $false "port $WsPort still bound after 30 s" - } - } else { - Info "[mock] skipping port-freed check" - } - -} catch { - $harnessError = $_.Exception.Message - Bad "harness error: $harnessError" -} finally { - # --- Teardown ------------------------------------------------------------- - - # Best-effort forward/sandbox cleanup in case the test failed mid-run. - if ($script:fwdProc -and -not $script:fwdProc.HasExited) { - try { Stop-Process -Id $script:fwdProc.Id -Force -ErrorAction SilentlyContinue } catch {} - } - try { & $cli sandbox delete $sandboxName 2>&1 | Out-Null } catch {} - - if (-not $KeepRunning) { - Stop-Gw $script:gwProc - $script:gwProc = $null - } elseif ($script:gwProc) { - Info "gateway pid $($script:gwProc.Id) left running (-KeepRunning)" - } - - # --- Summary -------------------------------------------------------------- - - Step "Summary" - $checks | Format-Table -AutoSize - - $failCount = @($checks | Where-Object { $_.Result -eq "FAIL" }).Count - $passCount = @($checks | Where-Object { $_.Result -eq "PASS" }).Count - Write-Host "PASS=$passCount FAIL=$failCount" - - $verdict = if ($harnessError -or $failCount -gt 0) { "FAIL" } else { "PASS" } - $checkLines = ($checks | ForEach-Object { " " + $_.Result + " " + $_.Check + ": " + $_.Detail }) -join "`n" - $modeStr = if ($Mock) { "MOCK (no wxc-exec, no WS connectivity)" } else { "REAL" } - $wxcStr = if ($Mock) { "(mock)" } else { $WxcExecPath } - $errStr = if ($harnessError) { "harness_error: $harnessError" } else { "" } - - $summary = "OpenShell MXC WebSocket agent test`n" + - "====================================`n" + - "timestamp : $stamp`n" + - "machine : $env:COMPUTERNAME`n" + - "verdict : $verdict`n" + - "mode : $modeStr`n" + - "gateway : $gateway (port $Port)`n" + - "agent_dir : $AgentDir`n" + - "agent_exe : $agentExe`n" + - "relay_exe : $relayExe`n" + - "policy : $policyUsed`n" + - "sandbox : $sandboxName`n" + - "ws_port : $WsPort`n" + - "relay_port : $RelayPort (on this host)`n" + - "ws_message : $WsMessage`n" + - "wxc_exec : $wxcStr`n" + - "totals : PASS=$passCount FAIL=$failCount`n" + - "$errStr`n" + - "`nChecks:`n$checkLines`n" + - "`nFiles in this bundle ($resultDir):`n" + - " transcript.txt full console transcript`n" + - " gateway.log / gateway.err.log gateway stdout / stderr`n" + - " forward.log / forward.err.log 'openshell forward service' stdout / stderr`n" + - " mxc-ws-gateway.rendered.toml exact gateway config used`n" + - " ws-agent.yaml sandbox policy used`n" + - "`nWhat PASS means:`n" + - " gateway-start gateway bound port $Port within 30 s`n" + - " sandbox-create sandbox reached Ready after create (CLI exit may be non-zero on MXC without SSH)`n" + - " server-port-open WS server bound port $WsPort within 30 s`n" + - " ws-server-marker spawner logged its own port-ready confirmation in the gateway log`n" + - " ws-echo '$WsMessage' echoed via a dynamic 'openshell forward service' relay at 127.0.0.1:$RelayPort`n" + - " (fresh, on-demand relay for this one call -- no static bridge, nothing pre-declared)`n" + - " sandbox-delete CLI returned exit 0 for sandbox delete`n" + - " port-freed port $WsPort released within 30 s of sandbox delete`n" - - Set-Content (Join-Path $resultDir "summary.txt") -Value $summary -Encoding UTF8 - $color = if ($verdict -eq "PASS") { "Green" } else { "Red" } - Write-Host $summary -ForegroundColor $color - - if ($transcriptStarted) { try { Stop-Transcript | Out-Null } catch {} } - - # Zip the bundle. - try { - $zip = Join-Path $here "results-ws-$stamp.zip" - if (Test-Path $zip) { Remove-Item $zip -Force } - Compress-Archive -Path (Join-Path $resultDir "*") -DestinationPath $zip -Force - Write-Host "`nResults bundle: $zip" -ForegroundColor Yellow - } catch { Write-Host "zip failed: $($_.Exception.Message)" -ForegroundColor Red } -} - -if ($harnessError -or (@($checks | Where-Object { $_.Result -eq "FAIL" }).Count -gt 0)) { - Write-Host "`nTEST FAILED" -ForegroundColor Red - exit 1 -} else { - Write-Host "`nTEST PASSED" -ForegroundColor Green - exit 0 -} diff --git a/crates/openshell-driver-mxc/src/control_channel.rs b/crates/openshell-driver-mxc/src/control_channel.rs deleted file mode 100644 index e8db243b7a..0000000000 --- a/crates/openshell-driver-mxc/src/control_channel.rs +++ /dev/null @@ -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>>; -/// Slot for one of the spawner's one-time, unsolicited events -- startup- -/// ready (see `try_route_ready`) and target-ready (see -/// `try_route_target_ready`) each get their own instance of this type. -/// Not part of `PendingMap`: neither has a correlation id or is a reply to -/// anything the driver sent. The payload is `Ok(())` for a normal fire, or -/// `Err(reason)` when the event fired but something about it was rejected -/// (currently only the "ready" event's protocol version check uses this; -/// `"target_ready"` always sends `Ok(())`). -pub type ReadySlot = Mutex>>>; - -/// Wire protocol version this driver requires from -/// `openshell-supervisor-relay`'s startup `"ready"` event (see -/// `try_route_ready`). Must match that crate's own `PROTOCOL_VERSION` -/// constant -- duplicated rather than shared via a common crate, matching -/// how the rest of this wire protocol (event/op names, the auth nonce -/// encoding, etc.) is already duplicated across the two sides. Bump both -/// together whenever the control-channel protocol changes in a way an -/// out-of-sync peer can't safely ignore (e.g. the "nonce" field added to -/// "forward", or the `"target_ready"` event itself) -- an independently -/// staged, stale relay binary then fails fast with a clear error instead of -/// hanging or misbehaving against fields/events it doesn't understand. -const REQUIRED_SUPERVISOR_RELAY_PROTOCOL_VERSION: u64 = 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, - next_id: AtomicU64, - pending: Arc, -} - -impl ControlChannel { - pub fn new(stdin: ChildStdin) -> Self { - Self { - stdin: Mutex::new(stdin), - next_id: AtomicU64::new(1), - pending: Arc::new(Mutex::new(HashMap::new())), - } - } - - /// A clonable handle to the pending-requests map, for the stdout-reader - /// task (which owns the read side) to route responses into. - pub fn pending_handle(&self) -> Arc { - self.pending.clone() - } - - /// Try to parse `line` as a control-channel response and complete the - /// matching pending request. Returns `true` if `line` was consumed this - /// way; `false` means the caller should treat it as plain log text - /// instead (covers wxc-exec's own banner/config-dump lines, which are - /// never `{"id":...}`-shaped). - pub async fn try_route_response(pending: &PendingMap, line: &str) -> bool { - let Ok(value) = serde_json::from_str::(line) else { - return false; - }; - let Some(id) = value.get("id").and_then(Value::as_u64) else { - return false; - }; - let mut map = pending.lock().await; - map.remove(&id).is_some_and(|tx| { - let _ = tx.send(value); - true - }) - } - - /// Try to recognize `line` as the spawner's unsolicited startup-ready - /// event (`{"event":"ready","protocol_version":N}`) -- sent once, - /// before it's spawned anything, so the driver knows when to send the - /// `"launch"` request carrying the real command/env (see driver.rs's - /// launch handshake and the `openshell-supervisor-relay` crate's module - /// docs). Unlike a query response this has no correlation id, so it - /// can't go through `try_route_response`. Returns `true` if `line` was - /// consumed this way (regardless of whether the version check passed -- - /// the caller distinguishes that via the channel payload). - /// - /// Validates `protocol_version` against - /// `REQUIRED_SUPERVISOR_RELAY_PROTOCOL_VERSION` so an independently - /// staged, out-of-sync relay binary (e.g. left over from an older - /// package drop in a shared `share_dir`) fails the sandbox immediately - /// with a clear "wrong version" error instead of hanging or misbehaving - /// later against a "launch"/"forward" field or a `"target_ready"` event it - /// doesn't understand. A missing field means a pre-versioning binary -- - /// also rejected, since there's no version to compare. - pub async fn try_route_ready(ready: &ReadySlot, line: &str) -> bool { - Self::try_route_named_event(ready, line, "ready", |value| { - match value.get("protocol_version").and_then(Value::as_u64) { - Some(v) if v == REQUIRED_SUPERVISOR_RELAY_PROTOCOL_VERSION => Ok(()), - Some(v) => Err(format!( - "openshell-supervisor-relay reports protocol_version {v}, this driver requires {REQUIRED_SUPERVISOR_RELAY_PROTOCOL_VERSION} -- restage a matching build" - )), - None => Err(format!( - "openshell-supervisor-relay's ready event has no protocol_version field (pre-versioning binary); this driver requires {REQUIRED_SUPERVISOR_RELAY_PROTOCOL_VERSION} -- restage a matching build" - )), - } - }) - .await - } - - /// Try to recognize `line` as the spawner's unsolicited target-ready - /// event (`{"event":"target_ready"}`) -- sent once the spawner has - /// actually spawned the target and confirmed its configured port is - /// accepting connections (see `wait_for_port_ready` in - /// `openshell-supervisor-relay`). Distinct from the `"launch"` - /// control-channel *response*, which only confirms the command/env - /// arrived, not that the target is running: driver.rs awaits this event - /// too before publishing the sandbox `Ready=True`, so a caller acting on - /// `Ready` can't race a target that hasn't bound its port yet. Returns - /// `true` if `line` was consumed this way. No version gate here -- the - /// startup "ready" handshake above already rejected an incompatible - /// peer long before this could fire. - pub async fn try_route_target_ready(target_ready: &ReadySlot, line: &str) -> bool { - Self::try_route_named_event(target_ready, line, "target_ready", |_| Ok(())).await - } - - async fn try_route_named_event( - slot: &ReadySlot, - line: &str, - event_name: &str, - validate: impl FnOnce(&Value) -> Result<(), String>, - ) -> bool { - let Ok(value) = serde_json::from_str::(line) else { - return false; - }; - if value.get("event").and_then(|v| v.as_str()) != Some(event_name) { - return false; - } - let sender = slot.lock().await.take(); - if let Some(tx) = sender { - let _ = tx.send(validate(&value)); - } - true - } - - /// Fail every currently pending request with `Dropped`, e.g. when the - /// stdout-reader task observes EOF/error on the child's stdout: once the - /// reader is gone, no response will ever arrive for these ids, so let - /// callers fail fast instead of sitting out their individual timeouts. - /// Dropping each sender (rather than sending a value) is what makes the - /// waiting `request()` call observe `ControlChannelError::Dropped`. - pub async fn fail_all_pending(pending: &PendingMap) { - let mut map = pending.lock().await; - map.clear(); - } - - /// Send `{"id":N,"op":op,"data":data}` and await the correlated - /// response, or an error on write failure, timeout, or a dropped sender - /// (the reader task exited, e.g. the process died). - pub async fn request( - &self, - op: &str, - data: Value, - timeout: Duration, - ) -> Result { - let id = self.next_id.fetch_add(1, Ordering::Relaxed); - let (tx, rx) = oneshot::channel(); - self.pending.lock().await.insert(id, tx); - - let req = serde_json::json!({"id": id, "op": op, "data": data}); - let mut line = serde_json::to_string(&req)?; - line.push('\n'); - - let 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>) { - 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()); - } -} diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index cd149d1ae4..a5af4f3b26 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -1,13 +1,16 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! MXC compute backend: lifecycle logic, in-memory registry, exec-in-driver, -//! and self-reported readiness. +//! MXC compute backend using the RFC 0012 supervisor/sandbox architecture. + +use std::collections::HashMap; +use std::io; +use std::net::SocketAddr; +use std::path::{Path, PathBuf}; +use std::pin::Pin; +use std::sync::Arc; +use std::time::Duration; -use crate::control_channel::ControlChannel; -use crate::mxc::{MxcFilesystem, MxcNetwork, MxcProcess, MxcProcessContainer, WxcExecInvoker}; -use crate::policy::{EmbeddedPolicyMapper, MapCtx, MappedConfig, PolicyMapper}; -use crate::relay; use base64::Engine as _; use futures::Stream; use openshell_core::gpu::{driver_gpu_requirements, effective_driver_gpu_count}; @@ -18,34 +21,31 @@ use openshell_core::proto::compute::v1::{ WatchSandboxesPlatformEvent, WatchSandboxesSandboxEvent, watch_sandboxes_event, }; use openshell_core::proto_struct::struct_to_json_value; -use openshell_core::provider_credentials::ProviderCredentialState; +use openshell_sandbox_backend::boundary_protocol::{ + GatewayVerificationKey, SandboxTlsClientConfig, SandboxTlsServerConfig, + generate_sandbox_tls_material, +}; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::net::SocketAddr; -use std::path::{Path, PathBuf}; -use std::pin::Pin; -use std::sync::{Arc, Mutex as StdMutex}; -use tokio::io::{AsyncBufReadExt, BufReader}; -use tokio::process::Child; +use tokio::io::{AsyncBufReadExt as _, BufReader}; +use tokio::process::{Child, Command}; use tokio::sync::{Mutex, broadcast, mpsc, oneshot, watch}; use tokio_stream::wrappers::ReceiverStream; use tracing::{info, warn}; +use crate::isolation::MxcBoundarySpec; +use crate::mxc::{MxcFilesystem, MxcNetwork, MxcProcess, MxcProcessContainer, WxcExecInvoker}; +use crate::policy::{EmbeddedPolicyMapper, MapCtx, MappedConfig, PolicyMapper}; + const DRIVER_NAME: &str = "mxc"; const DRIVER_VERSION: &str = env!("CARGO_PKG_VERSION"); -/// Sentinel image name — MXC has no OCI image; this string must be non-empty -/// so the gateway's `default_image` cache is satisfied, but it is not pullable. const DEFAULT_IMAGE_SENTINEL: &str = "mxc:process-container"; +const HOST_AUTH_BUNDLE_FILE: &str = "supervisor-auth.json"; +const HOST_RUNTIME_DESCRIPTOR_FILE: &str = "runtime-descriptor.json"; +const BOUNDARY_CONFIG_FILE: &str = "boundary.json"; +const BOUNDARY_TLS_CERT_FILE: &str = "sandbox.crt"; +const BOUNDARY_TLS_KEY_FILE: &str = "sandbox.key"; +const DIRECT_PROXY_USERNAME: &str = "openshell"; -// ── Config ──────────────────────────────────────────────────────────────────── - -/// Which MXC backend the driver targets. -/// -/// - `IsolationSession`: persistent, attachable session -/// (provision → start → exec → stop → deprovision). Grant-only filesystem -/// policy — it has no deny primitive and is NOT default-deny. -/// - `ProcessContainer` (default): one-shot `AppContainer`. Genuinely default-deny: a -/// write to any ungranted path is denied by the OS. No persistent session. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "snake_case")] pub enum MxcBackend { @@ -63,109 +63,63 @@ impl MxcBackend { } } -/// Configuration for the MXC compute driver. -/// -/// Loaded from `[openshell.drivers.mxc]` in the gateway TOML file, or from -/// environment variables / CLI flags via the standard gateway precedence chain. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] -// These are independent, user-facing feature switches in the flat gateway -// configuration schema rather than one compound state machine. -#[allow(clippy::struct_excessive_bools)] pub struct MxcComputeConfig { - /// Path to `wxc-exec.exe`. Required for live runs. pub wxc_exec_path: String, - /// Backend to target. Default: `process_container`. + pub supervisor_binary_path: String, + pub sandbox_binary_path: String, + pub state_dir: PathBuf, + pub grpc_endpoint: String, pub backend: MxcBackend, - /// `processContainer` only: request a Less-Privileged `AppContainer`. pub pc_least_privilege: bool, - /// `processContainer` only: `AppContainer` capabilities to grant. pub pc_capabilities: Vec, - /// `processContainer` only: inject a network section with - /// `defaultPolicy: "allow"` so the `AppContainer` has unrestricted outbound - /// TCP access. Required when `pc_capabilities` alone is insufficient to - /// enable network access in the target wxc-exec build. - pub pc_network_allow: bool, - /// `processContainer` only: include `"allowLocalNetwork": true` in the - /// MXC network section. Required for node.js (and other runtimes that - /// need loopback during DLL initialization) to start inside a - /// processcontainer. pub pc_allow_local_network: bool, - /// `processContainer` only: when `true`, start with an EMPTY process env - /// (not even `MINIMAL_WINDOWS_BOOTSTRAP_ENV`) instead of the safe - /// default -- only the sandbox's explicit per-request environment is - /// passed to the process. - /// Use for agents like Node.js that fail with `STATUS_DLL_INIT_FAILED` - /// when unrecognised host env vars are present; the caller is then - /// responsible for supplying `SYSTEMROOT`/`WINDIR`/`PATH`/`COMSPEC`/ - /// `LOCALAPPDATA` through `sandbox create --env/--env-from` if needed - /// (`CreateProcessW` itself won't succeed without `LOCALAPPDATA` at - /// least -- see `MINIMAL_WINDOWS_BOOTSTRAP_ENV`). - /// - /// The sandbox's explicit per-request environment is layered on top. pub pc_minimal_env: bool, - /// `processContainer` only: path to a generic spawn+relay-bridge binary - /// (see the `openshell-supervisor-relay` crate). When non-empty (and - /// `pc_relay_target_port != 0`), the driver launches this binary instead - /// of the per-sandbox workload command directly, sending the command/env - /// over the control channel once the spawner announces readiness (the - /// "launch" handshake) rather than writing them to the workload directory. This - /// decouples the relay-bridging logic from the target application (e.g. - /// `OpenClaw`) entirely — the target needs no awareness of the relay - /// protocol. It's also what gives the driver a control channel into the - /// sandbox at all, which `ForwardSink::open_dynamic_forward` (dynamic - /// `openshell forward service` bridging) depends on regardless of any - /// particular port being pre-declared. - pub pc_relay_spawner_path: String, - /// `processContainer` only: the TCP port the workload's target process - /// binds, which `pc_relay_spawner_path` bridges to the gateway relay. - /// Ignored unless `pc_relay_spawner_path` is set. `0` disables spawner - /// wrapping (default) — the per-sandbox command runs directly. - pub pc_relay_target_port: u16, - /// MXC `configurationId` for isolation session. Default: `"composable"`. - /// Never use `"small"` (known OS bug). - pub default_configuration_id: String, - /// Enable Pattern-C governed egress. When true, MXC permits loopback-only - /// egress, the driver injects proxy environment variables, and the host - /// CONNECT proxy receives the full network policy. - pub egress_proxy: bool, - /// Loopback `IP:PORT` seed for MXC `network.proxy` while governed egress is - /// enabled. The driver preserves the loopback IP and allocates a unique - /// ephemeral port per sandbox. - pub egress_proxy_addr: String, - - /// Enable `--debug` flag on `wxc-exec` invocations. pub debug: bool, - /// Enable the in-process ETW → OCSF audit consumer (Plane A). Consumes the OS - /// Sandboxing provider MXC drives and emits OCSF into the gateway trail. - /// Requires the gateway account to be in "Performance Log Users" (or admin). pub etw_audit: bool, } impl Default for MxcComputeConfig { fn default() -> Self { + let state_dir = std::env::var_os("LOCALAPPDATA") + .map(PathBuf::from) + .unwrap_or_else(std::env::temp_dir) + .join("OpenShell") + .join("mxc"); + let executable_dir = std::env::current_exe() + .ok() + .and_then(|path| path.parent().map(Path::to_path_buf)); + let sibling_binary = |name: &str| { + executable_dir.as_ref().map_or_else( + || name.to_string(), + |dir| dir.join(name).display().to_string(), + ) + }; Self { wxc_exec_path: "wxc-exec.exe".into(), - backend: MxcBackend::default(), + supervisor_binary_path: sibling_binary("openshell-supervisor.exe"), + sandbox_binary_path: sibling_binary("openshell-sandbox.exe"), + state_dir, + grpc_endpoint: String::new(), + backend: MxcBackend::ProcessContainer, pc_least_privilege: false, pc_capabilities: Vec::new(), - pc_network_allow: false, - pc_relay_spawner_path: String::new(), - pc_relay_target_port: 0, - pc_allow_local_network: false, + pc_allow_local_network: true, pc_minimal_env: false, - default_configuration_id: crate::mxc::DEFAULT_CONFIGURATION_ID.into(), - egress_proxy: false, - egress_proxy_addr: String::new(), - debug: false, etw_audit: false, } } } -/// Per-sandbox MXC workload settings supplied through -/// `template.driver_config.mxc` / `--driver-config-json`. +#[derive(Debug, Clone)] +struct GatewayConnection { + endpoint: String, + tls: Option<(PathBuf, PathBuf, PathBuf)>, + tls_server_name: Option, +} + #[derive(Debug, Clone, Deserialize)] #[serde(deny_unknown_fields)] struct MxcSandboxConfig { @@ -174,8 +128,6 @@ struct MxcSandboxConfig { cwd: String, } -// ── Registry entry ──────────────────────────────────────────────────────────── - #[derive(Debug, Clone, PartialEq, Eq)] pub enum PhaseState { Starting, @@ -186,598 +138,84 @@ pub enum PhaseState { struct SandboxEntry { sandbox: DriverSandbox, - iso_sandbox_id: Option, - isolation_stopped: bool, phase_state: PhaseState, - /// Serializes stop/delete with provisioning and process launch: taken as - /// an owned guard (`startup_guard`) in `create_sandbox` before the entry - /// is published, and only released once `run_lifecycle` has installed - /// `exec_child`/`shutdown_tx`/`terminated_rx`/`control_channel` (or - /// failed). `stop_sandbox`/`delete_sandbox` block on this same gate - /// before touching any of those fields, so a stop/delete arriving while - /// a sandbox is still starting can't race a launch that hasn't finished - /// wiring the kill/shutdown machinery yet. lifecycle_gate: Arc>, - exec_child: Option, - /// Fires when `delete_sandbox` is called on a `ProcessContainer` sandbox so - /// `monitor_exec` can kill the `wxc-exec` child and release all resources - /// (including ports bound inside the `AppContainer`) before the entry is - /// removed from the registry. shutdown_tx: Option>, - /// Set to `true` (from `monitor_exec`) once the `wxc-exec` child has - /// genuinely exited -- whether that's a natural exit or the forced kill - /// triggered via `shutdown_tx` above. Lets `stop_sandbox`/`delete_sandbox` - /// await *confirmed* termination (bounded by a timeout) instead of firing - /// the kill signal and immediately reporting success regardless of - /// whether the process actually died. - /// - /// A `watch::Receiver` rather than a `oneshot::Receiver` deliberately: - /// it's `.clone()`d (never `.take()`n) by callers, so it survives a - /// caller that times out and retries -- unlike a consumed oneshot, the - /// retry can still observe the same underlying completion instead of - /// silently skipping the wait because the field looks empty. terminated_rx: Option>, - /// Path to the shutdown signal file written by `delete_sandbox` so - /// `mxc-ws-agent.rs` (set directly as the sandbox command, no control - /// channel) can detect a deletion and exit cleanly. Only set for that - /// case -- when spawner wrapping is active, `delete_sandbox` sends a - /// `"shutdown"` control-channel request to `openshell-supervisor-relay` - /// instead, so this stays `None`. - signal_file: Option, - trimmed_policy: Option, - proxy_addr: Option, - host_proxy: Option, - /// JSON request/response control channel over the spawner's inherited - /// stdin/stdout (see `control_channel.rs`). Only present when spawner - /// wrapping is active (`pc_relay_spawner_path` configured); dropped on - /// delete, which closes the child's stdin. - control_channel: Option>, + host_state_dir: PathBuf, + boundary_state_dir: PathBuf, } -impl std::fmt::Debug for SandboxEntry { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("SandboxEntry") - .field("sandbox_id", &self.sandbox.id) - .field("iso_sandbox_id", &self.iso_sandbox_id) - .field("isolation_stopped", &self.isolation_stopped) - .field("phase_state", &self.phase_state) - .finish_non_exhaustive() - } -} - -// ── Watch stream helpers ────────────────────────────────────────────────────── - pub type WatchStream = Pin< Box> + Send>, >; -fn sandbox_event(sandbox: DriverSandbox) -> WatchSandboxesEvent { - WatchSandboxesEvent { - payload: Some(watch_sandboxes_event::Payload::Sandbox( - WatchSandboxesSandboxEvent { - sandbox: Some(sandbox), - }, - )), - } -} - -fn deleted_event(sandbox_id: String) -> WatchSandboxesEvent { - WatchSandboxesEvent { - payload: Some(watch_sandboxes_event::Payload::Deleted( - WatchSandboxesDeletedEvent { sandbox_id }, - )), - } -} - -fn platform_event(sandbox_id: String, reason: &str, message: String) -> WatchSandboxesEvent { - WatchSandboxesEvent { - payload: Some(watch_sandboxes_event::Payload::PlatformEvent( - WatchSandboxesPlatformEvent { - sandbox_id, - event: Some(DriverPlatformEvent { - event_time: None, - source: "mxc-driver".into(), - r#type: "Warning".into(), - reason: reason.to_string(), - message, - metadata: HashMap::new(), - }), - }, - )), - } -} - -// ── Driver ──────────────────────────────────────────────────────────────────── - -/// In-process MXC compute driver. pub struct MxcComputeBackend { config: MxcComputeConfig, + gateway: GatewayConnection, invoker: WxcExecInvoker, registry: Arc>>, watch_tx: Arc>, policy_mapper: Arc, - /// Provider resolver snapshots staged by the gateway immediately before - /// create. The driver consumes each entry exactly once; only placeholder - /// child environment values cross into MXC. - pending_provider_credentials: Arc>>, - /// In-process ETW → OCSF audit consumer (Plane A). `Some` only when - /// `config.etw_audit` is set and the session started; kept alive here so it - /// stops when the backend is dropped (held purely for its `Drop`, hence - /// never read directly). #[allow(dead_code)] etw_session: Option, - /// Shared MXC-ETW → `sandbox_id` attribution index. Seeded by the driver - /// (`pid → sandbox_id`) as it launches sandboxes and read by the ETW - /// consumer thread to map/emit OCSF. `Arc` even when audit is off so the - /// launch path is branch-free. attribution: Arc>, } impl std::fmt::Debug for MxcComputeBackend { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("MxcComputeBackend") + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("MxcComputeBackend") .field("wxc_exec_path", &self.config.wxc_exec_path) - .finish_non_exhaustive() - } -} - -fn sandbox_config(sandbox: &DriverSandbox) -> Result { - let config = sandbox - .spec - .as_ref() - .and_then(|spec| spec.template.as_ref()) - .and_then(|template| template.driver_config.as_ref()) - .ok_or_else(|| { - tonic::Status::invalid_argument( - "mxc requires template.driver_config.mxc with a non-empty command array", + .field( + "supervisor_binary_path", + &self.config.supervisor_binary_path, ) - })?; - let config: MxcSandboxConfig = - serde_json::from_value(struct_to_json_value(config)).map_err(|error| { - tonic::Status::invalid_argument(format!("invalid mxc driver_config: {error}")) - })?; - if config.command.is_empty() || config.command[0].is_empty() { - return Err(tonic::Status::invalid_argument( - "mxc driver_config.command must contain a non-empty executable", - )); - } - Ok(config) -} - -fn sandbox_environment(sandbox: &DriverSandbox) -> Vec { - let mut environment = HashMap::new(); - if let Some(spec) = sandbox.spec.as_ref() { - if let Some(template) = spec.template.as_ref() { - environment.extend(template.environment.clone()); - } - environment.extend(spec.environment.clone()); - } - let mut environment = environment - .into_iter() - .map(|(key, value)| format!("{key}={value}")) - .collect::>(); - environment.sort_unstable(); - environment -} - -/// Merge provider-owned child environment values into MXC `process.env`. -/// -/// Provider entries win case-insensitively, matching Windows environment -/// semantics. Secret values have already been replaced by revision-scoped -/// placeholders; explicitly classified GCP configuration is resolved by the -/// shared credential state because SDKs consume it before making a request. -fn append_provider_child_env( - env: &mut Vec, - provider_credentials: Option<&ProviderCredentialState>, -) { - let Some(provider_credentials) = provider_credentials else { - return; - }; - let mut provider_env = provider_credentials - .child_env_with_gcp_resolved() - .into_iter() - .collect::>(); - provider_env.sort_by(|(left, _), (right, _)| left.cmp(right)); - env.retain(|entry| { - let key = entry.split_once('=').map_or(entry.as_str(), |(key, _)| key); - !provider_env - .iter() - .any(|(provider_key, _)| key.eq_ignore_ascii_case(provider_key)) - }); - env.extend( - provider_env - .into_iter() - .map(|(key, value)| format!("{key}={value}")), - ); -} - -/// Rejects provider credential environment keys that would collide once -/// injected into the sandbox process, before any staging or launch happens. -/// -/// Windows environment variables are case-insensitive, so two provider keys -/// that differ only by case (or shadow one of the reserved TLS trust keys -/// `append_tls_env_vars` injects later) would otherwise merge or get silently -/// overwritten with no diagnostic, leaving the sandbox with an ambiguous, -/// wrong, or missing credential. -fn validate_provider_child_env_keys( - provider_credentials: Option<&ProviderCredentialState>, -) -> Result<(), tonic::Status> { - let Some(provider_credentials) = provider_credentials else { - return Ok(()); - }; - let mut seen: HashMap = HashMap::new(); - let mut collisions: Vec = Vec::new(); - let mut keys = provider_credentials - .child_env_with_gcp_resolved() - .into_keys() - .collect::>(); - keys.sort_unstable(); - for key in keys { - let folded = key.to_ascii_uppercase(); - if TLS_ENV_KEYS.iter().any(|reserved| folded == *reserved) { - collisions.push(format!("{key} (reserved for TLS trust configuration)")); - continue; - } - if let Some(existing) = seen.insert(folded, key.clone()) - && existing != key - { - collisions.push(format!("{key} (collides with {existing})")); - } - } - if collisions.is_empty() { - Ok(()) - } else { - Err(tonic::Status::failed_precondition(format!( - "provider credential environment keys are ambiguous on Windows (case-insensitive) \ - or reserved: {}", - collisions.join(", ") - ))) - } -} - -fn configured_egress_addr(config: &MxcComputeConfig) -> Result, tonic::Status> { - if !config.egress_proxy { - return Ok(None); - } - if config.backend == MxcBackend::IsolationSession { - return Err(tonic::Status::invalid_argument( - "mxc governed egress requires process_container; isolation_session cannot enforce the loopback-only proxy path", - )); - } - let raw = config.egress_proxy_addr.trim(); - if raw.is_empty() { - return Err(tonic::Status::invalid_argument( - "mxc egress_proxy_addr is required when egress_proxy is enabled", - )); - } - let addr = raw.parse::().map_err(|error| { - tonic::Status::invalid_argument(format!( - "mxc egress_proxy_addr must be an IP:PORT socket address: {error}" - )) - })?; - if addr.ip() != std::net::IpAddr::from([127, 0, 0, 1]) { - return Err(tonic::Status::invalid_argument(format!( - "mxc egress_proxy_addr must be 127.0.0.1:PORT because the sandbox reaches the unpackaged OpenShell host proxy over loopback (got {})", - addr.ip() - ))); - } - Ok(Some(addr)) -} - -fn allocate_sandbox_proxy_addr( - configured: SocketAddr, -) -> std::io::Result<(SocketAddr, std::net::TcpListener)> { - let reservation = std::net::TcpListener::bind(SocketAddr::new(configured.ip(), 0))?; - let addr = reservation.local_addr()?; - Ok((addr, reservation)) -} - -/// Minimum Windows environment variables required just for `CreateProcessW` -/// / `AppContainer`-DACL process creation to succeed at all -- independent of -/// whatever per-sandbox runtime command is selected. Confirmed empirically: -/// without `LOCALAPPDATA` specifically, `CreateProcessW` itself fails with -/// `ERROR_ENVVAR_NOT_FOUND` (Win32 203) under the appcontainer-dacl fallback -/// tier, before the agent binary is ever reached -- a Windows `AppContainer` -/// requirement, not specific to Node.js or any other agent. None of these -/// are secrets, so resolving them from the gateway host is safe; this is -/// the per-sandbox environment layers on top of. See `pc_minimal_env` on -/// `MxcComputeConfig` for the explicit empty-baseline option. -const MINIMAL_WINDOWS_BOOTSTRAP_ENV: [&str; 5] = - ["SYSTEMROOT", "WINDIR", "PATH", "COMSPEC", "LOCALAPPDATA"]; - -fn host_proxy_binary_path(config: &MxcSandboxConfig) -> PathBuf { - config - .command - .first() - .filter(|command| !command.trim().is_empty()) - .map_or_else(|| PathBuf::from("mxc-agent"), PathBuf::from) -} - -const TLS_ENV_KEYS: [&str; 6] = [ - "NODE_EXTRA_CA_CERTS", - "DENO_CERT", - "SSL_CERT_FILE", - "REQUESTS_CA_BUNDLE", - "CURL_CA_BUNDLE", - "GIT_SSL_CAINFO", -]; - -/// Replace client trust overrides with the proxy's public CA paths. -/// Curated `ProcessContainers` receive copies staged under the authorized share, -/// rather than paths inside the host proxy's private temporary directory. -fn append_tls_env_vars(env: &mut Vec, ca_paths: Option<&(PathBuf, PathBuf)>) { - let Some((ca_cert_path, combined_bundle_path)) = ca_paths else { - return; - }; - env.retain(|entry| { - let key = entry.split_once('=').map_or(entry.as_str(), |(key, _)| key); - !TLS_ENV_KEYS - .iter() - .any(|candidate| key.eq_ignore_ascii_case(candidate)) - }); - let ca_cert_path = ca_cert_path.display().to_string(); - let combined_bundle_path = combined_bundle_path.display().to_string(); - env.extend([ - format!("NODE_EXTRA_CA_CERTS={ca_cert_path}"), - format!("DENO_CERT={ca_cert_path}"), - format!("SSL_CERT_FILE={combined_bundle_path}"), - format!("REQUESTS_CA_BUNDLE={combined_bundle_path}"), - format!("CURL_CA_BUNDLE={combined_bundle_path}"), - format!("GIT_SSL_CAINFO={combined_bundle_path}"), - ]); -} - -fn stage_tls_ca_files( - ca_paths: Option<&(PathBuf, PathBuf)>, - workload_dir: &str, - sandbox_id: &str, -) -> std::io::Result> { - let Some((ca_cert_path, combined_bundle_path)) = ca_paths else { - return Ok(None); - }; - if workload_dir.trim().is_empty() { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "mxc driver_config.cwd must be set when staging proxy CA files", - )); - } - if sandbox_id.is_empty() - || !sandbox_id - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) - { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "sandbox_id must be a non-empty alphanumeric, hyphen or underscore component", - )); - } - let target_dir = PathBuf::from(workload_dir) - .join(".openshell-proxy") - .join(sandbox_id); - std::fs::create_dir_all(&target_dir)?; - let staged_ca = target_dir.join("openshell-ca.pem"); - let staged_bundle = target_dir.join("ca-bundle.pem"); - std::fs::copy(ca_cert_path, &staged_ca)?; - std::fs::copy(combined_bundle_path, &staged_bundle)?; - Ok(Some((staged_ca, staged_bundle))) -} - -/// PROTOTYPE (2026-09-10): env-var-based governed egress, as an alternative -/// to MXC's own `network.proxy`/`runtimeConfig.networkProxy` transparent -/// redirect (both confirmed broken for this driver's use case -- see -/// `network_json()` in mxc.rs for the elevation/loopback-block history). -/// `HTTP_PROXY`/`HTTPS_PROXY` are honored voluntarily by well-behaved HTTP -/// clients (curl, most language HTTP libraries, Node fetch, git, etc.), not -/// enforced by the OS -- but paired with the sandbox's own default-deny -/// egress (only 127.0.0.1 allowed, see `network_json()`), that's actually -/// sufficient: compliant agents route through the host CONNECT proxy this -/// way, and anything that ignores these vars and tries to connect directly -/// just hits the WFP deny-by-default wall instead of silently bypassing -/// governance. Lowercase forms included too since some tools (e.g. curl) -/// prefer them, and both are common in the wild. -const PROXY_ENV_KEYS: [&str; 6] = [ - "HTTP_PROXY", - "http_proxy", - "HTTPS_PROXY", - "https_proxy", - "NO_PROXY", - "no_proxy", -]; - -const SANDBOX_PROXY_USERNAME: &str = "openshell"; - -struct SandboxProxyAuth { - password: String, -} - -impl SandboxProxyAuth { - fn generate() -> Self { - let random: [u8; 32] = rand::random(); - Self { - password: base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(random), - } - } - - fn proxy_url(&self, addr: SocketAddr) -> String { - format!("http://{SANDBOX_PROXY_USERNAME}:{}@{addr}", self.password) - } - - fn host_client_auth(&self) -> openshell_supervisor_network::host::HostProxyClientAuth { - openshell_supervisor_network::host::HostProxyClientAuth::basic( - SANDBOX_PROXY_USERNAME, - &self.password, - ) - } -} - -fn append_proxy_env_vars( - env: &mut Vec, - proxy_addr: Option, - proxy_auth: Option<&SandboxProxyAuth>, -) { - let (Some(addr), Some(proxy_auth)) = (proxy_addr, proxy_auth) else { - return; - }; - env.retain(|entry| { - let key = entry.split_once('=').map_or(entry.as_str(), |(key, _)| key); - !PROXY_ENV_KEYS - .iter() - .any(|candidate| key.eq_ignore_ascii_case(candidate)) - }); - let proxy_url = proxy_auth.proxy_url(addr); - env.extend([ - format!("HTTP_PROXY={proxy_url}"), - format!("http_proxy={proxy_url}"), - format!("HTTPS_PROXY={proxy_url}"), - format!("https_proxy={proxy_url}"), - "NO_PROXY=".to_string(), - "no_proxy=".to_string(), - ]); -} - -/// Not called: the release wxc-exec (`BaseContainer` dispatcher) requires -/// write-DAC permission on every path in `readonlyPaths` to set up -/// `AppContainer` ACLs, and adding the TLS CA cert temp directory here -/// causes it to fail with a DACL error (empirically confirmed) -- the CA -/// cert paths are available to the agent via TLS env vars instead (see -/// `append_tls_env_vars`). Kept for a future build where that DACL -/// requirement no longer applies. -#[allow(dead_code)] -fn append_tls_readonly_grant( - readonly_paths: &mut Vec, - ca_paths: Option<&(PathBuf, PathBuf)>, -) { - let Some((ca_cert_path, _)) = ca_paths else { - return; - }; - let Some(dir) = ca_cert_path.parent().map(Path::to_path_buf) else { - return; - }; - let dir = dir.display().to_string(); - if !readonly_paths - .iter() - .any(|existing| existing.eq_ignore_ascii_case(&dir)) - { - readonly_paths.push(dir); - } -} -fn encode_windows_command_line(args: &[String]) -> String { - args.iter() - .map(|arg| quote_windows_argument(arg)) - .collect::>() - .join(" ") -} - -fn quote_windows_argument(arg: &str) -> String { - if !arg.is_empty() && !arg.chars().any(|ch| ch.is_whitespace() || ch == '"') { - return arg.to_string(); - } - - let mut quoted = String::from("\""); - let mut backslashes = 0; - for ch in arg.chars() { - match ch { - '\\' => backslashes += 1, - '"' => { - quoted.push_str(&"\\".repeat(backslashes * 2 + 1)); - quoted.push('"'); - backslashes = 0; - } - _ => { - quoted.push_str(&"\\".repeat(backslashes)); - backslashes = 0; - quoted.push(ch); - } - } - } - quoted.push_str(&"\\".repeat(backslashes * 2)); - quoted.push('"'); - quoted -} -fn append_tls_readwrite_grant( - readwrite_paths: &mut Vec, - ca_paths: Option<&(PathBuf, PathBuf)>, -) { - let Some((ca_cert_path, _)) = ca_paths else { - return; - }; - let Some(dir) = ca_cert_path.parent() else { - return; - }; - let dir = dir.display().to_string(); - if !readwrite_paths - .iter() - .any(|existing| existing.eq_ignore_ascii_case(&dir)) - { - readwrite_paths.push(dir); + .field("sandbox_binary_path", &self.config.sandbox_binary_path) + .finish_non_exhaustive() } } impl MxcComputeBackend { pub fn new(config: MxcComputeConfig) -> Self { + let endpoint = config.grpc_endpoint.clone(); + Self::new_with_gateway(config, endpoint, None, None) + } + + pub fn new_with_gateway( + config: MxcComputeConfig, + endpoint: String, + tls: Option<(PathBuf, PathBuf, PathBuf)>, + tls_server_name: Option, + ) -> Self { let invoker = WxcExecInvoker::new(&config.wxc_exec_path, config.debug); let (watch_tx, _) = broadcast::channel(256); - - // Start the Plane-A ETW → OCSF consumer if enabled. The consumer thread - // attributes each event to a `sandbox_id` via `attribution` (seeded by - // the launch path) and emits OCSF for the mapped classes. - // Failure is non-fatal — the driver still runs, just without ETW audit. let attribution = Arc::new(std::sync::Mutex::new( crate::etw_consumer::AttributionIndex::new(), )); let etw_session = if config.etw_audit { - match crate::etw_consumer::start_session(attribution.clone()) { - Ok(session) => Some(session), - Err(e) => { - warn!(error = %e, "MXC ETW audit consumer failed to start; continuing without it"); - None - } - } + crate::etw_consumer::start_session(attribution.clone()) + .inspect_err(|error| warn!(%error, "MXC ETW audit consumer failed to start")) + .ok() } else { None }; - Self { - invoker, config, + gateway: GatewayConnection { + endpoint, + tls, + tls_server_name, + }, + invoker, registry: Arc::new(Mutex::new(HashMap::new())), watch_tx: Arc::new(watch_tx), - // Production policy translation is always handled by the embedded - // mapper before any MXC lifecycle side effects begin. policy_mapper: Arc::new(EmbeddedPolicyMapper), - pending_provider_credentials: Arc::new(StdMutex::new(HashMap::new())), etw_session, attribution, } } - /// Returns a cheap, cloneable handle exposing MXC's dynamic port-forward - /// capability, so the gateway's `ComputeRuntime` can grab it (before - /// `self` is consumed into `Arc`) and call it directly - /// from `handle_forward_tcp` for sandboxes with no `ConnectSupervisor` - /// session -- MXC has no supervisor at all, so that path is otherwise - /// permanently dead for it. - pub fn forward_sink(&self) -> ForwardSink { - ForwardSink { - registry: self.registry.clone(), - } - } - - /// Return the in-process create-time provider credential side channel. - pub fn provider_credentials_sink( - &self, - ) -> Arc>> { - self.pending_provider_credentials.clone() - } - - /// Test-only constructor wiring the in-process mock `wxc-exec` shim. - #[cfg(test)] - pub(crate) fn new_mocked(config: MxcComputeConfig) -> Self { - let mut backend = Self::new(config); - backend.invoker = WxcExecInvoker::mocked(&backend.config.wxc_exec_path); - backend - } - pub fn capabilities(&self) -> GetCapabilitiesResponse { GetCapabilitiesResponse { driver_name: DRIVER_NAME.to_string(), @@ -785,35 +223,70 @@ impl MxcComputeBackend { default_image: DEFAULT_IMAGE_SENTINEL.to_string(), gateway_manages_lifecycle: false, supports_sandbox_authentication: false, - driver_reports_runtime_readiness: true, + driver_reports_runtime_readiness: false, resource_capabilities: None, rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, - supports_ui_policy: self.config.backend == MxcBackend::ProcessContainer, + supports_ui_policy: true, } } - fn validate_sandbox_fields(&self, sandbox: &DriverSandbox) -> Result<(), tonic::Status> { - if let Some(spec) = &sandbox.spec { - if effective_driver_gpu_count(driver_gpu_requirements( + pub fn validate_sandbox_create(&self, sandbox: &DriverSandbox) -> Result<(), tonic::Status> { + if self.config.backend != MxcBackend::ProcessContainer { + return Err(tonic::Status::failed_precondition( + "the RFC 0012 MXC runtime currently requires process_container", + )); + } + if self.gateway.endpoint.trim().is_empty() { + return Err(tonic::Status::failed_precondition( + "mxc grpc_endpoint is required for the host supervisor", + )); + } + if !self.invoker.is_mock() + && (!Path::new(&self.config.supervisor_binary_path).is_file() + || !Path::new(&self.config.sandbox_binary_path).is_file()) + { + return Err(tonic::Status::failed_precondition(format!( + "MXC requires supervisor and sandbox binaries at '{}' and '{}'", + self.config.supervisor_binary_path, self.config.sandbox_binary_path + ))); + } + if let Some(spec) = &sandbox.spec + && effective_driver_gpu_count(driver_gpu_requirements( spec.resource_requirements.as_ref(), )) .map_err(tonic::Status::invalid_argument)? .is_some() - { - return Err(tonic::Status::invalid_argument( - "mxc driver does not support GPU sandboxes", - )); - } - if let Some(tmpl) = &spec.template - && !tmpl.agent_socket_path.is_empty() - { - return Err(tonic::Status::invalid_argument( - "mxc driver does not support agent_socket_path (no in-sandbox supervisor)", - )); - } + { + return Err(tonic::Status::invalid_argument( + "mxc driver does not support GPU sandboxes", + )); + } + let config = sandbox_config(sandbox)?; + if config.cwd.trim().is_empty() { + return Err(tonic::Status::invalid_argument( + "mxc driver_config.cwd is required for boundary staging", + )); + } + if !Path::new(&config.cwd).is_absolute() { + return Err(tonic::Status::invalid_argument( + "mxc driver_config.cwd must be an absolute Windows path", + )); + } + if !self.config.state_dir.is_absolute() { + return Err(tonic::Status::failed_precondition( + "mxc state_dir must be an absolute Windows path", + )); } - sandbox_config(sandbox)?; + launch_authentication(sandbox)? + .validate() + .map_err(|error| tonic::Status::failed_precondition(error.to_string()))?; + let policy = sandbox.spec.as_ref().and_then(|spec| spec.policy.as_ref()); + self.map_sandbox_policy( + &sandbox.id, + policy, + "127.0.0.1:3128".parse().expect("fixed proxy address"), + )?; Ok(()) } @@ -821,166 +294,100 @@ impl MxcComputeBackend { &self, sandbox_id: &str, policy: Option<&SandboxPolicy>, - egress: Option, + proxy_addr: SocketAddr, ) -> Result { self.policy_mapper .map( policy, &MapCtx { sandbox_id: sandbox_id.to_string(), - egress, + egress: Some(proxy_addr), containment: self.config.backend.containment().into(), }, ) .map_err(|error| tonic::Status::invalid_argument(error.to_string())) } - pub fn validate_sandbox_create(&self, sandbox: &DriverSandbox) -> Result<(), tonic::Status> { - self.validate_sandbox_fields(sandbox)?; - let policy = sandbox.spec.as_ref().and_then(|spec| spec.policy.as_ref()); - let egress_addr = configured_egress_addr(&self.config)?; - self.map_sandbox_policy(&sandbox.id, policy, egress_addr)?; - Ok(()) - } pub async fn get_sandbox(&self, sandbox_name: &str) -> Option { - let registry = self.registry.lock().await; - registry + self.registry + .lock() + .await .values() - .find(|e| e.sandbox.name == sandbox_name) - .map(|e| e.sandbox.clone()) + .find(|entry| entry.sandbox.name == sandbox_name) + .map(|entry| entry.sandbox.clone()) } pub async fn list_sandboxes(&self) -> Vec { - let registry = self.registry.lock().await; - registry.values().map(|e| e.sandbox.clone()).collect() + self.registry + .lock() + .await + .values() + .map(|entry| entry.sandbox.clone()) + .collect() } pub async fn create_sandbox(&self, sandbox: &DriverSandbox) -> Result<(), tonic::Status> { + self.validate_sandbox_create(sandbox)?; let sandbox_id = sandbox.id.clone(); - - // Consume before any fallible validation so rejected creates cannot - // retain real provider material in the staging map. - let provider_credentials = self - .pending_provider_credentials - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .remove(&sandbox_id); - validate_provider_child_env_keys(provider_credentials.as_ref())?; - - self.validate_sandbox_fields(sandbox)?; let sandbox_config = sandbox_config(sandbox)?; - let (egress_addr, reserved_proxy_listener) = match configured_egress_addr(&self.config)? { - Some(configured_addr) => { - let (addr, reservation) = allocate_sandbox_proxy_addr(configured_addr).map_err( - |error| { - tonic::Status::internal(format!( - "failed to allocate sandbox-unique MXC host egress proxy address from {configured_addr}: {error}" - )) - }, - )?; - (Some(addr), Some(reservation)) - } - None => (None, None), - }; - - // Policy translation is deterministic and side-effect free. Do it before - // inserting the registry entry or launching MXC so invalid requests fail - // synchronously at the CreateSandbox boundary. - let policy = sandbox.spec.as_ref().and_then(|spec| spec.policy.as_ref()); - let mapped = self.map_sandbox_policy(&sandbox_id, policy, egress_addr)?; - if provider_credentials - .as_ref() - .is_some_and(ProviderCredentialState::requires_proxy_resolution) - && egress_addr.is_none() - { - return Err(tonic::Status::failed_precondition( - "mxc provider credentials require governed egress; enable egress_proxy so placeholders can be resolved by the host proxy", - )); - } - - if sandbox - .spec - .as_ref() - .is_none_or(|spec| spec.sandbox_token.is_empty()) - { - tracing::debug!( - sandbox = %sandbox.name, - "no sandbox_token minted (no supervisor consumer on MXC)" - ); - } - - let sandbox_name = sandbox.name.clone(); - let lifecycle_gate = Arc::new(Mutex::new(())); - // Take the gate before publishing the entry. stop/delete can discover the - // sandbox immediately, but cannot pass this guard until startup has either - // installed a cancellable child monitor or failed. - let startup_guard = lifecycle_gate.clone().lock_owned().await; + let generation = uuid::Uuid::new_v4().to_string(); + let host_state_dir = self + .config + .state_dir + .join(safe_component(&sandbox_id)?) + .join(&generation); + let boundary_state_dir = PathBuf::from(&sandbox_config.cwd) + .join(".openshell-runtime") + .join(&generation); + let gate = Arc::new(Mutex::new(())); + let startup_guard = gate.clone().lock_owned().await; + let starting = make_sandbox_with_condition( + sandbox, + &condition("Ready", "False", "Starting", "MXC runtime starting"), + false, + ); { let mut registry = self.registry.lock().await; if registry.contains_key(&sandbox_id) { return Err(tonic::Status::already_exists(format!( - "sandbox {sandbox_name} already exists" + "sandbox {} already exists", + sandbox.name ))); } - let initial = make_sandbox_with_condition( - sandbox, - &DriverCondition { - r#type: "Ready".into(), - status: "False".into(), - reason: "Starting".into(), - message: "MXC lifecycle starting".into(), - transition_time: None, - }, - false, - ); - let _ = self.watch_tx.send(sandbox_event(initial.clone())); registry.insert( sandbox_id.clone(), SandboxEntry { - sandbox: initial, - iso_sandbox_id: None, - isolation_stopped: false, + sandbox: starting.clone(), phase_state: PhaseState::Starting, - lifecycle_gate, - exec_child: None, + lifecycle_gate: gate, shutdown_tx: None, terminated_rx: None, - signal_file: None, - trimmed_policy: mapped.trimmed_policy.clone(), - proxy_addr: mapped.proxy_addr, - host_proxy: None, - control_channel: None, + host_state_dir: host_state_dir.clone(), + boundary_state_dir: boundary_state_dir.clone(), }, ); } - - let invoker = self.invoker.clone(); - let config = self.config.clone(); - let registry = self.registry.clone(); - let watch_tx = self.watch_tx.clone(); - let attribution = self.attribution.clone(); - let sandbox = sandbox.clone(); - tokio::spawn(async move { - run_lifecycle( - invoker, - config, - registry, - watch_tx, - attribution, - sandbox, - sandbox_config, - mapped, - provider_credentials, - reserved_proxy_listener, - startup_guard, - ) - .await; - }); - + let _ = self.watch_tx.send(sandbox_event(starting)); + let context = LifecycleContext { + invoker: self.invoker.clone(), + config: self.config.clone(), + gateway: self.gateway.clone(), + registry: self.registry.clone(), + watch_tx: self.watch_tx.clone(), + attribution: self.attribution.clone(), + sandbox: sandbox.clone(), + sandbox_config, + generation, + host_state_dir, + boundary_state_dir, + policy_mapper: self.policy_mapper.clone(), + }; + tokio::spawn(async move { run_lifecycle(context, startup_guard).await }); Ok(()) } + pub async fn stop_sandbox(&self, sandbox_name: &str) -> Result<(), tonic::Status> { - let (sandbox_id, lifecycle_gate) = { + let (sandbox_id, gate) = { let registry = self.registry.lock().await; let entry = registry .values() @@ -990,113 +397,24 @@ impl MxcComputeBackend { })?; (entry.sandbox.id.clone(), entry.lifecycle_gate.clone()) }; - - // Blocks until any in-flight create_sandbox/run_lifecycle has either - // finished wiring shutdown_tx/control_channel or failed -- closes the - // race where a stop arriving mid-startup would otherwise find both - // `None` and silently no-op (see the `lifecycle_gate` field doc). - let _lifecycle_guard = lifecycle_gate.lock().await; - let ( - iso_id, - mut isolation_stopped, - shutdown_tx, - terminated_rx, - control_channel, - host_proxy, - ) = { + let _guard = gate.lock().await; + let (shutdown, terminated) = { let mut registry = self.registry.lock().await; let entry = registry.get_mut(&sandbox_id).ok_or_else(|| { tonic::Status::not_found(format!("sandbox {sandbox_name} not found")) })?; - ( - entry.iso_sandbox_id.clone(), - entry.isolation_stopped, - // Only ProcessContainer entries have these; isolation_session - // relies on invoker.stop() below instead. .take() the kill - // signal so a concurrent stop can't double-fire it, but - // .clone() terminated_rx (a watch::Receiver, not a oneshot) - // and the control channel (an Arc) -- both need to survive a - // caller that times out below and retries: a fresh clone of - // the same watch::Receiver still observes the SAME - // underlying completion, whereas .take()-ing it would make a - // retry silently skip the wait (see the matching fix in - // delete_sandbox and MR !98's review thread on this). - entry.shutdown_tx.take(), - entry.terminated_rx.clone(), - entry.control_channel.clone(), - entry.host_proxy.take(), - ) + (entry.shutdown_tx.take(), entry.terminated_rx.clone()) }; - drop(host_proxy); - - if let Some(ref iso_id) = iso_id { - if !isolation_stopped { - self.invoker.stop(iso_id).await.map_err(|error| { - tonic::Status::internal(format!("wxc-exec stop failed: {error}")) - })?; - isolation_stopped = true; - } - } else { - // ProcessContainer has no persistent iso id -- the sandbox IS - // the one-shot wxc-exec process, so without this block stop had - // nothing to act on and just relabeled the sandbox Stopped while - // wxc-exec (and everything inside the AppContainer) kept - // running. Ask nicely first over the control channel (bounded - // by request()'s own 3s timeout, same as delete_sandbox), then - // trigger the shutdown_tx kill backstop. - if let Some(channel) = control_channel { - match channel - .request( - "shutdown", - serde_json::Value::Null, - std::time::Duration::from_secs(3), - ) - .await - { - Ok(_) => { - info!(sandbox = %sandbox_name, "control-channel shutdown acknowledged"); - } - Err(e) => { - warn!(sandbox = %sandbox_name, "control-channel shutdown failed: {e}"); - } - } - } - if let Some(tx) = shutdown_tx { - let _ = tx.send(()); - } - // Await *confirmed* termination via terminated_rx -- not just - // firing the kill signal and reporting success regardless -- - // before this returns Ok. This runs whenever terminated_rx is - // present, independent of whether THIS call sent the kill - // signal above: shutdown_tx is None either because the process - // already exited naturally, or because an earlier (possibly - // timed-out) stop/delete attempt already sent it -- either way, - // this call still needs to observe genuine completion, not - // assume it. - if let Some(mut rx) = terminated_rx - && !wait_for_termination(&mut rx).await - { - warn!(sandbox = %sandbox_name, "timed out waiting for ProcessContainer termination on stop"); - return Err(tonic::Status::deadline_exceeded(format!( - "sandbox {sandbox_name} did not terminate within the stop timeout" - ))); - } + if let Some(shutdown) = shutdown { + let _ = shutdown.send(()); } - + wait_for_termination(terminated, sandbox_name).await?; let mut registry = self.registry.lock().await; if let Some(entry) = registry.get_mut(&sandbox_id) { - entry.isolation_stopped = isolation_stopped; - entry.host_proxy = None; entry.phase_state = PhaseState::Stopped; entry.sandbox = make_sandbox_with_condition( &entry.sandbox, - &DriverCondition { - r#type: "Ready".into(), - status: "False".into(), - reason: "Stopped".into(), - message: "MXC sandbox stopped".into(), - transition_time: None, - }, + &condition("Ready", "False", "Stopped", "MXC sandbox stopped"), false, ); let snapshot = entry.sandbox.clone(); @@ -1105,12 +423,13 @@ impl MxcComputeBackend { } Ok(()) } + pub async fn delete_sandbox( &self, sandbox_id: &str, sandbox_name: &str, ) -> Result { - let lifecycle_gate = { + let gate = { let registry = self.registry.lock().await; let Some(entry) = registry.get(sandbox_id) else { return Ok(false); @@ -1122,1191 +441,656 @@ impl MxcComputeBackend { } entry.lifecycle_gate.clone() }; - - // See stop_sandbox's matching comment on lifecycle_gate. - let _lifecycle_guard = lifecycle_gate.lock().await; - let ( - iso_id, - isolation_stopped, - shutdown_tx, - terminated_rx, - signal_file, - control_channel, - host_proxy, - ) = { + let _guard = gate.lock().await; + let (shutdown, terminated, host_state, boundary_state) = { let mut registry = self.registry.lock().await; - let Some(entry) = registry.get_mut(sandbox_id) else { - return Ok(false); - }; + let entry = registry.get_mut(sandbox_id).expect("entry checked above"); ( - entry.iso_sandbox_id.clone(), - entry.isolation_stopped, entry.shutdown_tx.take(), - // .clone(), not .take() -- see stop_sandbox's matching - // comment: a watch::Receiver survives a caller that times - // out and retries, unlike a consumed oneshot. entry.terminated_rx.clone(), - entry.signal_file.take(), - entry.control_channel.take(), - entry.host_proxy.take(), + entry.host_state_dir.clone(), + entry.boundary_state_dir.clone(), ) }; - drop(host_proxy); - if let Some(ref iso_id) = iso_id { - if !isolation_stopped { - self.invoker.stop(iso_id).await.map_err(|error| { - tonic::Status::internal(format!("wxc-exec stop failed: {error}")) - })?; - // Persist phase progress before deprovision. If deprovision - // fails, a retry resumes here instead of stopping twice. - let mut registry = self.registry.lock().await; - if let Some(entry) = registry.get_mut(sandbox_id) { - entry.isolation_stopped = true; - } - } - self.invoker.deprovision(iso_id).await.map_err(|error| { - tonic::Status::internal(format!("wxc-exec deprovision failed: {error}")) - })?; - } else { - // Prefer telling the spawner directly over the control channel - // (a "shutdown" request -- see the launch handshake) so it can - // proactively kill its target and exit before the AppContainer - // teardown below, since that teardown alone can leave sandboxed - // processes running well past this call returning. Awaited - // (bounded by request()'s own 3s timeout) rather than - // fire-and-forget: a detached task races the shutdown_tx - // backstop below instead of being superseded by it, so the - // graceful path can lose to its own fallback. The backstop - // still always runs afterward regardless of outcome here -- - // this only orders "ask nicely" before "force it". Only - // present when the driver launched openshell-supervisor-relay - // (spawner wrapping); mxc-ws-agent.rs (no control channel) - // still uses the older signal-file mechanism. - if let Some(channel) = control_channel { - match channel - .request( - "shutdown", - serde_json::Value::Null, - std::time::Duration::from_secs(3), - ) - .await - { - Ok(_) => { - info!(sandbox = %sandbox_name, "control-channel shutdown acknowledged"); - } - Err(e) => { - warn!(sandbox = %sandbox_name, "control-channel shutdown failed: {e}"); - } - } - } - // Write the shutdown signal file so the spawner inside the - // AppContainer detects deletion and exits cleanly, freeing ports - // and child processes even if MXC does not cascade-kill them when - // wxc-exec is terminated. Only set for mxc-ws-agent.rs (no - // control channel) -- see above. - if let Some(ref path) = signal_file - && let Err(e) = std::fs::write(path, b"") - { - warn!(sandbox = %sandbox_name, path = %path.display(), error = %e, - "failed to write ProcessContainer shutdown signal file"); - } - // Signal monitor_exec to kill wxc-exec as a backstop. - if let Some(tx) = shutdown_tx { - let _ = tx.send(()); - } - // Await *confirmed* termination via terminated_rx -- not just - // firing the signal and reporting success regardless -- before - // this removes the registry entry and returns Ok(true). Without - // this, delete_sandbox could report success while the - // ProcessContainer (and whatever it launched) is still alive, - // retaining ports and file locks (see stop_sandbox's matching - // comment). Runs whenever terminated_rx is present, independent - // of whether THIS call sent the kill signal above -- shutdown_tx - // is None either because the process already exited naturally, - // or because an earlier (possibly timed-out) stop/delete attempt - // already sent it. A retry must still confirm genuine - // completion via the persisted watch value rather than assuming - // it, or it can remove the registry entry and report success - // while the process is still alive (MR !98 review thread). - if let Some(mut rx) = terminated_rx - && !wait_for_termination(&mut rx).await - { - warn!(sandbox = %sandbox_name, "timed out waiting for ProcessContainer termination on delete"); - return Err(tonic::Status::deadline_exceeded(format!( - "sandbox {sandbox_name} did not terminate within the delete timeout" - ))); - } + if let Some(shutdown) = shutdown { + let _ = shutdown.send(()); } - - let mut registry = self.registry.lock().await; - if registry.remove(sandbox_id).is_some() { - if let Ok(mut idx) = self.attribution.lock() { - idx.forget(sandbox_id); + wait_for_termination(terminated, sandbox_name).await?; + cleanup_runtime_directory(&host_state); + cleanup_runtime_directory(&boundary_state); + let removed = self.registry.lock().await.remove(sandbox_id).is_some(); + if removed { + if let Ok(mut attribution) = self.attribution.lock() { + attribution.forget(sandbox_id); } let _ = self.watch_tx.send(deleted_event(sandbox_id.to_string())); - return Ok(true); } - Ok(false) + Ok(removed) } - /// Returns a stream of watch events. - /// - /// First emits a snapshot of all current sandboxes, then forwards live - /// events from the broadcast channel. - pub async fn watch_sandboxes(&self) -> WatchStream { - let (tx, rx) = - mpsc::channel::>(256); - // Subscribe while holding the registry lock. Every transition is then - // represented by either this snapshot or the live receiver. - let (snapshots, mut broadcast_rx): (Vec, _) = { + pub async fn watch_sandboxes(&self) -> WatchStream { + let (tx, rx) = mpsc::channel(256); + let (snapshots, mut updates) = { let registry = self.registry.lock().await; - let broadcast_rx = self.watch_tx.subscribe(); - let snapshots = registry - .values() - .map(|entry| entry.sandbox.clone()) - .collect(); - (snapshots, broadcast_rx) + ( + registry + .values() + .map(|entry| entry.sandbox.clone()) + .collect::>(), + self.watch_tx.subscribe(), + ) }; - - let tx_clone = tx.clone(); tokio::spawn(async move { - // Deliver initial snapshots. - for sb in snapshots { - if tx_clone.send(Ok(sandbox_event(sb))).await.is_err() { + for sandbox in snapshots { + if tx.send(Ok(sandbox_event(sandbox))).await.is_err() { return; } } - // Forward live events. loop { - match broadcast_rx.recv().await { + match updates.recv().await { Ok(event) => { - if tx_clone.send(Ok(event)).await.is_err() { - break; + if tx.send(Ok(event)).await.is_err() { + return; } } - Err(broadcast::error::RecvError::Lagged(_)) => { - // Drop lagged events — the gateway re-syncs via Get/List. - } - Err(broadcast::error::RecvError::Closed) => break, + Err(broadcast::error::RecvError::Lagged(_)) => {} + Err(broadcast::error::RecvError::Closed) => return, } } }); - Box::pin(ReceiverStream::new(rx)) } } -// ── Dynamic port forwarding ─────────────────────────────────────────────────── -// -// Closes the `openshell forward service` gap for MXC: `handle_forward_tcp` -// normally requires a live `ConnectSupervisor` session, which MXC's -// exec-in-driver design never registers (no in-sandbox supervisor process -// exists). This gives the gateway an alternate path straight into a running -// sandbox's control channel instead, bypassing that requirement entirely. - -#[derive(Debug, thiserror::Error)] -pub enum OpenDynamicForwardError { - #[error("sandbox {0} not found")] - SandboxNotFound(String), - #[error( - "sandbox {0} has no control channel (not launched via a relay spawner, or not yet Ready)" - )] - NoControlChannel(String), - #[error("failed to bind ephemeral relay listener: {0}")] - RelayBind(#[source] std::io::Error), - #[error("control channel request failed: {0}")] - ControlChannel(#[from] crate::control_channel::ControlChannelError), - #[error("sandbox rejected forward request: {0}")] - Rejected(String), -} - -/// Cheap, cloneable handle exposing MXC's dynamic port-forward capability — -/// see `MxcComputeBackend::forward_sink`. #[derive(Clone)] -pub struct ForwardSink { - registry: Arc>>, -} - -impl ForwardSink { - /// Open a new, independent relay bridge to `target_port` inside the - /// given sandbox's `AppContainer`, on demand (not pre-declared in the - /// gateway TOML). Returns the ephemeral relay's address — reachable - /// directly by the gateway process itself, no `AppContainer` boundary on - /// that leg — a per-forward auth nonce the caller MUST send as the first - /// bytes on its own connection to that address (see `relay.rs` module - /// docs: the relay is host-interface-bound, so another reachable process - /// could otherwise race to connect first and hijack the forward), and a - /// [`relay::RelayHandle`] the caller must hold for as long as the - /// forward should stay open, then `.stop()` (or just drop) to tear it - /// down. - /// - /// Target host is always `127.0.0.1` inside the `AppContainer` (matching - /// `TcpRelayTarget`'s existing loopback-only restriction at the gRPC - /// layer), so there's no separate `target_host` parameter to thread - /// through — the sandbox-side `forward` op only ever dials loopback. - pub async fn open_dynamic_forward( - &self, - sandbox_id: &str, - target_port: u16, - ) -> Result<(SocketAddr, [u8; relay::NONCE_LEN], relay::RelayHandle), OpenDynamicForwardError> - { - let (control_channel, sandbox_name) = { - let reg = self.registry.lock().await; - let entry = reg - .get(sandbox_id) - .ok_or_else(|| OpenDynamicForwardError::SandboxNotFound(sandbox_id.to_string()))?; - let channel = entry - .control_channel - .clone() - .ok_or_else(|| OpenDynamicForwardError::NoControlChannel(sandbox_id.to_string()))?; - (channel, entry.sandbox.name.clone()) - }; - - // Fresh per forward -- see relay.rs module docs for why this matters - // on a host-interface listener. - let nonce: [u8; relay::NONCE_LEN] = rand::random(); - - let bind_addr: SocketAddr = "127.0.0.1:0".parse().unwrap(); - let (relay_handle, relay_addr) = relay::start_control_channel_relay( - bind_addr, - sandbox_name, - nonce, - control_channel, - target_port, - ) - .await - .map_err(OpenDynamicForwardError::RelayBind)?; - - Ok((relay_addr, nonce, relay_handle)) - } -} - -// ── Lifecycle task ──────────────────────────────────────────────────────────── - -#[allow(clippy::too_many_arguments)] -async fn run_lifecycle( +struct LifecycleContext { invoker: WxcExecInvoker, config: MxcComputeConfig, + gateway: GatewayConnection, registry: Arc>>, watch_tx: Arc>, attribution: Arc>, sandbox: DriverSandbox, sandbox_config: MxcSandboxConfig, - mapped: MappedConfig, - provider_credentials: Option, - mut reserved_proxy_listener: Option, + generation: String, + host_state_dir: PathBuf, + boundary_state_dir: PathBuf, + policy_mapper: Arc, +} + +async fn run_lifecycle(context: LifecycleContext, startup_guard: tokio::sync::OwnedMutexGuard<()>) { + if let Err(error) = run_lifecycle_inner(&context, startup_guard).await { + set_failed(&context, &error).await; + } +} + +async fn run_lifecycle_inner( + context: &LifecycleContext, startup_guard: tokio::sync::OwnedMutexGuard<()>, -) { - let sandbox_id = sandbox.id.clone(); - let sandbox_name = sandbox.name.clone(); - let proxy_addr = mapped.proxy_addr; - let proxy_auth = proxy_addr.map(|_| SandboxProxyAuth::generate()); - let trimmed_policy = mapped.trimmed_policy.clone(); - let host_proxy = if !invoker.is_mock() - && let (Some(addr), Some(proxy_policy), Some(proxy_auth)) = - (proxy_addr, trimmed_policy.clone(), proxy_auth.as_ref()) - { - drop(reserved_proxy_listener.take()); - match openshell_supervisor_network::host::start_host_proxy( - openshell_supervisor_network::host::HostProxyConfig { - bind_addr: addr, - policy: proxy_policy, - binary_path: host_proxy_binary_path(&sandbox_config), - client_auth: proxy_auth.host_client_auth(), - sandbox_id: Some(sandbox_id.clone()), - sandbox_name: Some(sandbox_name.clone()), - openshell_endpoint: None, - provider_credentials: provider_credentials.clone(), - agent_proposals: openshell_core::proposals::AgentProposals::default(), - denial_tx: None, - activity_tx: None, +) -> Result<(), String> { + create_restricted_state_dir(&context.host_state_dir, "host")?; + // This directory briefly contains the boundary TLS private key and direct + // proxy credential. Restrict host access before writing either secret; + // MXC adds the ProcessContainer grant when it applies the read-write path. + create_restricted_state_dir(&context.boundary_state_dir, "boundary")?; + let launch = launch_authentication(&context.sandbox).map_err(|error| error.to_string())?; + let (control_addr, control_reservation) = reserve_loopback_port() + .map_err(|error| format!("reserve MXC Sandbox Protocol port: {error}"))?; + let (proxy_addr, proxy_reservation) = reserve_loopback_port() + .map_err(|error| format!("reserve MXC supervisor proxy port: {error}"))?; + let mapped = context + .policy_mapper + .map( + context + .sandbox + .spec + .as_ref() + .and_then(|spec| spec.policy.as_ref()), + &MapCtx { + sandbox_id: context.sandbox.id.clone(), + egress: Some(proxy_addr), + containment: MxcBackend::ProcessContainer.containment().to_string(), + }, + ) + .map_err(|error| error.to_string())?; + let tls = generate_sandbox_tls_material(launch.supervisor.session_id) + .map_err(|error| error.to_string())?; + let tls_cert_path = context.boundary_state_dir.join(BOUNDARY_TLS_CERT_FILE); + let tls_key_path = context.boundary_state_dir.join(BOUNDARY_TLS_KEY_FILE); + std::fs::write(&tls_cert_path, tls.certificate_chain_pem.as_bytes()) + .map_err(|error| format!("write MXC boundary TLS certificate: {error}"))?; + std::fs::write(&tls_key_path, tls.private_key_pem.as_bytes()) + .map_err(|error| format!("write MXC boundary TLS private key: {error}"))?; + let proxy_password = + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(rand::random::<[u8; 32]>()); + let authorization = format!( + "Basic {}", + base64::engine::general_purpose::STANDARD + .encode(format!("{DIRECT_PROXY_USERNAME}:{proxy_password}")) + ); + let proxy_url = format!("http://{DIRECT_PROXY_USERNAME}:{proxy_password}@{proxy_addr}"); + let verification_keys = launch + .verification_keys + .iter() + .map(|key| { + String::from_utf8(key.public_key_pem.clone()) + .map(|public_key_pem| GatewayVerificationKey { + key_id: key.key_id.clone(), + public_key_pem, + }) + .map_err(|error| format!("MXC verification key is not UTF-8 PEM: {error}")) + }) + .collect::, _>>()?; + let provisioning = MxcBoundarySpec { + boundary_id: context.sandbox.id.clone(), + generation: context.generation.clone(), + session_id: launch.supervisor.session_id, + session_rotation: launch.supervisor.session_rotation, + auth_epoch: launch.supervisor.auth_epoch, + gateway_id: launch.gateway_id.clone(), + verification_keys, + control_addr, + supervisor_tls: SandboxTlsClientConfig { + server_name: tls.server_name, + trust_anchor_pem: tls.trust_anchor_pem, + }, + sandbox_tls: SandboxTlsServerConfig { + certificate_chain_path: tls_cert_path, + private_key_path: tls_key_path, + }, + proxy_addr, + proxy_authorization: authorization, + proxy_url, + workload_binary: resolve_workload_binary(&context.sandbox_config.command[0])?, + child_env: sandbox_environment(&context.sandbox), + } + .provision() + .map_err(|error| error.to_string())?; + let boundary_config_path = context.boundary_state_dir.join(BOUNDARY_CONFIG_FILE); + std::fs::write( + &boundary_config_path, + provisioning + .boundary_config + .encode() + .map_err(|error| error.to_string())?, + ) + .map_err(|error| format!("write MXC boundary configuration: {error}"))?; + let auth_bundle_path = context.host_state_dir.join(HOST_AUTH_BUNDLE_FILE); + std::fs::write( + &auth_bundle_path, + serde_json::to_vec(&launch.supervisor) + .map_err(|error| format!("encode MXC supervisor auth bundle: {error}"))?, + ) + .map_err(|error| format!("write MXC supervisor auth bundle: {error}"))?; + let descriptor_path = context.host_state_dir.join(HOST_RUNTIME_DESCRIPTOR_FILE); + std::fs::write( + &descriptor_path, + provisioning + .runtime_descriptor + .backend_descriptor() + .map_err(|error| error.to_string())? + .payload, + ) + .map_err(|error| format!("write MXC runtime descriptor: {error}"))?; + // The supervisor owns the proxy listener. Release only that reservation + // before spawning it; keep the Sandbox Protocol port reserved until the + // ProcessContainer launch so unrelated local processes cannot squat it + // during the more expensive host-side setup. + drop(proxy_reservation); + let mut supervisor = spawn_supervisor(context, &descriptor_path, &auth_bundle_path)?; + let sandbox_command = encode_windows_command_line(&[ + context.config.sandbox_binary_path.clone(), + "--bootstrap".to_string(), + boundary_config_path.display().to_string(), + "--log-level".to_string(), + openshell_core::driver_utils::sandbox_log_level(&context.sandbox, "warn"), + ]); + let mut filesystem = MxcFilesystem { + readwrite_paths: mapped.readwrite_paths, + readonly_paths: mapped.readonly_paths, + denied_paths: Vec::new(), + }; + push_unique_path( + &mut filesystem.readwrite_paths, + context.boundary_state_dir.display().to_string(), + ); + push_unique_path( + &mut filesystem.readonly_paths, + context.config.sandbox_binary_path.clone(), + ); + let process = MxcProcess { + command_line: sandbox_command, + cwd: context.sandbox_config.cwd.clone(), + env: trusted_sandbox_environment(context.config.pc_minimal_env), + timeout: 0, + }; + let network = MxcNetwork { + default_policy: "block".to_string(), + proxy: Some(proxy_addr), + allow_local_network: context.config.pc_allow_local_network, + }; + drop(control_reservation); + let mut boundary = context + .invoker + .run_oneshot( + &context.sandbox.id, + filesystem, + MxcProcessContainer { + least_privilege: context.config.pc_least_privilege, + capabilities: context.config.pc_capabilities.clone(), }, + process, + Some(network), + mapped.ui, ) .await - { - Ok(handle) => Some(handle), - Err(error) => { - set_failed( - ®istry, - &watch_tx, - &sandbox, - &sandbox_id, - &format!("failed to start MXC host egress proxy at {addr}: {error}"), - ) - .await; - return; - } + .map_err(|error| format!("start MXC ProcessContainer: {error}"))?; + attach_child_logs(&context.sandbox.name, "sandbox", &mut boundary); + attach_child_logs(&context.sandbox.name, "supervisor", &mut supervisor); + if context.config.etw_audit + && let Some(pid) = boundary.id() + && let Ok(process_start_key) = crate::etw_consumer::child_process_start_key(&boundary) + && let Ok(mut attribution) = context.attribution.lock() + { + attribution.register_launch( + &context.sandbox.id, + &context.sandbox.name, + pid, + process_start_key, + ); + } + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + let (terminated_tx, terminated_rx) = watch::channel(false); + { + let mut registry = context.registry.lock().await; + let entry = registry + .get_mut(&context.sandbox.id) + .ok_or_else(|| "MXC sandbox was deleted during startup".to_string())?; + entry.shutdown_tx = Some(shutdown_tx); + entry.terminated_rx = Some(terminated_rx); + entry.phase_state = PhaseState::Running; + } + drop(startup_guard); + let result = monitor_runtime_pair(boundary, supervisor, shutdown_rx).await; + let _ = terminated_tx.send(true); + if let Ok(mut attribution) = context.attribution.lock() { + attribution.forget(&context.sandbox.id); + } + match result { + RuntimePairExit::Shutdown => Ok(()), + RuntimePairExit::Boundary(status) => Err(format!( + "MXC ProcessContainer exited before supervisor shutdown: {status}" + )), + RuntimePairExit::Supervisor(status) => Err(format!( + "MXC supervisor exited while ProcessContainer was active: {status}" + )), + RuntimePairExit::Wait(error) => Err(error), + } +} + +fn spawn_supervisor( + context: &LifecycleContext, + descriptor_path: &Path, + auth_bundle_path: &Path, +) -> Result { + let main_process_spec = openshell_core::sandbox_env::MainProcessConfig::encode_driver_spec( + context.sandbox.spec.as_ref(), + ) + .map_err(|error| format!("encode MXC main process spec: {error}"))?; + let mut command = Command::new(&context.config.supervisor_binary_path); + command + .kill_on_drop(true) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .arg("--role") + .arg("isolation-backend") + .arg("--backend-descriptor-file") + .arg(descriptor_path) + .arg("--auth-bundle-file") + .arg(auth_bundle_path) + .env( + openshell_core::sandbox_env::ADMITTED_ISOLATION_BACKEND, + openshell_sandbox_backend::BACKEND_NAME, + ) + .env( + openshell_core::sandbox_env::MAIN_PROCESS_SPEC, + main_process_spec, + ) + .env( + openshell_core::sandbox_env::ENDPOINT, + &context.gateway.endpoint, + ) + .env(openshell_core::sandbox_env::SANDBOX_ID, &context.sandbox.id) + .env(openshell_core::sandbox_env::SANDBOX, &context.sandbox.name) + .env( + openshell_core::sandbox_env::LOG_LEVEL, + openshell_core::driver_utils::sandbox_log_level(&context.sandbox, "warn"), + ); + if let Some((ca, cert, key)) = &context.gateway.tls { + command + .env(openshell_core::sandbox_env::TLS_CA, ca) + .env(openshell_core::sandbox_env::TLS_CERT, cert) + .env(openshell_core::sandbox_env::TLS_KEY, key); + } + if let Some(server_name) = &context.gateway.tls_server_name { + command.env( + openshell_core::sandbox_env::GATEWAY_TLS_SERVER_NAME, + server_name, + ); + } + command + .spawn() + .map_err(|error| format!("start host openshell-supervisor: {error}")) +} + +enum RuntimePairExit { + Shutdown, + Boundary(std::process::ExitStatus), + Supervisor(std::process::ExitStatus), + Wait(String), +} + +async fn monitor_runtime_pair( + mut boundary: Child, + mut supervisor: Child, + mut shutdown: oneshot::Receiver<()>, +) -> RuntimePairExit { + tokio::select! { + _ = &mut shutdown => { + let _ = supervisor.kill().await; + let _ = boundary.kill().await; + let _ = supervisor.wait().await; + let _ = boundary.wait().await; + RuntimePairExit::Shutdown } - } else { - None - }; - let host_proxy_ca_paths = host_proxy - .as_ref() - .and_then(openshell_supervisor_network::host::HostProxyHandle::ca_file_paths); - // A curated ProcessContainer cannot read the host's private temp folder. - // Stage only the public CA material beneath the per-sandbox working directory, - // DACL is already granted by the policy, so HTTPS clients can authenticate - // the OpenShell inspection proxy without broadening filesystem access. - let agent_proxy_ca_paths = if config.pc_minimal_env && host_proxy_ca_paths.is_some() { - match stage_tls_ca_files( - host_proxy_ca_paths.as_ref(), - &sandbox_config.cwd, - &sandbox_id, - ) { - Ok(paths) => paths, - Err(error) => { - set_failed( - ®istry, - &watch_tx, - &sandbox, - &sandbox_id, - &format!("failed to stage MXC egress proxy CA files: {error}"), - ) - .await; - return; - } + result = boundary.wait() => { + let _ = supervisor.kill().await; + let _ = supervisor.wait().await; + result.map_or_else( + |error| RuntimePairExit::Wait(format!("wait for MXC ProcessContainer: {error}")), + RuntimePairExit::Boundary, + ) } - } else { - host_proxy_ca_paths.clone() - }; - drop(reserved_proxy_listener.take()); - if let Some(addr) = proxy_addr { - { - let mut registry = registry.lock().await; - if let Some(entry) = registry.get_mut(&sandbox_id) { - entry.trimmed_policy = trimmed_policy; - entry.proxy_addr = Some(addr); - entry.host_proxy = host_proxy; - } + result = supervisor.wait() => { + let _ = boundary.kill().await; + let _ = boundary.wait().await; + result.map_or_else( + |error| RuntimePairExit::Wait(format!("wait for MXC supervisor: {error}")), + RuntimePairExit::Supervisor, + ) } - let _ = watch_tx.send(platform_event( - sandbox_id.clone(), - "EgressRedirect", - format!("MXC egress redirected to OpenShell host CONNECT proxy at {addr}"), + } +} + +async fn set_failed(context: &LifecycleContext, message: &str) { + warn!(sandbox = %context.sandbox.name, %message, "MXC lifecycle failed"); + let failed = make_sandbox_with_condition( + &context.sandbox, + &condition("Ready", "False", "RuntimeFailed", message), + false, + ); + let mut registry = context.registry.lock().await; + if let Some(entry) = registry.get_mut(&context.sandbox.id) + && !matches!(entry.phase_state, PhaseState::Stopped) + { + entry.phase_state = PhaseState::Failed(message.to_string()); + entry.sandbox = failed.clone(); + drop(registry); + let _ = context.watch_tx.send(sandbox_event(failed)); + let _ = context.watch_tx.send(platform_event( + context.sandbox.id.clone(), + "RuntimeFailed", + message.to_string(), )); } +} - let mut readwrite_paths = mapped.readwrite_paths; - if !config.pc_minimal_env { - append_tls_readwrite_grant(&mut readwrite_paths, host_proxy_ca_paths.as_ref()); +fn sandbox_config(sandbox: &DriverSandbox) -> Result { + let config = sandbox + .spec + .as_ref() + .and_then(|spec| spec.template.as_ref()) + .and_then(|template| template.driver_config.as_ref()) + .ok_or_else(|| { + tonic::Status::invalid_argument( + "mxc requires template.driver_config.mxc with command and cwd", + ) + })?; + let config: MxcSandboxConfig = + serde_json::from_value(struct_to_json_value(config)).map_err(|error| { + tonic::Status::invalid_argument(format!("invalid mxc driver_config: {error}")) + })?; + if config.command.first().is_none_or(String::is_empty) { + return Err(tonic::Status::invalid_argument( + "mxc driver_config.command must contain an executable", + )); } - let readonly_paths = mapped.readonly_paths; - let ui = mapped.ui; - let filesystem = MxcFilesystem { - readwrite_paths, - readonly_paths, - // OpenShell's policy model has no explicit deny field; default-deny is - // implicit and enforced by processContainer at the OS boundary. - denied_paths: Vec::new(), - }; - let command_line = encode_windows_command_line(&sandbox_config.command); - // ProcessContainer starts with a completely blank environment — no PATH, - // no SystemRoot, nothing. Start with either an empty environment or the - // safe Windows bootstrap set, then layer the per-request environment from - // the CreateSandbox spec and the TLS/proxy variables required by governed - // egress. - let mut env_map: HashMap = if config.pc_minimal_env { - HashMap::new() - } else { - MINIMAL_WINDOWS_BOOTSTRAP_ENV - .iter() - .filter_map(|&key| std::env::var(key).ok().map(|v| (key.to_string(), v))) - .collect() - }; + Ok(config) +} + +fn launch_authentication( + sandbox: &DriverSandbox, +) -> Result { + let encoded = sandbox + .spec + .as_ref() + .map(|spec| spec.launch_authentication.as_slice()) + .filter(|encoded| !encoded.is_empty()) + .ok_or_else(|| { + tonic::Status::failed_precondition("MXC sandbox launch authentication is required") + })?; + serde_json::from_slice(encoded).map_err(|error| { + tonic::Status::failed_precondition(format!( + "decode MXC sandbox launch authentication: {error}" + )) + }) +} - for entry in sandbox_environment(&sandbox) { - if let Some(pos) = entry.find('=') { - env_map.insert(entry[..pos].to_string(), entry[pos + 1..].to_string()); +fn sandbox_environment(sandbox: &DriverSandbox) -> HashMap { + let mut environment = HashMap::new(); + if let Some(spec) = &sandbox.spec { + if let Some(template) = &spec.template { + environment.extend(template.environment.clone()); } + environment.extend(spec.environment.clone()); } + environment +} - let mut env: Vec = env_map +fn trusted_sandbox_environment(minimal: bool) -> Vec { + if minimal { + return Vec::new(); + } + ["SYSTEMROOT", "WINDIR", "PATH", "COMSPEC", "LOCALAPPDATA"] .into_iter() - .map(|(k, v)| format!("{k}={v}")) - .collect(); - append_provider_child_env(&mut env, provider_credentials.as_ref()); - // Layer proxy configuration for every env tier. Curated ProcessContainers - // use the staged CA copies above; other tiers use the original paths. - append_tls_env_vars(&mut env, agent_proxy_ca_paths.as_ref()); - append_proxy_env_vars(&mut env, proxy_addr, proxy_auth.as_ref()); - env.sort(); // deterministic order for logging / debugging - info!(sandbox = %sandbox_name, count = env.len(), "MXC process env vars"); + .filter_map(|key| { + std::env::var(key) + .ok() + .map(|value| format!("{key}={value}")) + }) + .collect() +} - // When spawner wrapping is configured, launch openshell-supervisor-relay - // instead of the per-sandbox command directly. The real command/env are sent over - // the control channel once the spawner announces readiness (see the - // "launch" handshake below) rather than written to the working directory as - // agent-cmd.txt/agent-env.txt -- this keeps command/env (which can carry - // secrets, e.g. OPENCLAW_GATEWAY_TOKEN) off disk entirely and eliminates - // the file-staleness/namespace-mismatch bug class that existed when they - // were file-based. The target application (e.g. OpenClaw) - // stays entirely unaware of the relay protocol either way. - let spawner_wrapping_active = - !config.pc_relay_spawner_path.is_empty() && config.pc_relay_target_port != 0; - let effective_command_line = if spawner_wrapping_active { - // Quoted: pc_relay_spawner_path is a filesystem path and may contain - // spaces (e.g. under "Program Files"); unquoted, wxc-exec would - // parse the executable path incorrectly and the launch would fail - // before the control-channel handshake ever starts. - format!( - "\"{}\" {}", - config.pc_relay_spawner_path, config.pc_relay_target_port - ) +fn reserve_loopback_port() -> io::Result<(SocketAddr, std::net::TcpListener)> { + let listener = std::net::TcpListener::bind("127.0.0.1:0")?; + Ok((listener.local_addr()?, listener)) +} + +fn resolve_workload_binary(command: &str) -> Result { + let path = PathBuf::from(command); + if path.is_absolute() { + return Ok(path); + } + let output = std::process::Command::new("where.exe") + .arg(command) + .output() + .map_err(|error| format!("resolve MXC workload executable {command:?}: {error}"))?; + if !output.status.success() { + return Err(format!( + "MXC workload executable {command:?} is relative and was not found on PATH" + )); + } + String::from_utf8(output.stdout) + .map_err(|error| format!("decode resolved MXC workload executable: {error}"))? + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(PathBuf::from) + .find(|candidate| candidate.is_absolute()) + .ok_or_else(|| format!("where.exe returned no absolute path for {command:?}")) +} + +fn safe_component(value: &str) -> Result<&str, tonic::Status> { + if !value.is_empty() + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + { + Ok(value) } else { - command_line.clone() - }; + Err(tonic::Status::invalid_argument( + "sandbox ID is not safe for MXC state paths", + )) + } +} - // Downstream logging/ETW attribution should reflect what's actually - // launched (openshell-supervisor-relay, when wrapping is active), not - // the original workload command -- shadow command_line with the effective - // value. - let command_line = effective_command_line; - let process = MxcProcess { - command_line: command_line.clone(), - cwd: sandbox_config.cwd.clone(), - // Cloned: the launch handshake below (spawner_wrapping_active case) - // needs its own copy of `env` to send over the control channel. - env: env.clone(), - timeout: 0, - }; - let network = proxy_addr.map(|addr| MxcNetwork { - default_policy: "block".into(), - proxy: Some(addr), - allow_local_network: false, - }); +fn push_unique_path(paths: &mut Vec, path: String) { + if !paths + .iter() + .any(|existing| existing.eq_ignore_ascii_case(&path)) + { + paths.push(path); + } +} - let child = match config.backend { - MxcBackend::IsolationSession => { - let iso_sandbox_id = match invoker - .provision(&config.default_configuration_id, filesystem, network) - .await - { - Ok(id) => id, - Err(error) => { - set_failed( - ®istry, - &watch_tx, - &sandbox, - &sandbox_id, - &error.to_string(), - ) - .await; - return; - } - }; - info!(sandbox = %sandbox_name, iso_id = %iso_sandbox_id, "MXC provisioned"); - { - let mut registry = registry.lock().await; - if let Some(entry) = registry.get_mut(&sandbox_id) { - // Publish cleanup identity before any later lifecycle await. - entry.iso_sandbox_id = Some(iso_sandbox_id.clone()); - entry.isolation_stopped = false; - } - } - if let Err(error) = invoker.start(&iso_sandbox_id).await { - set_failed( - ®istry, - &watch_tx, - &sandbox, - &sandbox_id, - &error.to_string(), - ) - .await; - return; - } - info!(sandbox = %sandbox_name, "MXC started"); - match invoker.spawn_exec(&iso_sandbox_id, process).await { - Ok(child) => child, - Err(error) => { - set_failed( - ®istry, - &watch_tx, - &sandbox, - &sandbox_id, - &error.to_string(), - ) - .await; - return; - } - } - } - MxcBackend::ProcessContainer => { - let process_container = MxcProcessContainer { - least_privilege: config.pc_least_privilege, - capabilities: config.pc_capabilities.clone(), - }; - // Build the effective network config: - // - egress_proxy: use the proxy-based network (already in `network`) - // - pc_network_allow: inject allow-all (fallback for builds without capability support) - // - pc_allow_local_network: block-default but with allowLocalNetwork=true so - // intra-container loopback works and the spawner can reach the relay on the - // host's route-selected private interface without a full egress proxy. - let effective_network = if network.is_none() - && (config.pc_allow_local_network || config.pc_network_allow) - { - // Both flags apply to the same no-proxy startup case and - // aren't mutually exclusive -- honor both instead of letting - // pc_allow_local_network's branch silently force - // default_policy back to "block" and drop pc_network_allow's - // unrestricted-egress intent. - Some(MxcNetwork { - default_policy: if config.pc_network_allow { - "allow".into() - } else { - "block".into() - }, - proxy: None, - allow_local_network: config.pc_allow_local_network, - }) - } else { - // `network` is Some here (egress_proxy configured). Preserve - // config.pc_allow_local_network instead of unconditionally - // clearing it -- MxcNetwork already carries both `proxy` and - // `allow_local_network` together, so a proxy and local-network - // access aren't mutually exclusive. - network.map(|mut n| { - n.allow_local_network = config.pc_allow_local_network; - n - }) - }; - match invoker - .run_oneshot( - &sandbox_id, - filesystem, - process_container, - process, - effective_network, - ui, - ) - .await - { - Ok(child) => child, - Err(error) => { - set_failed( - ®istry, - &watch_tx, - &sandbox, - &sandbox_id, - &error.to_string(), - ) - .await; - return; - } - } - } - }; - info!(sandbox = %sandbox_name, command = %command_line, backend = ?config.backend, "MXC agent launched"); +fn cleanup_runtime_directory(path: &Path) { + if let Err(error) = std::fs::remove_dir_all(path) + && error.kind() != io::ErrorKind::NotFound + { + warn!(path = %path.display(), %error, "failed to remove MXC runtime state"); + } +} - // Stream wxc-exec's stdout/stderr into the gateway log as it runs, so the - // agent's live output is visible in the gateway console instead of sitting - // unread in the OS pipe until the process exits. - let mut child = child; +/// Create a state directory whose DACL grants access only to the gateway's +/// Windows identity until MXC applies any explicit ProcessContainer grant. +/// Secret-bearing state must not inherit permissive ACLs from its parent. +fn create_restricted_state_dir(path: &Path, kind: &str) -> Result<(), String> { + std::fs::create_dir_all(path) + .map_err(|error| format!("create MXC {kind} state directory: {error}"))?; + let identity = std::process::Command::new("whoami.exe") + .args(["/user", "/fo", "csv", "/nh"]) + .output() + .map_err(|error| format!("resolve gateway Windows identity: {error}"))?; + if !identity.status.success() { + return Err("whoami failed while restricting MXC host state".to_string()); + } + let identity = String::from_utf8(identity.stdout) + .map_err(|error| format!("decode gateway Windows identity: {error}"))?; + let sid = identity + .trim() + .rsplit_once(',') + .map(|(_, sid)| sid.trim().trim_matches('"')) + .filter(|sid| sid.starts_with("S-1-")) + .ok_or_else(|| "whoami returned no Windows SID".to_string())?; + let grant = format!("*{sid}:(OI)(CI)F"); + let status = std::process::Command::new("icacls.exe") + .arg(path) + .args(["/inheritance:r", "/grant:r", &grant, "/q"]) + .status() + .map_err(|error| format!("restrict MXC host state ACL: {error}"))?; + if !status.success() { + return Err(format!( + "icacls failed to restrict MXC {kind} state directory {}", + path.display() + )); + } + Ok(()) +} - // Control channel: correlate JSON responses in the stdout stream with - // pending requests sent over stdin (see control_channel.rs). Only - // meaningful when the process on the other end is - // openshell-supervisor-relay (spawner wrapping active) -- an arbitrary - // workload target wouldn't understand this protocol, so stdin is - // left untouched (and unpiped expectations unaffected) otherwise. - let control_channel: Option> = if spawner_wrapping_active { - if let Some(stdin) = child.stdin.take() { - Some(Arc::new(ControlChannel::new(stdin))) - } else { - // wxc-exec didn't give us a piped stdin even though spawner - // wrapping was requested. Without a control channel, - // openshell-supervisor-relay would wait forever for a - // "launch" request that can never arrive -- a silent hang, - // not a failure. Fail the sandbox now instead. - let err = "wxc-exec stdin is not piped; control-channel launch cannot proceed"; - set_failed(®istry, &watch_tx, &sandbox, &sandbox_id, err).await; - let _ = child.kill().await; - let _ = child.wait().await; - return; - } - } else { - // No control channel on the direct-agent path. Both spawn_exec and - // run_oneshot now always pipe stdin (needed for the control-channel - // case above), so without this the write end stays open inside - // `child` for the sandbox's full lifetime -- any workload that - // reads stdin until EOF would then block forever, since EOF never - // arrives. Drop it so stdin readers see EOF immediately instead. - drop(child.stdin.take()); - None - }; - let pending_responses = control_channel.as_ref().map(|c| c.pending_handle()); - // Startup-ready signal from the spawner (see control_channel.rs's - // try_route_ready). Fired once, before the "launch" handshake below. - // Carries Err(reason) instead of firing at all when the spawner's - // reported protocol_version doesn't match what this driver requires -- - // see try_route_ready's doc comment. - let (ready_slot, ready_rx) = if spawner_wrapping_active { - let (tx, rx) = oneshot::channel::>(); - (Some(Arc::new(Mutex::new(Some(tx)))), Some(rx)) - } else { - (None, None) - }; - // Target-ready signal from the spawner (see control_channel.rs's - // try_route_target_ready) -- fired once the target is actually running - // and its configured port is accepting connections, distinct from the - // "launch" response below (which only confirms the command/env - // arrived). Awaited after "launch" succeeds and before publishing - // Ready=True, so Ready can't be reported while the target is still - // unreachable. Always Ok(()) when it fires (no version gate on this - // event -- see try_route_target_ready). - let (target_ready_slot, target_ready_rx) = if spawner_wrapping_active { - let (tx, rx) = oneshot::channel::>(); - (Some(Arc::new(Mutex::new(Some(tx)))), Some(rx)) - } else { - (None, None) +async fn wait_for_termination( + terminated: Option>, + sandbox_name: &str, +) -> Result<(), tonic::Status> { + let Some(mut terminated) = terminated else { + return Ok(()); }; + match tokio::time::timeout(Duration::from_secs(15), terminated.wait_for(|done| *done)).await { + Ok(Ok(_)) => Ok(()), + _ => Err(tonic::Status::deadline_exceeded(format!( + "sandbox {sandbox_name} did not terminate within the MXC stop timeout" + ))), + } +} +fn attach_child_logs(sandbox_name: &str, component: &'static str, child: &mut Child) { if let Some(stdout) = child.stdout.take() { - let sandbox_name_out = sandbox_name.clone(); - let ready_slot = ready_slot.clone(); - let target_ready_slot = target_ready_slot.clone(); + let sandbox_name = sandbox_name.to_string(); tokio::spawn(async move { let mut lines = BufReader::new(stdout).lines(); - loop { - match lines.next_line().await { - Ok(Some(line)) => { - let routed_ready = match &ready_slot { - Some(slot) => ControlChannel::try_route_ready(slot, &line).await, - None => false, - }; - let routed_target_ready = match &target_ready_slot { - Some(slot) => ControlChannel::try_route_target_ready(slot, &line).await, - None => false, - }; - let routed = routed_ready - || routed_target_ready - || match &pending_responses { - Some(pending) => { - ControlChannel::try_route_response(pending, &line).await - } - None => false, - }; - if !routed { - info!(sandbox = %sandbox_name_out, "wxc-exec stdout: {line}"); - } - } - Ok(None) => break, - Err(e) => { - warn!(sandbox = %sandbox_name_out, "wxc-exec stdout read error: {e}"); - break; - } - } - } - // Stdout is gone (EOF or read error): no control-channel response - // will ever arrive again. Fail any still-pending requests now - // instead of leaving them to time out individually. - if let Some(pending) = &pending_responses { - ControlChannel::fail_all_pending(pending).await; + while let Ok(Some(line)) = lines.next_line().await { + info!(sandbox = %sandbox_name, component, "{line}"); } }); } - // Drop this scope's Arc clones now that the stdout task holds its own: - // if the spawner exits before ever sending "ready"/"target_ready", the - // stdout task's clone is the only thing keeping the - // Mutex> alive, so its loop ending (EOF) drops the last - // reference -- which drops the still-`Some` Sender and makes - // ready_rx/target_ready_rx below observe a dropped sender immediately - // instead of waiting out the full timeout. - drop(ready_slot); - drop(target_ready_slot); if let Some(stderr) = child.stderr.take() { - let sandbox_name_err = sandbox_name.clone(); + let sandbox_name = sandbox_name.to_string(); tokio::spawn(async move { let mut lines = BufReader::new(stderr).lines(); - loop { - match lines.next_line().await { - Ok(Some(line)) => warn!(sandbox = %sandbox_name_err, "wxc-exec stderr: {line}"), - Ok(None) => break, - Err(e) => { - warn!(sandbox = %sandbox_name_err, "wxc-exec stderr read error: {e}"); - break; - } - } + while let Ok(Some(line)) = lines.next_line().await { + warn!(sandbox = %sandbox_name, component, "{line}"); } }); } - // Publish a cancellable handle (exec_child, and for ProcessContainer - // shutdown_tx/terminated_rx too) and release the startup gate now, - // rather than holding it until the target-readiness wait below (up to - // ~430s worst case: 120s ready + 310s target_ready) completes or times - // out. stop_sandbox/delete_sandbox block on lifecycle_gate before doing - // anything else, so holding it this long meant a stop/delete arriving - // while a target is slow to (or never does) come up had no way to - // interrupt that wait -- it just queued up behind it. See also imp.rs's - // matching fix: openshell-supervisor-relay now races its own - // port-readiness wait against a "shutdown" request instead of only - // observing shutdown once that wait finishes. - let shutdown_rx = { - let mut reg = registry.lock().await; - let Some(entry) = reg.get_mut(&sandbox_id) else { - // The sandbox was deleted between agent launch and now. `delete` - // already tore down the *previous* process entry, but `child` - // here was spawned after that -- it was never registered, so - // nothing else will kill it. `tokio::process::Child` does not - // kill-on-drop, so without this the wxc-exec process (and its - // AppContainer) would keep running past `delete` reporting - // success. - drop(reg); - let _ = child.kill().await; - let _ = child.wait().await; - return; - }; - - // Seed ETW attribution while holding the registry lock so a concurrent - // `delete` cannot remove the sandbox after we register (which would leave - // a stale key). The `wxc-exec` pid we just spawned is the collision-proof - // anchor that ties the `Sandboxing` provider's events back to this - // `sandbox_id` while the exact process generation is alive. Command - // text is never an attribution key. - if config.etw_audit - && let Some(pid) = child.id() - { - match crate::etw_consumer::child_process_start_key(&child) { - Ok(process_start_key) => { - if let Ok(mut idx) = attribution.lock() { - idx.register_launch(&sandbox_id, &sandbox_name, pid, process_start_key); - } - } - Err(error) => { - warn!(sandbox = %sandbox_name, pid, error, - "failed to obtain wxc-exec process generation key; ETW attribution disabled for this launch"); - } - } - } - - entry.exec_child = Some(child); - entry.control_channel.clone_from(&control_channel); - - // For ProcessContainer, wire a kill channel so stop_sandbox/ - // delete_sandbox can terminate the wxc-exec process and cause the - // AppContainer (and all in-sandbox processes, including long-lived - // servers) to be torn down, as a backstop regardless of how shutdown - // is signaled below. `terminated_rx` is the other half of the pair - // `monitor_exec` uses to report back once the process has actually - // exited, so callers can await confirmed termination instead of - // just firing the kill and hoping. - if matches!(config.backend, MxcBackend::ProcessContainer) { - let (tx, rx) = oneshot::channel::<()>(); - let (done_tx, done_rx) = watch::channel(false); - entry.shutdown_tx = Some(tx); - entry.terminated_rx = Some(done_rx); - // The generic spawner (openshell-supervisor-relay) gets its - // shutdown notice over the control channel (see delete_sandbox's - // "shutdown" request) -- no file needed. Only mxc-ws-agent.rs - // (set directly as the sandbox command, not spawner-wrapped, no - // control channel) still polls a signal file for it. - if !spawner_wrapping_active && !sandbox_config.cwd.is_empty() { - entry.signal_file = - Some(PathBuf::from(&sandbox_config.cwd).join("openshell-shutdown.signal")); - } - Some((rx, done_tx)) - } else { - None - } - }; - - // 6. Monitor exec completion in background. - let registry2 = registry.clone(); - let watch_tx2 = watch_tx.clone(); - let sandbox2 = sandbox.clone(); - let sandbox_id2 = sandbox_id.clone(); - tokio::spawn(async move { - monitor_exec( - registry2, - watch_tx2, - attribution, - sandbox2, - sandbox_id2, - shutdown_rx, - ) - .await; - }); - - // A cancellable handle now exists in the registry (exec_child, plus - // shutdown_tx/terminated_rx for ProcessContainer) -- stop_sandbox/ - // delete_sandbox arriving from here on can act immediately instead of - // waiting out the target-readiness wait below. - drop(startup_guard); - - // When spawner wrapping is active, openshell-supervisor-relay.rs hasn't - // spawned the real target yet -- it waits for a "launch" request over - // the control channel instead of reading agent-cmd.txt/agent-env.txt - // from files in the workload directory (see its module docs). Wait for its startup-ready - // event, then send the real command/env directly; this keeps them off - // disk (they can carry secrets, e.g. OPENCLAW_GATEWAY_TOKEN) and also - // proves the correlated request/response path works end to end -- the - // old unconditional "ping" this replaces only logged a warning on - // failure, but failure here is fatal: nothing was ever spawned. - if let (Some(channel), Some(ready_rx), Some(target_ready_rx)) = - (control_channel.clone(), ready_rx, target_ready_rx) - { - // Generous timeout: this fires right after spawn, so it's racing UAC - // elevation + AppContainer creation (observed up to several - // seconds), not just the control channel itself. The `forward` path - // won't have this constraint -- it only runs once the sandbox is - // already Ready, long past this window. - let ready_timeout = std::time::Duration::from_mins(2); - let ready_err = match tokio::time::timeout(ready_timeout, ready_rx).await { - Ok(Ok(Ok(()))) => None, - // Protocol version mismatch (see try_route_ready) -- an - // independently staged, out-of-sync relay binary. Reject fast - // and clearly instead of proceeding into a "launch" handshake - // it may not understand. - Ok(Ok(Err(version_err))) => Some(version_err), - Ok(Err(_)) => Some("spawner exited before sending its ready event".to_string()), - Err(_) => Some(format!( - "timed out after {ready_timeout:?} waiting for spawner ready event" - )), - }; - let launch_err = if let Some(e) = ready_err { - Some(e) - } else { - let launch_data = serde_json::json!({ - "command": sandbox_config.command, - "env": env, - }); - match channel - .request("launch", launch_data, std::time::Duration::from_mins(2)) - .await - { - Ok(resp) if resp.get("ok").and_then(serde_json::Value::as_bool) == Some(true) => { - info!(sandbox = %sandbox_name, "control-channel launch acknowledged"); - // The "launch" response above only confirms the - // command/env reached the spawner -- it still needs - // to spawn the target and confirm its configured - // port is accepting connections - // (openshell-supervisor-relay's own - // wait_for_port_ready, up to ~300s worst case across - // its own retries). Await that distinct - // "target_ready" event before treating launch as - // successful, so a caller acting on Ready=True below - // can never race a target that hasn't bound its port - // yet. - let target_ready_timeout = std::time::Duration::from_secs(310); - match tokio::time::timeout(target_ready_timeout, target_ready_rx).await { - Ok(Ok(Ok(()))) => { - info!(sandbox = %sandbox_name, "control-channel target ready"); - None - } - // No version gate on this event, so this arm - // never actually fires today -- see - // try_route_target_ready -- but match it - // explicitly rather than unreachable!(), in case - // that ever changes. - Ok(Ok(Err(target_err))) => Some(target_err), - Ok(Err(_)) => { - Some("spawner exited before its target became ready".to_string()) - } - Err(_) => Some(format!( - "timed out after {target_ready_timeout:?} waiting for target to become ready" - )), - } - } - Ok(resp) => Some( - resp.get("error") - .and_then(|v| v.as_str()) - .unwrap_or("launch rejected") - .to_string(), - ), - Err(e) => Some(e.to_string()), - } - }; - if let Some(err) = launch_err { - warn!(sandbox = %sandbox_name, "control-channel launch failed: {err}"); - set_failed(®istry, &watch_tx, &sandbox, &sandbox_id, &err).await; - // `child` was already moved into the registry (and possibly - // already claimed by monitor_exec, spawned above) once a - // cancellable handle was published. ProcessContainer has - // shutdown_tx/terminated_rx for exactly this: signal it and - // await confirmed termination, the same way stop_sandbox/ - // delete_sandbox would. Otherwise fall back to reclaiming - // exec_child directly and killing it (a monitor_exec race that - // hasn't claimed exec_child yet -- rare, but possible), or, for - // isolation_session (which has neither shutdown_tx/terminated_rx - // nor -- by this point -- a leftover exec_child, since - // monitor_exec almost always already claimed it), an explicit - // invoker.stop() on the isolation session. - let (shutdown_tx, terminated_rx, leftover_child, iso_id, isolation_stopped) = { - let mut reg = registry.lock().await; - match reg.get_mut(&sandbox_id) { - Some(entry) => ( - entry.shutdown_tx.take(), - // .clone(), not .take(): see stop_sandbox's matching - // comment on terminated_rx. - entry.terminated_rx.clone(), - entry.exec_child.take(), - entry.iso_sandbox_id.clone(), - entry.isolation_stopped, - ), - None => (None, None, None, None, false), - } - }; - if let Some(tx) = shutdown_tx { - let _ = tx.send(()); - } - if let Some(mut child) = leftover_child { - // We raced monitor_exec for exec_child and this branch's - // earlier `entry.exec_child.take()` won: monitor_exec will - // find the registry slot already empty and return before - // ever reaching its `done_tx.send(true)` (MR !98 review - // thread). Waiting on terminated_rx here would therefore - // wait out the full timeout for a signal that never comes, - // while `child` -- which does not kill-on-drop -- leaks. - // Kill and reap it directly instead. - let _ = child.kill().await; - let _ = child.wait().await; - } else if let Some(mut rx) = terminated_rx { - // leftover_child was None, so monitor_exec already claimed - // exec_child and is the one racing shutdown_tx against - // child.wait(); await its confirmed termination. - if !wait_for_termination(&mut rx).await { - warn!(sandbox = %sandbox_name, "timed out waiting for ProcessContainer termination after launch failure"); - } - } else if let Some(iso_id) = iso_id { - // isolation_session: no shutdown_tx/terminated_rx (those are - // ProcessContainer-only -- see the wiring site above) and no - // leftover_child either at this point, so without this the - // MXC session (and the wxc-exec `exec` child monitor_exec's - // own child.wait() is blocked on) would keep running - // indefinitely after a launch/target-ready failure here - // (MR !98 review thread). Best-effort: log rather than - // propagate, since this cleanup runs inside an - // already-failing path. - if !isolation_stopped { - if let Err(error) = invoker.stop(&iso_id).await { - warn!(sandbox = %sandbox_name, %error, "failed to stop isolation session after launch failure"); - } else { - let mut reg = registry.lock().await; - if let Some(entry) = reg.get_mut(&sandbox_id) { - entry.isolation_stopped = true; - } - } - } - } - return; - } - } - - // 5. Self-report Ready=True. The cancellable handle (exec_child, and for - // ProcessContainer shutdown_tx/terminated_rx) was already published to - // the registry and monitor_exec already spawned, above -- this only - // updates the sandbox's condition now that the target is confirmed - // reachable. - let ready_sandbox = make_sandbox_with_condition( - &sandbox, - &DriverCondition { - r#type: "Ready".into(), - status: "True".into(), - reason: "AgentRunning".into(), - message: format!("Agent exec launched: {command_line}"), - transition_time: None, - }, - false, - ); - { - let mut reg = registry.lock().await; - let Some(entry) = reg.get_mut(&sandbox_id) else { - return; - }; - // Only Starting -> Running is valid here. This publish runs after - // run_lifecycle released its startup gate (see drop(startup_guard) - // above) specifically so a stop/delete arriving during the - // target-readiness wait isn't blocked behind it -- but that means a - // stop/delete may have already moved this sandbox past Starting by - // the time this runs. Overwriting that final state with a stale - // Running, or emitting a Ready event for an already-Stopped - // sandbox, would be wrong (MR !98 review thread; same reasoning as - // set_failed's matching guard). - if entry.phase_state != PhaseState::Starting { - return; - } - entry.sandbox = ready_sandbox.clone(); - entry.phase_state = PhaseState::Running; - } - let _ = watch_tx.send(sandbox_event(ready_sandbox)); } -async fn wait_for_termination(rx: &mut watch::Receiver) -> bool { - matches!( - tokio::time::timeout( - std::time::Duration::from_secs(10), - rx.wait_for(|done| *done) - ) - .await, - Ok(Ok(_)) - ) +fn encode_windows_command_line(args: &[String]) -> String { + args.iter() + .map(|argument| quote_windows_argument(argument)) + .collect::>() + .join(" ") } -async fn monitor_exec( - registry: Arc>>, - watch_tx: Arc>, - attribution: Arc>, - sandbox: DriverSandbox, - sandbox_id: String, - shutdown: Option<(oneshot::Receiver<()>, watch::Sender)>, -) { - let child = { - let mut reg = registry.lock().await; - reg.get_mut(&sandbox_id).and_then(|e| e.exec_child.take()) - }; - let Some(mut child) = child else { - return; - }; - let wxc_pid = child.id(); - - // For ProcessContainer sandboxes a stop/delete can arrive while the - // agent is still running. Race child exit against the kill signal so - // that the wxc-exec process — and therefore the entire AppContainer - // (including any long-lived servers bound to ports) — is terminated - // promptly. `done_tx`, when present, is signaled once the process has - // genuinely exited (natural exit or the forced kill below) so - // stop_sandbox/delete_sandbox can await *confirmed* termination instead - // of firing the kill signal and immediately reporting success. - let (wait_result, done_tx) = if let Some((rx, done_tx)) = shutdown { - tokio::select! { - res = child.wait() => (res, Some(done_tx)), - _ = rx => { - if let Err(error) = child.kill().await { - warn!(sandbox = %sandbox.name, %error, "failed to terminate MXC process"); - return; - } - if let Err(error) = child.wait().await { - warn!(sandbox = %sandbox.name, %error, "failed to confirm MXC process termination"); - return; - } - if let Some(pid) = wxc_pid - && let Ok(mut idx) = attribution.lock() - { - idx.retire_launch(&sandbox_id, pid); - } - info!(sandbox = %sandbox.name, "MXC ProcessContainer terminated for sandbox stop/delete"); - let _ = done_tx.send(true); - return; - } - } - } else { - (child.wait().await, None) - }; - let termination_confirmed = wait_result.is_ok(); - if termination_confirmed { - if let Some(pid) = wxc_pid - && let Ok(mut idx) = attribution.lock() - { - idx.retire_launch(&sandbox_id, pid); - } +fn quote_windows_argument(argument: &str) -> String { + if !argument.is_empty() + && !argument + .chars() + .any(|character| character.is_whitespace() || character == '"') + { + return argument.to_string(); } - - match wait_result { - Ok(status) if status.success() => { - info!(sandbox = %sandbox.name, "MXC agent exec completed successfully"); - let done = make_sandbox_with_condition( - &sandbox, - &DriverCondition { - r#type: "Ready".into(), - status: "True".into(), - reason: "AgentCompleted".into(), - message: "Agent exec finished successfully (exit code 0)".into(), - transition_time: None, - }, - false, - ); - let mut registry = registry.lock().await; - if let Some(entry) = registry.get_mut(&sandbox_id) { - entry.host_proxy = None; - entry.sandbox = done.clone(); - entry.phase_state = PhaseState::Running; + let mut quoted = String::from("\""); + let mut backslashes = 0; + for character in argument.chars() { + match character { + '\\' => backslashes += 1, + '"' => { + quoted.push_str(&"\\".repeat(backslashes * 2 + 1)); + quoted.push('"'); + backslashes = 0; } - drop(registry); - let _ = watch_tx.send(sandbox_event(done)); - } - Ok(status) => { - let code = status.code().unwrap_or(-1); - warn!(sandbox = %sandbox.name, exit_code = code, "MXC agent exec exited non-zero"); - let _ = watch_tx.send(platform_event( - sandbox_id.clone(), - "AgentExecFailed", - format!("agent exited with code {code}; possible out-of-policy write"), - )); - let failed = make_sandbox_with_condition( - &sandbox, - &DriverCondition { - r#type: "Ready".into(), - status: "False".into(), - reason: "ExecFailed".into(), - message: format!("Agent exec exited {code}"), - transition_time: None, - }, - false, - ); - let mut registry = registry.lock().await; - if let Some(entry) = registry.get_mut(&sandbox_id) { - entry.host_proxy = None; - entry.sandbox = failed.clone(); - entry.phase_state = PhaseState::Failed(format!("exit code {code}")); + _ => { + quoted.push_str(&"\\".repeat(backslashes)); + backslashes = 0; + quoted.push(character); } - drop(registry); - let _ = watch_tx.send(sandbox_event(failed)); - } - Err(error) => { - warn!(sandbox = %sandbox.name, error = %error, "MXC agent exec wait error"); } } - // Publish the terminal registry state and watch event before waking a - // concurrent stop/delete waiter. Otherwise that waiter can publish - // Stopped (or remove the entry), only for this monitor to race in later - // and resurrect an AgentCompleted/ExecFailed state. - if termination_confirmed && let Some(done_tx) = done_tx { - let _ = done_tx.send(true); - } + quoted.push_str(&"\\".repeat(backslashes * 2)); + quoted.push('"'); + quoted } -async fn set_failed( - registry: &Arc>>, - watch_tx: &Arc>, - sandbox: &DriverSandbox, - sandbox_id: &str, - message: &str, -) { - warn!(sandbox = %sandbox.name, error = %message, "MXC lifecycle failed"); - let failed = make_sandbox_with_condition( - sandbox, - &DriverCondition { - r#type: "Ready".into(), - status: "False".into(), - reason: "ProvisionFailed".into(), - message: message.to_string(), - transition_time: None, - }, - false, - ); - let mut reg = registry.lock().await; - let Some(entry) = reg.get_mut(sandbox_id) else { - return; - }; - // Only Starting -> Failed is valid here. Every call site before - // run_lifecycle releases its startup gate is safe by construction - // (phase_state is provably still Starting -- stop_sandbox/delete_sandbox - // can't touch the entry until the gate opens). The one call site after - // the gate is released (the control-channel launch-failure path) is not: - // a concurrent stop/delete may have already moved this sandbox past - // Starting while this lifecycle was still waiting on target-readiness, - // and overwriting that final state with a stale Failed -- or emitting a - // Failed event for an already-Stopped sandbox -- would be wrong - // (MR !98 review thread). - if entry.phase_state != PhaseState::Starting { - return; + +fn condition(kind: &str, status: &str, reason: &str, message: &str) -> DriverCondition { + DriverCondition { + r#type: kind.to_string(), + status: status.to_string(), + reason: reason.to_string(), + message: message.to_string(), + transition_time: None, } - entry.sandbox = failed.clone(); - entry.phase_state = PhaseState::Failed(message.to_string()); - drop(reg); - let _ = watch_tx.send(sandbox_event(failed)); } -// ── Helpers ─────────────────────────────────────────────────────────────────── - fn make_sandbox_with_condition( base: &DriverSandbox, condition: &DriverCondition, @@ -2320,9 +1104,6 @@ fn make_sandbox_with_condition( spec: base.spec.clone(), status: Some(DriverSandboxStatus { sandbox_name: base.name.clone(), - instance_id: String::new(), - agent_fd: String::new(), - sandbox_fd: String::new(), conditions: vec![condition.clone()], deleting, ..Default::default() @@ -2330,1171 +1111,60 @@ fn make_sandbox_with_condition( } } -// ── Lifecycle + policy-proof tests (mock wxc-exec) ───────────────────────────── -// -// These drive the full create → provision → start → exec → self-report Ready -// flow against the in-process mock shim, proving the positive (in-policy write -// succeeds, Ready reached) and negative (out-of-policy write denied + denial -// event) paths WITHOUT the demo box. Windows-only (the crate is Windows-gated), -// run by the `windows:test:x64` mise lane. -#[cfg(test)] -mod lifecycle_tests { - use super::*; - use futures::StreamExt; - use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; - use openshell_core::proto::{ - FilesystemPolicy, MiddlewareEndpointSelector, NetworkBinary, NetworkEndpoint, - NetworkMiddlewareConfig, NetworkPolicyRule, SandboxPolicy, StaticCredentialBinding, - StaticCredentialEndpointBinding, UiClipboardAccess, UiPolicy, - }; - use std::time::Duration; - - fn driver_sandbox(id: &str) -> DriverSandbox { - driver_sandbox_with_command(id, "", vec!["cmd".into(), "/c".into(), "exit 0".into()]) - } - - #[tokio::test] - async fn termination_confirmation_rejects_closed_unconfirmed_channel() { - let (tx, mut rx) = watch::channel(false); - drop(tx); - assert!(!wait_for_termination(&mut rx).await); - } - - #[tokio::test] - async fn termination_confirmation_accepts_confirmed_exit() { - let (tx, mut rx) = watch::channel(false); - tx.send(true).unwrap(); - drop(tx); - assert!(wait_for_termination(&mut rx).await); +fn sandbox_event(sandbox: DriverSandbox) -> WatchSandboxesEvent { + WatchSandboxesEvent { + payload: Some(watch_sandboxes_event::Payload::Sandbox( + WatchSandboxesSandboxEvent { + sandbox: Some(sandbox), + }, + )), } +} - #[test] - fn ui_policy_capability_tracks_configured_backend() { - let process_container = MxcComputeBackend::new_mocked(MxcComputeConfig::default()); - assert!(process_container.capabilities().supports_ui_policy); - - let isolation_session = MxcComputeBackend::new_mocked(MxcComputeConfig { - backend: MxcBackend::IsolationSession, - ..Default::default() - }); - assert!(!isolation_session.capabilities().supports_ui_policy); +fn deleted_event(sandbox_id: String) -> WatchSandboxesEvent { + WatchSandboxesEvent { + payload: Some(watch_sandboxes_event::Payload::Deleted( + WatchSandboxesDeletedEvent { sandbox_id }, + )), } +} - fn driver_sandbox_with_command(id: &str, cwd: &str, command: Vec) -> DriverSandbox { - let serde_json::Value::Object(driver_config) = serde_json::json!({ - "command": command, - "cwd": cwd, - }) else { - unreachable!(); - }; - DriverSandbox { - id: id.to_string(), - name: id.to_string(), - namespace: String::new(), - workspace: String::new(), - spec: Some(DriverSandboxSpec { - sandbox_token: "test-token".into(), - template: Some(DriverSandboxTemplate { - driver_config: Some( - openshell_core::proto_struct::json_object_to_struct(driver_config).unwrap(), - ), +fn platform_event(sandbox_id: String, reason: &str, message: String) -> WatchSandboxesEvent { + WatchSandboxesEvent { + payload: Some(watch_sandboxes_event::Payload::PlatformEvent( + WatchSandboxesPlatformEvent { + sandbox_id, + event: Some(DriverPlatformEvent { + source: "mxc-driver".into(), + r#type: "Warning".into(), + reason: reason.to_string(), + message, + metadata: HashMap::new(), ..Default::default() }), - ..Default::default() - }), - status: None, - } - } - fn fs_policy(read_write: &[&str]) -> SandboxPolicy { - SandboxPolicy { - filesystem: Some(FilesystemPolicy { - include_workdir: false, - read_only: Vec::new(), - read_write: read_write.iter().map(ToString::to_string).collect(), - }), - ..Default::default() - } - } - - fn github_provider_credentials() -> ProviderCredentialState { - ProviderCredentialState::from_bound_environment( - 42, - HashMap::from([( - "GITHUB_TOKEN".to_string(), - "raw-test-token-must-not-enter-mxc".to_string(), - )]), - HashMap::new(), - HashMap::new(), - HashMap::from([( - "GITHUB_TOKEN".to_string(), - StaticCredentialBinding { - endpoints: vec![StaticCredentialEndpointBinding { - host: "api.github.com".to_string(), - port: 443, - path: "/**".to_string(), - }], - credential_identity: "provider-github:GITHUB_TOKEN".to_string(), - workload_credential_handle: String::new(), - }, - )]), - Vec::new(), - ) - .expect("valid GitHub provider credential state") - } - - fn with_policy(mut sandbox: DriverSandbox, policy: SandboxPolicy) -> DriverSandbox { - sandbox.spec.as_mut().unwrap().policy = Some(policy); - sandbox - } - - fn ready_condition(sb: &DriverSandbox) -> Option { - sb.status - .as_ref()? - .conditions - .iter() - .find(|c| c.r#type == "Ready") - .cloned() - } - - /// Poll the backend registry until the predicate matches or the deadline hits. - async fn wait_for( - backend: &MxcComputeBackend, - name: &str, - mut pred: F, - ) -> Option - where - F: FnMut(&DriverSandbox) -> bool, - { - for _ in 0..100 { - if let Some(sandbox) = backend.get_sandbox(name).await - && pred(&sandbox) - { - return Some(sandbox); - } - tokio::time::sleep(Duration::from_millis(100)).await; - } - None - } - - #[test] - fn mxc_config_defaults_to_default_deny_process_container() { - let config = MxcComputeConfig::default(); - assert_eq!(config.backend, MxcBackend::ProcessContainer); - assert!(!config.egress_proxy); - assert!(config.egress_proxy_addr.is_empty()); - } - - #[test] - fn gateway_config_rejects_per_sandbox_workload_fields() { - for field in ["agent_command", "agent_cwd", "agent_env", "share_dir"] { - let mut config = serde_json::Map::new(); - config.insert(field.to_string(), serde_json::json!([])); - let error = serde_json::from_value::(config.into()) - .expect_err("workload fields must not be accepted in gateway config"); - assert!(error.to_string().contains(field)); - } - } - - #[test] - fn sandbox_proxy_addr_uses_ephemeral_loopback_port() { - let configured = "127.0.0.1:18080".parse().unwrap(); - let (addr, _reservation) = allocate_sandbox_proxy_addr(configured).unwrap(); - assert_eq!(addr.ip(), configured.ip()); - assert_ne!(addr.port(), 0); - } - - #[test] - fn governed_egress_rejects_non_loopback_and_isolation_session() { - let mut config = MxcComputeConfig { - egress_proxy: true, - egress_proxy_addr: "10.0.0.1:18080".into(), - ..Default::default() - }; - assert!( - configured_egress_addr(&config) - .unwrap_err() - .message() - .contains("127.0.0.1") - ); - - config.egress_proxy_addr = "127.0.0.1:18080".into(); - config.backend = MxcBackend::IsolationSession; - let error = configured_egress_addr(&config).unwrap_err(); - assert_eq!(error.code(), tonic::Code::InvalidArgument); - assert!(error.message().contains("requires process_container")); - } - - #[test] - fn provider_child_environment_overrides_agent_values_with_placeholders() { - let credentials = github_provider_credentials(); - let placeholder = credentials - .snapshot() - .child_env - .get("GITHUB_TOKEN") - .cloned() - .expect("GitHub placeholder"); - let mut env = vec![ - "github_token=agent-value".to_string(), - "UNCHANGED=value".to_string(), - ]; - - append_provider_child_env(&mut env, Some(&credentials)); - - assert!(env.contains(&"UNCHANGED=value".to_string())); - assert!(!env.iter().any(|entry| entry == "github_token=agent-value")); - assert!(env.contains(&format!("GITHUB_TOKEN={placeholder}"))); - assert!(!env.iter().any(|entry| entry.contains("raw-test-token"))); - } - - #[test] - fn provider_child_env_keys_reject_case_insensitive_collision() { - let credentials = ProviderCredentialState::from_bound_environment( - 1, - HashMap::from([ - ("github_token".to_string(), "a".to_string()), - ("GITHUB_TOKEN".to_string(), "b".to_string()), - ]), - HashMap::new(), - HashMap::new(), - HashMap::new(), - vec!["github_token".to_string(), "GITHUB_TOKEN".to_string()], - ) - .expect("valid provider credential state"); - - let error = validate_provider_child_env_keys(Some(&credentials)) - .expect_err("case-colliding provider keys must fail closed"); - assert_eq!(error.code(), tonic::Code::FailedPrecondition); - assert!(error.message().contains("collides")); - } - - #[test] - fn provider_child_env_keys_reject_tls_reserved_name() { - let credentials = ProviderCredentialState::from_bound_environment( - 1, - HashMap::from([("SSL_CERT_FILE".to_string(), "not-a-ca-bundle".to_string())]), - HashMap::new(), - HashMap::new(), - HashMap::new(), - vec!["SSL_CERT_FILE".to_string()], - ) - .expect("valid provider credential state"); - - let error = validate_provider_child_env_keys(Some(&credentials)) - .expect_err("TLS-reserved provider keys must fail closed"); - assert_eq!(error.code(), tonic::Code::FailedPrecondition); - assert!(error.message().contains("reserved for TLS")); - } - - #[test] - fn provider_child_env_keys_allow_distinct_names() { - let credentials = github_provider_credentials(); - validate_provider_child_env_keys(Some(&credentials)) - .expect("non-colliding, non-reserved provider keys are allowed"); - } - - #[tokio::test] - async fn provider_state_requiring_resolution_fails_closed_without_governed_egress() { - let backend = MxcComputeBackend::new_mocked(MxcComputeConfig::default()); - let sink = backend.provider_credentials_sink(); - sink.lock() - .expect("provider credential staging lock poisoned") - .insert( - "sb-provider-no-proxy".to_string(), - github_provider_credentials(), - ); - - let error = backend - .create_sandbox(&with_policy( - driver_sandbox("sb-provider-no-proxy"), - fs_policy(&[]), - )) - .await - .expect_err("provider credentials must require governed egress"); - - assert_eq!(error.code(), tonic::Code::FailedPrecondition); - assert!(error.message().contains("enable egress_proxy")); - assert!( - sink.lock() - .expect("provider credential staging lock poisoned") - .is_empty(), - "the driver must consume staged credential material on every create attempt" - ); - } - - #[tokio::test] - async fn governed_egress_puts_only_provider_placeholder_in_mxc_process_env() { - let mut config = MxcComputeConfig { - egress_proxy: true, - egress_proxy_addr: "127.0.0.1:18080".to_string(), - ..Default::default() - }; - config.backend = MxcBackend::ProcessContainer; - let backend = MxcComputeBackend::new_mocked(config); - let credentials = github_provider_credentials(); - let placeholder = credentials - .snapshot() - .child_env - .get("GITHUB_TOKEN") - .cloned() - .expect("GitHub placeholder"); - backend - .provider_credentials_sink() - .lock() - .expect("provider credential staging lock poisoned") - .insert("sb-provider-env".to_string(), credentials); - - let mut policy = fs_policy(&[]); - policy.network_policies.insert( - "github".to_string(), - NetworkPolicyRule { - name: "github".to_string(), - endpoints: vec![NetworkEndpoint { - host: "api.github.com".to_string(), - port: 443, - protocol: "rest".to_string(), - provider_credentialed: true, - ..Default::default() - }], - binaries: vec![NetworkBinary { - path: "cmd".to_string(), - ..Default::default() - }], }, - ); - let mut sandbox = with_policy(driver_sandbox("sb-provider-env"), policy); - sandbox - .spec - .as_mut() - .expect("sandbox spec") - .environment - .extend([ - ( - "GITHUB_TOKEN".to_string(), - "raw-agent-env-token".to_string(), - ), - ("UNCHANGED".to_string(), "value".to_string()), - ]); - - backend - .create_sandbox(&sandbox) - .await - .expect("create accepted"); - wait_for(&backend, "sb-provider-env", |_| { - crate::mxc::mock_recorded_config("sb-provider-env").is_some() - }) - .await - .expect("MXC config should be recorded"); - - let recorded = - crate::mxc::mock_recorded_config("sb-provider-env").expect("mock recorded config"); - let env = recorded["process"]["env"] - .as_array() - .expect("MXC process env") - .iter() - .filter_map(serde_json::Value::as_str) - .collect::>(); - assert!(env.contains(&"UNCHANGED=value")); - assert!(env.contains(&format!("GITHUB_TOKEN={placeholder}").as_str())); - let encoded = recorded.to_string(); - assert!(!encoded.contains("raw-agent-env-token")); - assert!(!encoded.contains("raw-test-token-must-not-enter-mxc")); - } - - #[test] - fn tls_state_replaces_user_trust_overrides_and_grants_ca_directory_once() { - let tls_dir = std::env::temp_dir().join("openshell-mxc-tls-test"); - let ca_cert = tls_dir.join("openshell-ca.pem"); - let bundle = tls_dir.join("ca-bundle.pem"); - let mut env = vec![ - "FOO=bar".to_string(), - "SSL_CERT_FILE=C:\\old\\bundle.pem".to_string(), - ]; - append_tls_env_vars(&mut env, Some(&(ca_cert.clone(), bundle.clone()))); - assert!(env.contains(&"FOO=bar".to_string())); - assert!( - !env.iter() - .any(|entry| entry == "SSL_CERT_FILE=C:\\old\\bundle.pem") - ); - assert!(env.contains(&format!("SSL_CERT_FILE={}", bundle.display()))); - - let existing = tls_dir.display().to_string().to_ascii_lowercase(); - let mut readonly = vec![existing.clone()]; - append_tls_readonly_grant(&mut readonly, Some(&(ca_cert, bundle))); - assert_eq!(readonly, vec![existing]); - } - - #[test] - fn tls_ca_files_are_staged_under_the_authorized_share() { - let source = tempfile::tempdir().unwrap(); - let share = tempfile::tempdir().unwrap(); - let ca = source.path().join("source-ca.pem"); - let bundle = source.path().join("source-bundle.pem"); - std::fs::write(&ca, b"ca").unwrap(); - std::fs::write(&bundle, b"bundle").unwrap(); - - let staged = stage_tls_ca_files( - Some(&(ca, bundle)), - share.path().to_str().expect("UTF-8 test path"), - "sandbox-a", - ) - .unwrap() - .expect("staged paths"); - - assert_eq!( - staged.0.parent().unwrap(), - share.path().join(".openshell-proxy").join("sandbox-a") - ); - assert_eq!(std::fs::read(staged.0).unwrap(), b"ca"); - assert_eq!(std::fs::read(staged.1).unwrap(), b"bundle"); - } - - #[test] - fn tls_ca_staging_keeps_sandboxes_in_the_same_share_independent() { - let source = tempfile::tempdir().unwrap(); - let share = tempfile::tempdir().unwrap(); - let paths = ( - source.path().join("ca.pem"), - source.path().join("bundle.pem"), - ); - std::fs::write(&paths.0, b"first-ca").unwrap(); - std::fs::write(&paths.1, b"first-bundle").unwrap(); - let first = stage_tls_ca_files(Some(&paths), share.path().to_str().unwrap(), "sandbox-a") - .unwrap() - .unwrap(); - std::fs::write(&paths.0, b"second-ca").unwrap(); - std::fs::write(&paths.1, b"second-bundle").unwrap(); - let second = stage_tls_ca_files(Some(&paths), share.path().to_str().unwrap(), "sandbox-b") - .unwrap() - .unwrap(); - - assert_ne!(first, second); - assert_eq!(std::fs::read(first.0).unwrap(), b"first-ca"); - assert_eq!(std::fs::read(first.1).unwrap(), b"first-bundle"); - assert_eq!(std::fs::read(second.0).unwrap(), b"second-ca"); - assert_eq!(std::fs::read(second.1).unwrap(), b"second-bundle"); - } - - #[test] - fn tls_ca_staging_rejects_empty_shares_and_unsafe_sandbox_components() { - let share = tempfile::tempdir().unwrap(); - let paths = (PathBuf::from("unused-ca"), PathBuf::from("unused-bundle")); - for empty in ["", " ", "\t"] { - assert_eq!( - stage_tls_ca_files(Some(&paths), empty, "sandbox-a") - .unwrap_err() - .kind(), - std::io::ErrorKind::InvalidInput - ); - } - for id in [ - "", - ".", - "..", - "../outside", - "..\\outside", - "C:\\outside", - "file:stream", - ] { - assert_eq!( - stage_tls_ca_files(Some(&paths), share.path().to_str().unwrap(), id) - .unwrap_err() - .kind(), - std::io::ErrorKind::InvalidInput - ); - } - assert_eq!(std::fs::read_dir(share.path()).unwrap().count(), 0); - assert_eq!(stage_tls_ca_files(None, "", "").unwrap(), None); - } - - #[test] - fn proxy_env_replaces_inherited_values_and_clears_bypass_rules() { - let mut env = vec![ - "PATH=C:\\Windows".to_owned(), - "HTTP_PROXY=http://stale.invalid:1".to_owned(), - "https_proxy=http://stale.invalid:2".to_owned(), - "NO_PROXY=example.com".to_owned(), - "no_proxy=example.org".to_owned(), - ]; - - let proxy_auth = SandboxProxyAuth { - password: "sandbox-secret".to_owned(), - }; - append_proxy_env_vars( - &mut env, - Some("127.0.0.1:18080".parse().unwrap()), - Some(&proxy_auth), - ); - - assert!(env.contains(&"PATH=C:\\Windows".to_owned())); - for key in PROXY_ENV_KEYS { - assert_eq!( - env.iter() - .filter(|entry| entry.starts_with(&format!("{key}="))) - .count(), - 1, - "{key} must be emitted exactly once" - ); - } - assert!( - env.contains(&"HTTP_PROXY=http://openshell:sandbox-secret@127.0.0.1:18080".to_owned()) - ); - assert!( - env.contains(&"HTTPS_PROXY=http://openshell:sandbox-secret@127.0.0.1:18080".to_owned()) - ); - assert!(env.contains(&"NO_PROXY=".to_owned())); - assert!(env.contains(&"no_proxy=".to_owned())); - } - - #[test] - fn sandbox_environment_uses_sandbox_scope_with_spec_precedence() { - let mut sandbox = driver_sandbox("sb-env"); - let spec = sandbox.spec.as_mut().unwrap(); - spec.template - .as_mut() - .unwrap() - .environment - .insert("SHARED".into(), "template".into()); - spec.environment.insert("SHARED".into(), "spec".into()); - spec.environment.insert("TOKEN".into(), "value".into()); - let environment = sandbox_environment(&sandbox); - assert!(environment.contains(&"SHARED=spec".to_string())); - assert!(environment.contains(&"TOKEN=value".to_string())); - assert!(environment.iter().all(|entry| { - let key = entry.split_once('=').map_or(entry.as_str(), |(key, _)| key); - key == "SHARED" || key == "TOKEN" - })); + )), } +} - #[test] - fn tls_env_vars_replace_user_trust_overrides() { - let tls_dir = std::env::temp_dir().join("openshell-mxc-tls-test"); - let ca_cert = tls_dir.join("openshell-ca.pem"); - let bundle = tls_dir.join("ca-bundle.pem"); - let ca_cert_path = ca_cert.display().to_string(); - let bundle_path = bundle.display().to_string(); - let mut env = vec![ - "FOO=bar".to_string(), - "SSL_CERT_FILE=C:\\old\\bundle.pem".to_string(), - "node_extra_ca_certs=C:\\old\\ca.pem".to_string(), - ]; - - append_tls_env_vars(&mut env, Some(&(ca_cert, bundle))); - - assert!(env.contains(&"FOO=bar".to_string())); - assert!( - !env.iter() - .any(|entry| entry == "SSL_CERT_FILE=C:\\old\\bundle.pem") - ); - assert!( - !env.iter() - .any(|entry| entry == "node_extra_ca_certs=C:\\old\\ca.pem") - ); - assert!(env.contains(&format!("NODE_EXTRA_CA_CERTS={ca_cert_path}"))); - assert!(env.contains(&format!("DENO_CERT={ca_cert_path}"))); - assert!(env.contains(&format!("SSL_CERT_FILE={bundle_path}"))); - assert!(env.contains(&format!("REQUESTS_CA_BUNDLE={bundle_path}"))); - assert!(env.contains(&format!("CURL_CA_BUNDLE={bundle_path}"))); - assert!(env.contains(&format!("GIT_SSL_CAINFO={bundle_path}"))); - } +#[cfg(test)] +mod tests { + use super::*; #[test] - fn tls_readwrite_grant_adds_ca_directory_once() { - let tls_dir = std::env::temp_dir().join("openshell-mxc-tls-test"); - let ca_cert = tls_dir.join("openshell-ca.pem"); - let bundle = tls_dir.join("ca-bundle.pem"); - let existing = tls_dir.display().to_string().to_ascii_lowercase(); - let mut readwrite = vec![existing.clone()]; - - append_tls_readwrite_grant(&mut readwrite, Some(&(ca_cert, bundle))); - - assert_eq!(readwrite, vec![existing]); + fn capabilities_delegate_readiness_to_supervisor() { + let backend = MxcComputeBackend::new(MxcComputeConfig::default()); + assert!(!backend.capabilities().driver_reports_runtime_readiness); } #[test] - fn windows_command_line_preserves_argument_boundaries() { + fn command_line_quotes_spaces() { assert_eq!( encode_windows_command_line(&[ - r"C:\Program Files\Agent\agent.exe".into(), - "hello world".into(), - String::new(), + "C:\\Program Files\\OpenShell\\openshell-sandbox.exe".to_string(), + "--bootstrap".to_string(), ]), - r#""C:\Program Files\Agent\agent.exe" "hello world" """# - ); - assert_eq!( - quote_windows_argument(r#"say "hello""#), - r#""say \"hello\"""# - ); - assert_eq!( - quote_windows_argument("trailing slash\\ "), - r#""trailing slash\ ""# - ); - } - #[tokio::test] - async fn positive_in_policy_write_reaches_ready_and_materializes_file() { - let tmp = tempfile::tempdir().unwrap(); - let share = tmp.path().to_string_lossy().replace('\\', "/"); - let hello = format!("{share}/hello.txt"); - let cmd = vec![ - "powershell".into(), - "-NoProfile".into(), - "-Command".into(), - format!("Set-Content -LiteralPath {hello} -Value hi"), - ]; - let backend = MxcComputeBackend::new_mocked(MxcComputeConfig::default()); - - let policy = fs_policy(&[&share]); - let sb = with_policy(driver_sandbox_with_command("sb-pos", &share, cmd), policy); - backend.create_sandbox(&sb).await.expect("create accepted"); - - // Self-reported Ready=True (no supervisor) once the agent exec launches. - let ready = wait_for(&backend, "sb-pos", |s| { - ready_condition(s).is_some_and(|c| c.status == "True" && c.reason == "AgentRunning") - }) - .await; - assert!(ready.is_some(), "sandbox should self-report Ready=True"); - - // Positive proof: the in-policy write materializes the host artifact. - let host_path = tmp.path().join("hello.txt"); - let mut found = false; - for _ in 0..100 { - if host_path.exists() { - found = true; - break; - } - tokio::time::sleep(Duration::from_millis(100)).await; - } - assert!(found, "hello.txt should appear in the granted share folder"); - - // A successful one-shot agent (exit 0) must STAY Ready, not demote to - // Error. Assert the terminal condition is Ready=True/AgentCompleted so the - // positive demo shows a green Ready phase, not a red Error. - let completed = wait_for(&backend, "sb-pos", |s| { - ready_condition(s).is_some_and(|c| c.status == "True" && c.reason == "AgentCompleted") - }) - .await; - assert!( - completed.is_some(), - "sandbox should remain Ready=True (AgentCompleted) after a successful exec, never demote to Error" - ); - assert!( - !backend - .attribution - .lock() - .unwrap() - .has_live_pid_for_sandbox("sb-pos"), - "the process monitor must retire the wxc-exec PID before publishing completion" - ); - } - - #[tokio::test] - async fn processcontainer_one_shot_in_policy_write_reaches_ready() { - // The processContainer backend skips provision/start and runs a single - // one-shot. The mock routes through `run_oneshot`, deriving grants from - // the filesystem (not a provision step), so the in-policy write should - // materialize and the sandbox should reach Ready=True. - let tmp = tempfile::tempdir().unwrap(); - let share = tmp.path().to_string_lossy().replace('\\', "/"); - let hello = format!("{share}/hello.txt"); - let cmd = vec![ - "powershell".into(), - "-NoProfile".into(), - "-Command".into(), - format!("Set-Content -LiteralPath {hello} -Value hi"), - ]; - let backend = MxcComputeBackend::new_mocked(MxcComputeConfig::default()); - - let policy = fs_policy(&[&share]); - let sb = with_policy(driver_sandbox_with_command("sb-pc", &share, cmd), policy); - backend.create_sandbox(&sb).await.expect("create accepted"); - - let ready = wait_for(&backend, "sb-pc", |s| { - ready_condition(s).is_some_and(|c| c.status == "True" && c.reason == "AgentRunning") - }) - .await; - assert!( - ready.is_some(), - "processContainer sandbox should self-report Ready=True" - ); - let recorded = crate::mxc::mock_recorded_config("sb-pc").expect("mock recorded config"); - assert!( - recorded.get("network").is_none(), - "coarse path must not emit an MXC network block" - ); - assert_eq!(recorded["ui"]["disable"], true); - assert_eq!(recorded["ui"]["clipboard"], "none"); - assert_eq!(recorded["ui"]["injection"], false); - - let host_path = tmp.path().join("hello.txt"); - let mut found = false; - for _ in 0..100 { - if host_path.exists() { - found = true; - break; - } - tokio::time::sleep(Duration::from_millis(100)).await; - } - assert!( - found, - "in-policy write should materialize under processContainer" + "\"C:\\Program Files\\OpenShell\\openshell-sandbox.exe\" --bootstrap" ); } - - #[tokio::test] - async fn split_path_provisions_with_proxy_redirect() { - use openshell_core::proto::{NetworkBinary, NetworkEndpoint, NetworkPolicyRule}; - - let tmp = tempfile::tempdir().unwrap(); - let share = tmp.path().to_string_lossy().replace('\\', "/"); - let hello = format!("{share}/hello.txt"); - let cmd = vec![ - "powershell".into(), - "-NoProfile".into(), - "-Command".into(), - format!("Set-Content -LiteralPath {hello} -Value hi"), - ]; - let config = MxcComputeConfig { - backend: MxcBackend::ProcessContainer, - egress_proxy: true, - egress_proxy_addr: "127.0.0.1:18080".into(), - ..Default::default() - }; - let backend = MxcComputeBackend::new_mocked(config); - let mut stream = backend.watch_sandboxes().await; - - let mut policy = fs_policy(&[&share]); - policy.network_policies.insert( - "api".into(), - NetworkPolicyRule { - name: "api".into(), - endpoints: vec![NetworkEndpoint { - host: "example.com".into(), - ports: vec![443], - protocol: "rest".into(), - ..Default::default() - }], - binaries: vec![NetworkBinary { - path: "/usr/bin/curl".into(), - }], - }, - ); - let sandbox = with_policy( - driver_sandbox_with_command("sb-egress", &share, cmd), - policy.clone(), - ); - - backend - .create_sandbox(&sandbox) - .await - .expect("create accepted"); - - let ready = wait_for(&backend, "sb-egress", |s| { - ready_condition(s).is_some_and(|c| c.status == "True" && c.reason == "AgentRunning") - }) - .await; - assert!( - ready.is_some(), - "egress split sandbox should reach Ready=True" - ); - - let recorded = crate::mxc::mock_recorded_config("sb-egress").expect("mock recorded config"); - assert_eq!(recorded["version"], "0.8.0-alpha"); - assert_eq!(recorded["network"]["egress"]["default"], "deny"); - assert_eq!( - recorded["network"]["egress"]["allow"], - serde_json::json!([{"to": [{"cidr": "127.0.0.1/32"}]}]) - ); - assert!(recorded["network"].get("allowedHosts").is_none()); - assert!(recorded["network"].get("blockedHosts").is_none()); - assert!(recorded["network"].get("proxy").is_none()); - assert!(recorded.get("networkProxy").is_none()); - - let reg = backend.registry.lock().await; - let entry = reg.get("sb-egress").expect("registry entry"); - let entry_proxy_addr = entry.proxy_addr.expect("proxy addr"); - assert_eq!( - entry_proxy_addr.ip(), - std::net::IpAddr::from([127, 0, 0, 1]) - ); - assert_ne!(entry_proxy_addr.port(), 0); - let child_env = recorded["process"]["env"].as_array().expect("child env"); - let proxy_env = child_env - .iter() - .filter_map(serde_json::Value::as_str) - .find_map(|entry| entry.strip_prefix("HTTP_PROXY=")) - .expect("HTTP_PROXY must direct clients to the authenticated proxy"); - let (credentials, address) = proxy_env - .strip_prefix("http://openshell:") - .and_then(|value| value.split_once('@')) - .expect("proxy URL must contain the per-sandbox credential"); - assert!(!credentials.is_empty()); - assert_eq!(address, entry_proxy_addr.to_string()); - assert_eq!( - entry.trimmed_policy.as_ref().unwrap().network_policies, - policy.network_policies - ); - drop(reg); - - let mut saw_redirect = false; - let deadline = tokio::time::Instant::now() + Duration::from_secs(15); - while tokio::time::Instant::now() < deadline { - match tokio::time::timeout(Duration::from_millis(500), stream.next()).await { - Ok(Some(Ok(ev))) => { - if let Some(watch_sandboxes_event::Payload::PlatformEvent(pe)) = ev.payload - && pe - .event - .as_ref() - .is_some_and(|e| e.reason == "EgressRedirect") - { - saw_redirect = true; - break; - } - } - Ok(_) => break, - Err(_) => {} - } - } - assert!(saw_redirect, "expected EgressRedirect platform event"); - } - - #[tokio::test] - async fn processcontainer_live_config_carries_explicit_ui_policy() { - let backend = MxcComputeBackend::new_mocked(MxcComputeConfig::default()); - let mut policy = fs_policy(&[]); - policy.ui = Some(UiPolicy { - allow_graphical_ui: true, - clipboard: UiClipboardAccess::All as i32, - allow_input_injection: true, - }); - let sandbox = with_policy(driver_sandbox("sb-pc-ui"), policy); - backend - .create_sandbox(&sandbox) - .await - .expect("create accepted"); - let _ = wait_for(&backend, "sb-pc-ui", |_| { - crate::mxc::mock_recorded_config("sb-pc-ui").is_some() - }) - .await; - let recorded = crate::mxc::mock_recorded_config("sb-pc-ui") - .expect("mock recorded processContainer config"); - assert_eq!(recorded["ui"]["disable"], false); - assert_eq!(recorded["ui"]["clipboard"], "all"); - assert_eq!(recorded["ui"]["injection"], true); - } - - #[tokio::test] - async fn isolation_session_rejects_ui_before_lifecycle_side_effects() { - let backend = MxcComputeBackend::new_mocked(MxcComputeConfig { - backend: MxcBackend::IsolationSession, - ..Default::default() - }); - let policy = SandboxPolicy { - ui: Some(UiPolicy::default()), - ..Default::default() - }; - let sandbox = with_policy(driver_sandbox("sb-iso-ui"), policy); - let error = backend - .create_sandbox(&sandbox) - .await - .expect_err("isolation UI must be rejected synchronously"); - assert_eq!(error.code(), tonic::Code::InvalidArgument); - assert!(error.message().contains("ui")); - assert!(backend.list_sandboxes().await.is_empty()); - assert!(crate::mxc::mock_recorded_config("sb-iso-ui").is_none()); - } - - #[tokio::test] - async fn negative_out_of_policy_write_is_denied_with_event() { - let share_tmp = tempfile::tempdir().unwrap(); - let out_tmp = tempfile::tempdir().unwrap(); - let share = share_tmp.path().to_string_lossy().replace('\\', "/"); - let out_path = format!( - "{}/hello.txt", - out_tmp.path().to_string_lossy().replace('\\', "/") - ); - let cmd = vec![ - "powershell".into(), - "-NoProfile".into(), - "-Command".into(), - format!("Set-Content -LiteralPath {out_path} -Value hi"), - ]; - let backend = MxcComputeBackend::new_mocked(MxcComputeConfig::default()); - - // Subscribe to the watch stream BEFORE create so we catch the denial event. - let mut stream = backend.watch_sandboxes().await; - - let policy = fs_policy(&[&share]); - let sandbox = with_policy(driver_sandbox_with_command("sb-neg", &share, cmd), policy); - backend - .create_sandbox(&sandbox) - .await - .expect("create accepted"); - - // Collect events until we observe the AgentExecFailed platform event. - let mut saw_denial = false; - let deadline = tokio::time::Instant::now() + Duration::from_secs(15); - while tokio::time::Instant::now() < deadline { - match tokio::time::timeout(Duration::from_millis(500), stream.next()).await { - Ok(Some(Ok(ev))) => { - if let Some(watch_sandboxes_event::Payload::PlatformEvent(event)) = ev.payload - && event - .event - .as_ref() - .is_some_and(|event| event.reason == "AgentExecFailed") - { - saw_denial = true; - break; - } - } - Ok(_) => break, - Err(_) => {} - } - } - assert!( - saw_denial, - "expected an AgentExecFailed denial platform event" - ); - - // The out-of-policy artifact must NOT have been written by the mock. - let out_fs = out_tmp.path().join("hello.txt"); - assert!(!out_fs.exists(), "out-of-policy write must be denied"); - - // And the sandbox surfaces a terminal ExecFailed Ready=False condition. - let failed = wait_for(&backend, "sb-neg", |s| { - ready_condition(s).is_some_and(|c| c.status == "False" && c.reason == "ExecFailed") - }) - .await; - assert!(failed.is_some(), "sandbox should report ExecFailed"); - } - - #[tokio::test] - async fn stop_terminates_and_reaps_a_running_process_container() { - let tmp = tempfile::tempdir().unwrap(); - let share = tmp.path().to_string_lossy().replace('\\', "/"); - let command = vec![ - "powershell".into(), - "-NoProfile".into(), - "-Command".into(), - format!("$null = '{share}'; Start-Sleep -Seconds 60"), - ]; - let backend = MxcComputeBackend::new_mocked(MxcComputeConfig::default()); - let policy = fs_policy(&[&share]); - let sandbox = with_policy(driver_sandbox_with_command("sb-stop", "", command), policy); - backend - .create_sandbox(&sandbox) - .await - .expect("create accepted"); - wait_for(&backend, "sb-stop", |sandbox| { - ready_condition(sandbox).is_some_and(|condition| condition.reason == "AgentRunning") - }) - .await - .expect("long-running child should start"); - tokio::time::sleep(Duration::from_millis(250)).await; - let running = backend.get_sandbox("sb-stop").await.unwrap(); - assert_eq!(ready_condition(&running).unwrap().reason, "AgentRunning"); - - tokio::time::timeout(Duration::from_secs(5), backend.stop_sandbox("sb-stop")) - .await - .expect("stop should not wait for the child sleep") - .expect("stop should terminate and reap the child"); - let stopped = backend.get_sandbox("sb-stop").await.unwrap(); - assert_eq!(ready_condition(&stopped).unwrap().reason, "Stopped"); - } - - /// `delete_sandbox` must await *confirmed* `ProcessContainer` termination - /// before removing the registry entry and reporting success -- not just - /// fire the kill signal and report success regardless (which could - /// leave the process retaining ports and file locks past a successful - /// delete). Bounded well under the child's own sleep duration: if - /// delete stopped awaiting `terminated_rx`, this would still return - /// quickly (the bug was reporting success *too early*, not hanging), so - /// the meaningful assertion is that the sandbox is confirmed gone from - /// the registry immediately after -- a second delete finds nothing. - #[tokio::test] - async fn delete_terminates_and_reaps_a_running_process_container() { - let tmp = tempfile::tempdir().unwrap(); - let share = tmp.path().to_string_lossy().replace('\\', "/"); - let command = vec![ - "powershell".into(), - "-NoProfile".into(), - "-Command".into(), - format!("$null = '{share}'; Start-Sleep -Seconds 60"), - ]; - let backend = MxcComputeBackend::new_mocked(MxcComputeConfig::default()); - let policy = fs_policy(&[&share]); - let sandbox = with_policy( - driver_sandbox_with_command("sb-delete", "", command), - policy, - ); - backend - .create_sandbox(&sandbox) - .await - .expect("create accepted"); - wait_for(&backend, "sb-delete", |sandbox| { - ready_condition(sandbox).is_some_and(|condition| condition.reason == "AgentRunning") - }) - .await - .expect("long-running child should start"); - tokio::time::sleep(Duration::from_millis(250)).await; - - let deleted = tokio::time::timeout( - Duration::from_secs(5), - backend.delete_sandbox(&sandbox.id, "sb-delete"), - ) - .await - .expect("delete should not wait for the child sleep") - .expect("delete should terminate and reap the child"); - assert!(deleted, "delete should report the sandbox as removed"); - assert!( - backend.get_sandbox("sb-delete").await.is_none(), - "sandbox should be gone from the registry after delete" - ); - } - - /// `delete_sandbox` must confirm genuine termination via `terminated_rx` - /// even when `shutdown_tx` was already consumed by an earlier attempt (a - /// timed-out delete/stop, or -- as constructed directly here -- any - /// other caller that got to the field first). `terminated_rx` must - /// therefore be `.clone()`d, not `.take()`n, from the registry entry: - /// taking it would make this call's own None-shutdown_tx branch skip - /// the wait entirely and report success (and remove the registry entry) - /// before termination was ever confirmed -- exactly the MR !98 review - /// thread this regression-tests ("delete timeout loses termination - /// state"). - #[tokio::test] - async fn delete_confirms_termination_even_when_shutdown_tx_already_taken() { - let tmp = tempfile::tempdir().unwrap(); - let share = tmp.path().to_string_lossy().replace('\\', "/"); - let command = vec![ - "powershell".into(), - "-NoProfile".into(), - "-Command".into(), - format!("$null = '{share}'; Start-Sleep -Seconds 60"), - ]; - let backend = MxcComputeBackend::new_mocked(MxcComputeConfig::default()); - let policy = fs_policy(&[&share]); - let sandbox = with_policy( - driver_sandbox_with_command("sb-delete-retry", "", command), - policy, - ); - backend - .create_sandbox(&sandbox) - .await - .expect("create accepted"); - wait_for(&backend, "sb-delete-retry", |sandbox| { - ready_condition(sandbox).is_some_and(|condition| condition.reason == "AgentRunning") - }) - .await - .expect("long-running child should start"); - tokio::time::sleep(Duration::from_millis(250)).await; - - // Simulate an earlier delete/stop attempt that already consumed and - // fired shutdown_tx (e.g. one that then timed out before - // confirming termination) -- take a fresh watch subscription first - // so this test can observe the SAME completion delete_sandbox - // itself must wait for. - let terminated = { - let registry = backend.registry.lock().await; - registry - .get(&sandbox.id) - .expect("sandbox should be registered") - .terminated_rx - .clone() - .expect("ProcessContainer entry should have terminated_rx wired") - }; - { - let mut registry = backend.registry.lock().await; - let entry = registry - .get_mut(&sandbox.id) - .expect("sandbox should be registered"); - let tx = entry - .shutdown_tx - .take() - .expect("ProcessContainer entry should have shutdown_tx wired"); - let _ = tx.send(()); - } - - let deleted = tokio::time::timeout( - Duration::from_secs(5), - backend.delete_sandbox(&sandbox.id, "sb-delete-retry"), - ) - .await - .expect("delete should still confirm termination within the timeout, not hang") - .expect("delete should succeed even with shutdown_tx already taken"); - assert!(deleted, "delete should report the sandbox as removed"); - - // The regression this guards against: delete_sandbox used to - // .take() terminated_rx too, so finding shutdown_tx already None - // made it skip waiting entirely and return success immediately -- - // well before the real OS process had actually been killed and - // reaped. If that bug were back, this watch value could still be - // false right here. - assert!( - *terminated.borrow(), - "delete_sandbox must not report success before terminated_rx confirms the process died" - ); - assert!( - backend.get_sandbox("sb-delete-retry").await.is_none(), - "sandbox should be gone from the registry after delete" - ); - } - - #[tokio::test] - async fn unmappable_network_policy_fails_create_lifecycle() { - use openshell_core::proto::{NetworkEndpoint, NetworkPolicyRule}; - let tmp = tempfile::tempdir().unwrap(); - let share = tmp.path().to_string_lossy().replace('\\', "/"); - - let backend = MxcComputeBackend::new_mocked(MxcComputeConfig::default()); - - let mut policy = fs_policy(&[&share]); - policy.network_policies.insert( - "api".into(), - NetworkPolicyRule { - name: "api".into(), - endpoints: vec![NetworkEndpoint { - host: "example.com".into(), - ..Default::default() - }], - binaries: Vec::new(), - }, - ); - let sandbox = with_policy(driver_sandbox("sb-net"), policy); - let error = backend - .create_sandbox(&sandbox) - .await - .expect_err("unmappable policy must fail CreateSandbox synchronously"); - assert_eq!(error.code(), tonic::Code::InvalidArgument); - assert!(backend.get_sandbox("sb-net").await.is_none()); - } - - #[tokio::test] - async fn governed_egress_rejects_network_middleware_before_lifecycle() { - let config = MxcComputeConfig { - egress_proxy: true, - egress_proxy_addr: "127.0.0.1:18080".into(), - ..Default::default() - }; - let backend = MxcComputeBackend::new_mocked(config); - - let mut policy = fs_policy(&[]); - policy.network_middlewares.insert( - "redactor".into(), - NetworkMiddlewareConfig { - name: "redactor".into(), - middleware: "openshell/regex".into(), - on_error: "fail_closed".into(), - endpoints: Some(MiddlewareEndpointSelector { - include: vec!["api.example.com".into()], - exclude: Vec::new(), - }), - ..Default::default() - }, - ); - let sandbox = with_policy(driver_sandbox("sb-middleware"), policy); - - let error = backend.create_sandbox(&sandbox).await.unwrap_err(); - assert_eq!(error.code(), tonic::Code::InvalidArgument); - assert!(error.message().contains("network_middlewares")); - assert!(backend.get_sandbox("sb-middleware").await.is_none()); - } } diff --git a/crates/openshell-driver-mxc/src/isolation.rs b/crates/openshell-driver-mxc/src/isolation.rs new file mode 100644 index 0000000000..99606ba0a3 --- /dev/null +++ b/crates/openshell-driver-mxc/src/isolation.rs @@ -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, + 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, +} + +pub(crate) struct MxcBoundaryProvisioning { + pub boundary_config: BoundaryConfig, + pub runtime_descriptor: SandboxRuntimeDescriptor, +} + +impl MxcBoundarySpec { + pub fn provision(self) -> Result { + 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, + }, + }) + } +} diff --git a/crates/openshell-driver-mxc/src/lib.rs b/crates/openshell-driver-mxc/src/lib.rs index dce22b3b49..a6eb56b8ba 100644 --- a/crates/openshell-driver-mxc/src/lib.rs +++ b/crates/openshell-driver-mxc/src/lib.rs @@ -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")] diff --git a/crates/openshell-driver-mxc/src/mxc.rs b/crates/openshell-driver-mxc/src/mxc.rs index eecc1f7155..ffdba79df1 100644 --- a/crates/openshell-driver-mxc/src/mxc.rs +++ b/crates/openshell-driver-mxc/src/mxc.rs @@ -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>> { - static GRANTS: OnceLock>>> = 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> { - static CONFIGS: OnceLock>> = OnceLock::new(); - CONFIGS.get_or_init(|| Mutex::new(HashMap::new())) -} - -#[cfg(test)] -pub fn mock_recorded_config(id: &str) -> Option { - 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, - pub error: Option, -} - // ── 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) -> 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::(&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::(&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, - ) -> Result { - 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 = 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::(&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 { - 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 { - 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); - } } diff --git a/crates/openshell-driver-mxc/src/policy.rs b/crates/openshell-driver-mxc/src/policy.rs index 2a5c9883aa..f0376dedf5 100644 --- a/crates/openshell-driver-mxc/src/policy.rs +++ b/crates/openshell-driver-mxc/src/policy.rs @@ -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); } } diff --git a/crates/openshell-driver-mxc/src/policy_map/map.rs b/crates/openshell-driver-mxc/src/policy_map/map.rs index 4af3290715..7f53cf32b3 100644 --- a/crates/openshell-driver-mxc/src/policy_map/map.rs +++ b/crates/openshell-driver-mxc/src/policy_map/map.rs @@ -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]) { diff --git a/crates/openshell-driver-mxc/src/relay.rs b/crates/openshell-driver-mxc/src/relay.rs deleted file mode 100644 index 378f030c9d..0000000000 --- a/crates/openshell-driver-mxc/src/relay.rs +++ /dev/null @@ -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:"` as its first message, before anything else is -//! accepted from that connection -- see openshell-supervisor- -//! relay's `run_relay_bridge`, which sends this immediately -//! after connecting. -//! Phase B (host client, raw TCP -- normally the gateway process itself, -//! connecting right after `open_dynamic_forward` returns) must -//! write the raw nonce bytes as the first bytes on the -//! connection, before any tunneled application data -- see -//! openshell-server's `ForwardTcp` handler. -//! -//! A connection that fails or times out on this check is closed and the -//! relay keeps waiting for the real peer, rather than treating the first -//! comer as authoritative or tearing the whole relay down (a wrong guess -//! shouldn't be a viable way to deny service to the real caller either). -//! -//! Protocol over Phase A (WS connection from sandbox to relay), after auth: -//! TEXT "`SESSION_START`" — relay opened a Phase B TCP connection -//! BINARY — bytes from Phase B TCP stream -//! TEXT "`SESSION_END`" — Phase B TCP connection closed -//! WS Close — relay shutting down (`delete_sandbox` or error) -//! -//! Phase B (host client), after the nonce prefix, is a plain byte stream — -//! no WS handshake — so the client's full byte stream (including any WS -//! upgrade request and frames) is tunneled transparently to the in-sandbox -//! service from that point on. - -use crate::control_channel::ControlChannel; -use base64::Engine; -use futures::{SinkExt, StreamExt}; -use openshell_core::net::set_tcp_nodelay_best_effort; -use std::net::SocketAddr; -use std::sync::Arc; -use std::time::Duration; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::TcpListener; -use tokio::sync::oneshot; -use tokio_tungstenite::{accept_async, tungstenite::Message}; -use tracing::{info, warn}; - -/// Length in bytes of the per-forward auth nonce (see module docs). Must -/// match `NONCE_LEN` in `driver.rs` (which generates it) and -/// `openshell-supervisor-relay`'s copy (which echoes it back on Phase A) -- -/// duplicated rather than shared via a common crate, matching how the rest -/// of this wire protocol (e.g. the "`SESSION_START`"/"`SESSION_END`" literals) -/// is already duplicated across the two sides. -pub const NONCE_LEN: usize = 32; - -/// How long to wait for a freshly-accepted connection to present its auth -/// nonce before giving up on it and going back to waiting for the real peer. -/// Generous: this is a local host round-trip, but a slow/malicious -/// connector shouldn't be able to stall the relay for the legitimate peer -/// for long either. -const AUTH_TIMEOUT: Duration = Duration::from_secs(5); - -/// Select the concrete host IPv4 address used to reach the machine's default -/// route. UDP `connect` performs route selection without sending a packet, so -/// this does not depend on the probe endpoint being reachable. Binding the -/// relay to that exact interface avoids exposing it on every interface while -/// still making it reachable from an `AppContainer` whose loopback is isolated -/// from the host's loopback. -/// Fixed-time byte comparison so a wrong guess doesn't leak how many -/// leading bytes it got right via response timing. The nonce is one-shot -/// (a fresh relay per forward) so this is defense in depth rather than -/// closing a practically exploitable channel, but it's free. -fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { - if a.len() != b.len() { - return false; - } - a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0 -} - -/// Hex-encode `bytes` (lowercase, unpadded). `pub` (crate-visible in -/// practice, since `relay` isn't a `pub mod`) so `ForwardSink:: -/// open_dynamic_forward` in `driver.rs` can use the exact same encoding to -/// build the "forward" control-channel request's `nonce` field that this -/// module expects back from the sandbox on Phase A. -pub fn encode_hex(bytes: &[u8]) -> String { - use std::fmt::Write; - bytes - .iter() - .fold(String::with_capacity(bytes.len() * 2), |mut s, b| { - let _ = write!(s, "{b:02x}"); - s - }) -} - -/// Render the first `n` bytes of `data` as a printable-ASCII preview -/// (non-printable bytes shown as `.`), for hop-by-hop diagnostic logging. -/// Not a general-purpose formatter -- just enough to eyeball whether e.g. an -/// HTTP/WS handshake looks intact versus corrupted or empty. -/// -/// Not called anywhere: forwarded traffic can carry auth headers, cookies, or -/// other sensitive payload, and this relay's own logs are gateway logs, so no -/// byte preview is ever logged, at any level. Kept only so a future opt-in -/// diagnostic mode has a ready-made (still-redaction-worthy) formatter to -/// start from. -#[allow(dead_code)] -fn byte_preview(data: &[u8]) -> String { - const MAX: usize = 120; - let n = data.len().min(MAX); - let mut s: String = data[..n] - .iter() - .map(|&b| { - if b.is_ascii_graphic() || b == b' ' { - b as char - } else { - '.' - } - }) - .collect(); - if data.len() > MAX { - s.push_str("..."); - } - s -} - -// ── Public handle ───────────────────────────────────────────────────────────── - -/// Owned handle returned by [`start_relay`]. Drop or call [`stop`] to -/// shut down the relay task and release the listener port. -pub struct RelayHandle { - shutdown_tx: oneshot::Sender<()>, -} - -impl RelayHandle { - pub fn stop(self) { - let _ = self.shutdown_tx.send(()); - } -} - -// ── Entry point ─────────────────────────────────────────────────────────────── - -/// Bind a TCP listener on `bind_addr` and spawn the relay task. The caller -/// communicates the returned address (and `nonce`) to the sandbox directly -/// (over the control channel) — this doesn't touch the filesystem at all. -/// `nonce` must be freshly generated per call (see module docs) — it's what -/// lets this host-interface relay tell the real Phase A/B peers apart from -/// any other local process that might race to connect first. -pub async fn start_relay( - bind_addr: SocketAddr, - sandbox_name: String, - nonce: [u8; NONCE_LEN], -) -> std::io::Result<(RelayHandle, SocketAddr)> { - let listener = TcpListener::bind(bind_addr).await?; - let actual = listener.local_addr()?; - let (shutdown_tx, shutdown_rx) = oneshot::channel(); - tokio::spawn(relay_task( - listener, - sandbox_name.clone(), - nonce, - shutdown_rx, - )); - info!(sandbox = %sandbox_name, relay = %actual, "MXC relay started"); - Ok((RelayHandle { shutdown_tx }, actual)) -} - -/// Start a host-loopback listener whose sandbox leg is multiplexed over the -/// inherited stdin/stdout control channel. Unlike [`start_relay`], this path -/// never asks the `AppContainer` to connect back to a host network address. -pub async fn start_control_channel_relay( - bind_addr: SocketAddr, - sandbox_name: String, - nonce: [u8; NONCE_LEN], - control_channel: Arc, - target_port: u16, -) -> std::io::Result<(RelayHandle, SocketAddr)> { - let listener = TcpListener::bind(bind_addr).await?; - let actual = listener.local_addr()?; - let (shutdown_tx, shutdown_rx) = oneshot::channel(); - tokio::spawn(control_channel_relay_task( - listener, - sandbox_name.clone(), - nonce, - control_channel, - target_port, - shutdown_rx, - )); - info!(sandbox = %sandbox_name, relay = %actual, "MXC control-channel relay started"); - Ok((RelayHandle { shutdown_tx }, actual)) -} - -async fn control_channel_relay_task( - listener: TcpListener, - sandbox_name: String, - nonce: [u8; NONCE_LEN], - control_channel: Arc, - target_port: u16, - mut shutdown_rx: oneshot::Receiver<()>, -) { - loop { - let mut host_stream = tokio::select! { - result = listener.accept() => match result { - Ok((stream, addr)) => { - info!(sandbox = %sandbox_name, %addr, "MXC control-channel relay: host client connected"); - set_tcp_nodelay_best_effort(&stream); - stream - } - Err(error) => { - warn!(sandbox = %sandbox_name, "MXC control-channel relay listener error: {error}"); - return; - } - }, - _ = &mut shutdown_rx => return, - }; - - let mut auth_buf = [0_u8; NONCE_LEN]; - match tokio::time::timeout(AUTH_TIMEOUT, host_stream.read_exact(&mut auth_buf)).await { - Ok(Ok(_)) if constant_time_eq(&auth_buf, &nonce) => {} - Ok(Ok(_) | Err(_)) | Err(_) => continue, - } - - let session_id = encode_hex(&rand::random::<[u8; NONCE_LEN]>()); - let open = control_channel - .request( - "forward_open", - serde_json::json!({"session_id": session_id, "target_port": target_port}), - Duration::from_secs(10), - ) - .await; - if !control_response_ok(&open) { - warn!(sandbox = %sandbox_name, "MXC control-channel relay: sandbox rejected session open"); - continue; - } - - let (mut host_read, mut host_write) = host_stream.into_split(); - let mut host_buf = vec![0_u8; 8192]; - let mut host_to_sandbox_bytes = 0_u64; - let mut sandbox_to_host_bytes = 0_u64; - let mut shutting_down = false; - 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, -) -> 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 ──────────────────────────────────────────────────────────────── diff --git a/crates/openshell-driver-mxc/tests/openclaw_appcontainer_compat.rs b/crates/openshell-driver-mxc/tests/openclaw_appcontainer_compat.rs deleted file mode 100644 index 3a3a16af59..0000000000 --- a/crates/openshell-driver-mxc/tests/openclaw_appcontainer_compat.rs +++ /dev/null @@ -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")); -} diff --git a/crates/openshell-driver-mxc/tests/openclaw_environment_cleanup.ps1 b/crates/openshell-driver-mxc/tests/openclaw_environment_cleanup.ps1 deleted file mode 100644 index 987ea5aed7..0000000000 --- a/crates/openshell-driver-mxc/tests/openclaw_environment_cleanup.ps1 +++ /dev/null @@ -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" diff --git a/crates/openshell-driver-mxc/tests/wxc_exec_real.rs b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs index d748f35fcd..48f9f182b2 100644 --- a/crates/openshell-driver-mxc/tests/wxc_exec_real.rs +++ b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs @@ -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); diff --git a/crates/openshell-driver-podman/src/isolation.rs b/crates/openshell-driver-podman/src/isolation.rs index d66c29b77c..29aa08a7ad 100644 --- a/crates/openshell-driver-podman/src/isolation.rs +++ b/crates/openshell-driver-podman/src/isolation.rs @@ -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, diff --git a/crates/openshell-driver-vm/src/isolation/mod.rs b/crates/openshell-driver-vm/src/isolation/mod.rs index 6a909cd003..b313008095 100644 --- a/crates/openshell-driver-vm/src/isolation/mod.rs +++ b/crates/openshell-driver-vm/src/isolation/mod.rs @@ -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, }, diff --git a/crates/openshell-gateway/src/lib.rs b/crates/openshell-gateway/src/lib.rs index 30e1c83727..f134b60474 100644 --- a/crates/openshell-gateway/src/lib.rs +++ b/crates/openshell-gateway/src/lib.rs @@ -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 { - 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, Box), String> { - let (addr, nonce, handle) = self - .0 - .open_dynamic_forward(sandbox_id, target_port) - .await - .map_err(|error| error.to_string())?; - Ok((addr, nonce.to_vec(), Box::new(handle))) + 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, diff --git a/crates/openshell-isolation-interface/src/contract.rs b/crates/openshell-isolation-interface/src/contract.rs index ca8ea4762f..7bd8c1435a 100644 --- a/crates/openshell-isolation-interface/src/contract.rs +++ b/crates/openshell-isolation-interface/src/contract.rs @@ -370,6 +370,15 @@ pub trait BoundBoundary: Send { /// Retained by the supervisor before consuming `Bound`. fn network_mediation_source(&self) -> Arc; + /// 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 { + 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) -> Result; } +/// 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", &"") + .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, diff --git a/crates/openshell-sandbox-backend/src/boundary_protocol.rs b/crates/openshell-sandbox-backend/src/boundary_protocol.rs index ec62813a0e..b319f524b5 100644 --- a/crates/openshell-sandbox-backend/src/boundary_protocol.rs +++ b/crates/openshell-sandbox-backend/src/boundary_protocol.rs @@ -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, + /// 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, /// 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, /// Driver-resolved environment exposed only to workload processes. #[serde(default)] pub child_env: std::collections::HashMap, @@ -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(|_| ""), + ) .field("child_env_keys", &self.child_env.keys().collect::>()) .finish() } diff --git a/crates/openshell-sandbox-backend/src/runtime.rs b/crates/openshell-sandbox-backend/src/runtime.rs index 05d6bf65c8..609034402b 100644 --- a/crates/openshell-sandbox-backend/src/runtime.rs +++ b/crates/openshell-sandbox-backend/src/runtime.rs @@ -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, host_gateway_ip: Option, + direct_proxy: Option, ca_file_paths: Arc>>, 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 { + self.direct_proxy.clone() + } + async fn confirm(self: Box) -> Result { 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(), }, diff --git a/crates/openshell-sandbox/Cargo.toml b/crates/openshell-sandbox/Cargo.toml index 1283dd063b..18d762bb5a 100644 --- a/crates/openshell-sandbox/Cargo.toml +++ b/crates/openshell-sandbox/Cargo.toml @@ -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" diff --git a/crates/openshell-sandbox/src/boundary_server.rs b/crates/openshell-sandbox/src/boundary_server.rs index 041ae9e625..670649273c 100644 --- a/crates/openshell-sandbox/src/boundary_server.rs +++ b/crates/openshell-sandbox/src/boundary_server.rs @@ -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()) } diff --git a/crates/openshell-sandbox/src/boundary_server/windows.rs b/crates/openshell-sandbox/src/boundary_server/windows.rs new file mode 100644 index 0000000000..b00335894b --- /dev/null +++ b/crates/openshell-sandbox/src/boundary_server/windows.rs @@ -0,0 +1,1410 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Windows ProcessContainer implementation of the authenticated Sandbox Protocol. +//! +//! MXC owns the outer filesystem and network fence. This process owns the +//! authenticated lifecycle channel, launches the admitted workload only after +//! confirmation, retains process output, and provides exec and loopback access. + +use std::collections::{HashMap, HashSet, VecDeque}; +use std::io; +use std::mem::size_of_val; +use std::path::{Path, PathBuf}; +use std::pin::Pin; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::task::{Context, Poll}; +use std::time::Duration; + +use openshell_core::jwt::{ + SandboxId, SessionJwtVerifier, SessionTokenProfile, SessionVerificationKey, SystemJwtClock, +}; +use openshell_isolation_interface::contract::BoundaryConfirmation; +use openshell_sandbox_backend::boundary_protocol::{ + AgentSpecWire, BoundaryConfig, BoundaryErrorKind, BoundaryListener, ExecSpecWire, + ExitStatusWire, MxcSandboxAuditEvidence, OpenShellBoundaryAuditEvidence, OutputWindowWire, + ProcessKindWire, ProcessSnapshotWire, Request, RequestEnvelope, Response, ResponseEnvelope, + STREAM_EXIT, STREAM_STDERR, STREAM_STDIN, STREAM_STDIN_CLOSED, STREAM_STDOUT, + SandboxPolicyWire, SessionSnapshotWire, SignalWire, encode_frame, read_frame_async, + read_stream_frame, write_stream_frame, +}; +use openshell_sandbox_backend::proto::{ + BoundaryChunk, + isolation_boundary_server::{IsolationBoundary, IsolationBoundaryServer}, +}; +use openshell_sandbox_backend::sandbox_auth::{ + SandboxConnectionId, SandboxConnectionRegistry, SandboxProtocolAuthenticator, + SandboxProtocolPrincipal, +}; +use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; +use tokio::process::{Child, Command}; +use tokio_stream::wrappers::ReceiverStream; + +const CONTROL_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(5); +const CONTROL_KEEPALIVE_TIMEOUT: Duration = Duration::from_secs(10); +const AUTHENTICATED_RECONNECT_TIMEOUT: Duration = Duration::from_secs(30); +const OUTPUT_BUFFER_BYTES: usize = 1024 * 1024; +const MAX_REPLAY_ENTRIES: usize = 4096; + +pub(super) fn run_boundary( + config_path: &Path, + _qualification: crate::RuntimeQualification, +) -> Result<(), String> { + let bytes = std::fs::read(config_path) + .map_err(|error| format!("read boundary config {}: {error}", config_path.display()))?; + let config: BoundaryConfig = serde_json::from_slice(&bytes) + .map_err(|error| format!("decode boundary config {}: {error}", config_path.display()))?; + validate_config(&config)?; + std::fs::remove_file(config_path) + .map_err(|error| format!("consume boundary config {}: {error}", config_path.display()))?; + + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .map_err(|error| format!("create Windows boundary runtime: {error}"))?; + runtime.block_on(async move { + let (address, tls) = match &config.listener { + BoundaryListener::TlsTcp { address, tls } => (*address, tls.clone()), + BoundaryListener::Unix { .. } | BoundaryListener::Vsock { .. } => { + return Err("MXC requires a TLS TCP boundary listener".to_string()); + } + }; + let tls = Arc::new(load_tls_server_config(&tls)?); + let listener = tokio::net::TcpListener::bind(address) + .await + .map_err(|error| format!("bind MXC boundary listener at {address}: {error}"))?; + let boundary = Arc::new(BoundaryRuntime::new(config)?); + tracing::info!(%address, "MXC Sandbox Protocol listener ready"); + loop { + let (stream, _) = listener + .accept() + .await + .map_err(|error| format!("accept MXC boundary connection: {error}"))?; + openshell_core::net::set_tcp_nodelay_best_effort(&stream); + let acceptor = tokio_rustls::TlsAcceptor::from(tls.clone()); + let boundary = boundary.clone(); + tokio::spawn(async move { + let result = async { + let stream = + tokio::time::timeout(Duration::from_secs(5), acceptor.accept(stream)) + .await + .map_err(|_| "MXC boundary TLS handshake timed out".to_string())? + .map_err(|error| { + format!("MXC boundary TLS handshake failed: {error}") + })?; + serve_grpc(Box::new(stream), boundary, SandboxConnectionId::new()).await + } + .await; + if let Err(error) = result { + tracing::debug!(%error, "MXC boundary connection ended"); + } + }); + } + }) +} + +fn validate_config(config: &BoundaryConfig) -> Result<(), String> { + if config.boundary_id.trim().is_empty() + || config.generation.trim().is_empty() + || config.gateway_id.trim().is_empty() + || config.verification_keys.is_empty() + { + return Err("MXC boundary identity and verification keys are required".to_string()); + } + config + .outer_fence + .validate(&config.generation) + .map_err(|error| error.to_string())?; + match &config.listener { + BoundaryListener::TlsTcp { address, tls } + if address.port() != 0 + && tls.certificate_chain_path.is_absolute() + && tls.private_key_path.is_absolute() => {} + BoundaryListener::TlsTcp { .. } => { + return Err("MXC boundary TLS listener configuration is invalid".to_string()); + } + BoundaryListener::Unix { .. } | BoundaryListener::Vsock { .. } => { + return Err("MXC boundary supports only TLS TCP transport".to_string()); + } + } + let Some(proxy_url) = config.direct_proxy_url.as_deref() else { + return Err("MXC boundary requires a generation-scoped direct proxy".to_string()); + }; + let url = proxy_url + .parse::() + .map_err(|error| format!("validate MXC direct proxy URL: {error}"))?; + if url.scheme() != "http" + || url.host_str() != Some("127.0.0.1") + || url.port().is_none() + || url.username().is_empty() + || url.password().is_none() + { + return Err("MXC direct proxy must be an authenticated 127.0.0.1 HTTP URL".to_string()); + } + Ok(()) +} + +fn load_tls_server_config( + tls: &openshell_sandbox_backend::boundary_protocol::SandboxTlsServerConfig, +) -> Result { + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + let certificate_bytes = std::fs::read(&tls.certificate_chain_path) + .map_err(|error| format!("read MXC boundary TLS certificate: {error}"))?; + let certificates = rustls_pemfile::certs(&mut certificate_bytes.as_slice()) + .collect::, _>>() + .map_err(|error| format!("parse MXC boundary TLS certificate: {error}"))?; + let private_key_bytes = std::fs::read(&tls.private_key_path) + .map_err(|error| format!("read MXC boundary TLS private key: {error}"))?; + let private_key = rustls_pemfile::private_key(&mut private_key_bytes.as_slice()) + .map_err(|error| format!("parse MXC boundary TLS private key: {error}"))? + .ok_or_else(|| "MXC boundary TLS private key is empty".to_string())?; + let mut server = + rustls::ServerConfig::builder_with_protocol_versions(&[&rustls::version::TLS13]) + .with_no_client_auth() + .with_single_cert(certificates, private_key) + .map_err(|error| format!("configure MXC boundary TLS: {error}"))?; + server.alpn_protocols = vec![b"h2".to_vec()]; + for path in [&tls.certificate_chain_path, &tls.private_key_path] { + std::fs::remove_file(path).map_err(|error| { + format!("consume MXC boundary TLS file {}: {error}", path.display()) + })?; + } + Ok(server) +} + +async fn serve_grpc( + stream: openshell_isolation_interface::contract::BoundaryDuplexStream, + runtime: Arc, + connection_id: SandboxConnectionId, +) -> Result<(), String> { + let (connection_shutdown, mut connection_closed) = tokio::sync::watch::channel(()); + runtime.register_connection(connection_id, connection_shutdown.clone()); + let incoming = tokio_stream::StreamExt::chain( + tokio_stream::iter([Ok::<_, io::Error>(GrpcServerIo { + stream, + _connection_alive: connection_shutdown, + _disconnect: DisconnectGuard { + runtime: Arc::downgrade(&runtime), + connection_id, + }, + })]), + tokio_stream::pending(), + ); + let result = tonic::transport::Server::builder() + .http2_keepalive_interval(Some(CONTROL_KEEPALIVE_INTERVAL)) + .http2_keepalive_timeout(Some(CONTROL_KEEPALIVE_TIMEOUT)) + .add_service(IsolationBoundaryServer::new(GrpcBoundaryService { + runtime: runtime.clone(), + connection_id, + })) + .serve_with_incoming_shutdown(incoming, async move { + let _ = connection_closed.changed().await; + }) + .await; + runtime.transport_disconnected(connection_id); + result.map_err(|error| format!("serve MXC boundary gRPC: {error}")) +} + +struct GrpcServerIo { + stream: openshell_isolation_interface::contract::BoundaryDuplexStream, + _connection_alive: tokio::sync::watch::Sender<()>, + _disconnect: DisconnectGuard, +} + +struct DisconnectGuard { + runtime: std::sync::Weak, + connection_id: SandboxConnectionId, +} + +impl Drop for DisconnectGuard { + fn drop(&mut self) { + if let Some(runtime) = self.runtime.upgrade() { + runtime.transport_disconnected(self.connection_id); + } + } +} + +impl tokio::io::AsyncRead for GrpcServerIo { + fn poll_read( + mut self: Pin<&mut Self>, + context: &mut Context<'_>, + buffer: &mut tokio::io::ReadBuf<'_>, + ) -> Poll> { + Pin::new(&mut self.stream).poll_read(context, buffer) + } +} + +impl tokio::io::AsyncWrite for GrpcServerIo { + fn poll_write( + mut self: Pin<&mut Self>, + context: &mut Context<'_>, + buffer: &[u8], + ) -> Poll> { + Pin::new(&mut self.stream).poll_write(context, buffer) + } + + fn poll_flush(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.stream).poll_flush(context) + } + + fn poll_shutdown(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.stream).poll_shutdown(context) + } +} + +impl tonic::transport::server::Connected for GrpcServerIo { + type ConnectInfo = (); + fn connect_info(&self) -> Self::ConnectInfo {} +} + +#[derive(Clone)] +struct GrpcBoundaryService { + runtime: Arc, + connection_id: SandboxConnectionId, +} + +type GrpcResponseStream = ReceiverStream>; + +#[tonic::async_trait] +impl IsolationBoundary for GrpcBoundaryService { + type ExchangeStream = GrpcResponseStream; + type MediateStream = GrpcResponseStream; + + async fn exchange( + &self, + request: tonic::Request>, + ) -> Result, tonic::Status> { + let principal = self + .runtime + .authenticate_request(self.connection_id, request.metadata())?; + let (stream, response) = bridge_grpc_stream(request.into_inner()); + let runtime = self.runtime.clone(); + tokio::spawn(async move { + if let Err(error) = serve_one(stream, runtime, principal).await { + tracing::warn!(%error, "MXC Sandbox Protocol exchange failed"); + } + }); + Ok(tonic::Response::new(response)) + } + + async fn mediate( + &self, + request: tonic::Request>, + ) -> Result, tonic::Status> { + let _ = self + .runtime + .authenticate_request(self.connection_id, request.metadata())?; + Err(tonic::Status::failed_precondition( + "MXC uses the supervisor-owned authenticated explicit proxy", + )) + } +} + +fn bridge_grpc_stream( + mut inbound: tonic::Streaming, +) -> (tokio::io::DuplexStream, GrpcResponseStream) { + let (application, bridge) = tokio::io::duplex(256 * 1024); + let (mut reader, mut writer) = tokio::io::split(bridge); + let (outbound, outbound_rx) = tokio::sync::mpsc::channel(64); + tokio::spawn(async move { + loop { + match inbound.message().await { + Ok(Some(chunk)) if writer.write_all(&chunk.data).await.is_ok() => {} + Ok(Some(_)) | Err(_) => return, + Ok(None) => { + let _ = writer.shutdown().await; + return; + } + } + } + }); + tokio::spawn(async move { + let mut buffer = vec![0_u8; 16 * 1024]; + loop { + let Ok(read) = reader.read(&mut buffer).await else { + return; + }; + if read == 0 + || outbound + .send(Ok(BoundaryChunk { + data: buffer[..read].to_vec(), + })) + .await + .is_err() + { + return; + } + } + }); + (application, ReceiverStream::new(outbound_rx)) +} + +#[derive(Clone)] +struct ReplayRecord { + digest: String, + response: Response, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Lifecycle { + AwaitingAttach, + Bound, + Ready, + Running, + Terminal, +} + +struct BoundaryRuntime { + config: BoundaryConfig, + authenticator: SandboxProtocolAuthenticator, + connections: SandboxConnectionRegistry, + connection_shutdowns: Mutex>>, + active_connection: Mutex>, + lifecycle: Mutex, + attached_policy: Mutex>, + processes: Mutex>>, + main_process: Mutex>, + provider_environment: Mutex<(u64, HashMap)>, + replay: Mutex>, + replay_order: Mutex>, + exec_requests: Mutex>, + next_exec: AtomicU64, +} + +impl BoundaryRuntime { + fn new(config: BoundaryConfig) -> Result { + let sandbox_id = SandboxId::parse(config.boundary_id.clone()) + .map_err(|error| format!("validate MXC sandbox ID: {error}"))?; + let generation = openshell_core::sandbox_generation::SandboxGenerationId::parse( + config.generation.clone(), + ) + .map_err(|error| format!("validate MXC runtime generation: {error}"))?; + let verifier = SessionJwtVerifier::new( + &config.gateway_id, + SessionTokenProfile::Sandbox, + config + .verification_keys + .iter() + .map(|key| SessionVerificationKey { + key_id: key.key_id.clone(), + public_key_pem: key.public_key_pem.as_bytes().to_vec(), + }), + Arc::new(SystemJwtClock), + ) + .map_err(|error| format!("configure MXC Sandbox Protocol verifier: {error}"))?; + Ok(Self { + authenticator: SandboxProtocolAuthenticator::new( + verifier, + sandbox_id, + generation, + config.auth_epoch, + ), + connections: SandboxConnectionRegistry::new(config.session_id, config.session_rotation), + config, + connection_shutdowns: Mutex::new(HashMap::new()), + active_connection: Mutex::new(None), + lifecycle: Mutex::new(Lifecycle::AwaitingAttach), + attached_policy: Mutex::new(None), + processes: Mutex::new(HashMap::new()), + main_process: Mutex::new(None), + provider_environment: Mutex::new((0, HashMap::new())), + replay: Mutex::new(HashMap::new()), + replay_order: Mutex::new(VecDeque::new()), + exec_requests: Mutex::new(HashSet::new()), + next_exec: AtomicU64::new(1), + }) + } + + fn authenticate_request( + &self, + connection_id: SandboxConnectionId, + metadata: &tonic::metadata::MetadataMap, + ) -> Result { + self.authenticator + .authenticate(connection_id, metadata) + .map_err(|error| tonic::Status::unauthenticated(error.to_string())) + } + + fn authorize( + &self, + principal: &SandboxProtocolPrincipal, + request: &Request, + ) -> Result<(), String> { + if matches!(request, Request::Attach { .. }) { + return Ok(()); + } + if matches!(request, Request::Confirm) { + self.connections + .require_attached(principal) + .map_err(|error| error.to_string()) + } else { + self.connections + .require_active(principal) + .map_err(|error| error.to_string()) + } + } + + fn register_connection( + &self, + id: SandboxConnectionId, + shutdown: tokio::sync::watch::Sender<()>, + ) { + lock(&self.connection_shutdowns).insert(id, shutdown); + } + + fn close_connection(&self, id: SandboxConnectionId) { + if let Some(shutdown) = lock(&self.connection_shutdowns).remove(&id) { + let _ = shutdown.send(()); + } + } + + fn transport_disconnected(self: &Arc, id: SandboxConnectionId) { + lock(&self.connection_shutdowns).remove(&id); + if !self.connections.disconnect(id) { + return; + } + *lock(&self.active_connection) = None; + let weak = Arc::downgrade(self); + tokio::spawn(async move { + tokio::time::sleep(AUTHENTICATED_RECONNECT_TIMEOUT).await; + let Some(runtime) = weak.upgrade() else { + return; + }; + if lock(&runtime.active_connection).is_none() { + tracing::error!("MXC supervisor recovery expired; terminating workload"); + runtime.terminate_all().await; + } + }); + } + + fn commit_attach( + &self, + principal: &SandboxProtocolPrincipal, + instance: openshell_sandbox_backend::boundary_protocol::SupervisorInstanceId, + ) -> Result<(), String> { + if let Some(replaced) = self + .connections + .attach(principal, instance) + .map_err(|error| error.to_string())? + { + self.close_connection(replaced); + } + Ok(()) + } + + fn commit_confirm(&self, principal: &SandboxProtocolPrincipal) -> Result<(), String> { + if let Some(replaced) = self + .connections + .confirm(principal) + .map_err(|error| error.to_string())? + { + self.close_connection(replaced); + } + *lock(&self.active_connection) = Some(principal.connection_id()); + Ok(()) + } + + fn snapshot(&self) -> SessionSnapshotWire { + let mut processes = lock(&self.processes) + .values() + .map(|process| ProcessSnapshotWire { + process_id: process.id.clone(), + kind: process.kind, + terminal: false, + status: process.exit_status(), + retained_output: process.output.window(), + }) + .collect::>(); + processes.sort_by(|left, right| left.process_id.cmp(&right.process_id)); + SessionSnapshotWire { + generation: self.config.generation.clone(), + processes, + } + } + + fn confirmation(&self) -> Result { + let evidence = MxcSandboxAuditEvidence { + process_container: current_process_is_appcontainer()?, + appcontainer_profile: self + .config + .resource_claims + .get("mxc.appcontainer_profile") + .cloned() + .unwrap_or_else(|| self.config.generation.clone()), + default_deny_filesystem: true, + default_deny_egress: true, + loopback_proxy_only: self.config.direct_proxy_url.is_some(), + authenticated_control: true, + generation_scoped_attribution: true, + }; + evidence.validate().map_err(|error| error.to_string())?; + let properties = evidence.properties(); + let backend_audit = + serde_json::to_value(OpenShellBoundaryAuditEvidence::WindowsMxc(evidence)) + .map_err(|error| format!("encode MXC audit evidence: {error}"))?; + Ok(BoundaryConfirmation { + generation: self.config.generation.clone(), + identity: self.config.workload_identity.clone(), + properties, + authenticated_supervisor: true, + session_id: self.config.session_id, + outer_fence: self.config.outer_fence.clone(), + runtime_exit_terminates_workload: true, + resource_claims: self.config.resource_claims.clone(), + backend_audit, + }) + } + + fn dispatch(&self, envelope: &RequestEnvelope) -> Response { + if envelope.validate_payload_digest().is_err() { + return guest_error(BoundaryErrorKind::Denied, "control payload digest mismatch"); + } + if envelope.request.is_replayable_mutation() + && let Some(record) = lock(&self.replay).get(&envelope.request_id) + { + return if record.digest == envelope.payload_digest { + record.response.clone() + } else { + guest_error( + BoundaryErrorKind::Denied, + "control request ID reused with a different payload", + ) + }; + } + let response = match &envelope.request { + Request::Attach { + policy, + resource_claims, + .. + } => { + if resource_claims != &self.config.resource_claims { + guest_error(BoundaryErrorKind::Denied, "MXC resource claims mismatch") + } else { + let mut lifecycle = lock(&self.lifecycle); + let mut attached_policy = lock(&self.attached_policy); + if *lifecycle == Lifecycle::AwaitingAttach { + *attached_policy = Some((**policy).clone()); + *lifecycle = Lifecycle::Bound; + } + if attached_policy.as_ref() == Some(policy) { + Response::Attached { + snapshot: self.snapshot(), + } + } else { + guest_error(BoundaryErrorKind::Denied, "MXC attach policy changed") + } + } + } + Request::Confirm => { + let mut lifecycle = lock(&self.lifecycle); + match *lifecycle { + Lifecycle::Bound | Lifecycle::Ready | Lifecycle::Running => { + match self.confirmation() { + Ok(confirmation) => { + if *lifecycle == Lifecycle::Bound { + *lifecycle = Lifecycle::Ready; + } + Response::Confirmed { + confirmation: Box::new(confirmation), + } + } + Err(error) => guest_error(BoundaryErrorKind::Process, error), + } + } + Lifecycle::AwaitingAttach | Lifecycle::Terminal => guest_error( + BoundaryErrorKind::Invalid, + "MXC boundary must be attached before confirmation", + ), + } + } + Request::UpdateProviderEnvironment { + expected_revision, + revision, + provider_env, + } => { + let mut current = lock(&self.provider_environment); + if current.0 != *expected_revision || *revision <= *expected_revision { + guest_error( + BoundaryErrorKind::Denied, + "provider environment revision compare-and-swap failed", + ) + } else { + *current = (*revision, provider_env.clone()); + Response::ProviderEnvironmentUpdated { + revision: *revision, + } + } + } + Request::Resize { .. } => guest_error( + BoundaryErrorKind::Invalid, + "Windows ConPTY is not enabled for the MXC boundary", + ), + Request::OpenMediation | Request::AcceptNetwork => guest_error( + BoundaryErrorKind::Invalid, + "MXC uses the supervisor-owned authenticated explicit proxy", + ), + Request::StartAgent { .. } + | Request::AttachProcess { .. } + | Request::Wait { .. } + | Request::Signal { .. } + | Request::Terminate { .. } + | Request::TerminateBoundary + | Request::Exec { .. } + | Request::ExecSignal { .. } + | Request::LoopbackConnect { .. } => guest_error( + BoundaryErrorKind::Invalid, + "streaming request used on the non-streaming path", + ), + }; + if envelope.request.is_replayable_mutation() { + self.remember_replay(envelope, &response); + } + response + } + + fn remember_replay(&self, envelope: &RequestEnvelope, response: &Response) { + let mut replay = lock(&self.replay); + let mut order = lock(&self.replay_order); + if !replay.contains_key(&envelope.request_id) { + while replay.len() >= MAX_REPLAY_ENTRIES { + if let Some(oldest) = order.pop_front() { + replay.remove(&oldest); + } + } + order.push_back(envelope.request_id.clone()); + } + replay.insert( + envelope.request_id.clone(), + ReplayRecord { + digest: envelope.payload_digest.clone(), + response: response.clone(), + }, + ); + } + + async fn start_agent(&self, envelope: &RequestEnvelope) -> Response { + let Request::StartAgent { + spec, + policy, + ca_cert, + ca_bundle, + provider_env_revision, + provider_env, + .. + } = &envelope.request + else { + return guest_error(BoundaryErrorKind::Invalid, "expected StartAgent"); + }; + { + let lifecycle = *lock(&self.lifecycle); + if lifecycle == Lifecycle::Running { + if let Some(id) = lock(&self.main_process).clone() { + return Response::Started { + process_id: id, + provider_env_revision: lock(&self.provider_environment).0, + }; + } + } + if lifecycle != Lifecycle::Ready { + return guest_error( + BoundaryErrorKind::Invalid, + "MXC boundary must be confirmed before agent start", + ); + } + if lock(&self.attached_policy).as_ref() != Some(policy) { + return guest_error(BoundaryErrorKind::Denied, "MXC start policy changed"); + } + } + let mut environment = self.config.child_env.clone(); + environment.extend(provider_env.clone()); + if let Some(proxy_url) = &self.config.direct_proxy_url { + for key in ["HTTP_PROXY", "http_proxy", "HTTPS_PROXY", "https_proxy"] { + environment.insert(key.to_string(), proxy_url.clone()); + } + environment.insert("NO_PROXY".to_string(), String::new()); + environment.insert("no_proxy".to_string(), String::new()); + } + match install_ca_material( + &self.config.generation, + ca_cert.as_deref(), + ca_bundle.as_deref(), + ) { + Ok(Some((certificate, bundle))) => { + environment.insert( + "NODE_EXTRA_CA_CERTS".to_string(), + certificate.display().to_string(), + ); + environment.insert("DENO_CERT".to_string(), certificate.display().to_string()); + for key in [ + "SSL_CERT_FILE", + "REQUESTS_CA_BUNDLE", + "CURL_CA_BUNDLE", + "GIT_SSL_CAINFO", + ] { + environment.insert(key.to_string(), bundle.display().to_string()); + } + } + Ok(None) => {} + Err(error) => return guest_error(BoundaryErrorKind::Process, error), + } + let process_id = format!("{}:main:0", self.config.generation); + let (program, args) = agent_command(spec.clone()); + let process = match ManagedProcess::spawn( + process_id.clone(), + ProcessKindWire::Main, + program, + args, + spec.workdir.clone(), + environment, + ) + .await + { + Ok(process) => Arc::new(process), + Err(error) => return guest_error(BoundaryErrorKind::Process, error), + }; + lock(&self.processes).insert(process_id.clone(), process); + *lock(&self.main_process) = Some(process_id.clone()); + *lock(&self.provider_environment) = (*provider_env_revision, provider_env.clone()); + *lock(&self.lifecycle) = Lifecycle::Running; + let response = Response::Started { + process_id, + provider_env_revision: *provider_env_revision, + }; + self.remember_replay(envelope, &response); + response + } + + async fn start_exec(&self, envelope: &RequestEnvelope, spec: ExecSpecWire) -> Response { + if spec.pty { + return guest_error( + BoundaryErrorKind::Invalid, + "Windows ConPTY is not enabled for MXC exec", + ); + } + { + let mut requests = lock(&self.exec_requests); + if !requests.insert(envelope.request_id.clone()) { + return guest_error( + BoundaryErrorKind::Denied, + "exec request was already consumed", + ); + } + } + let id = format!( + "{}:exec:{}", + self.config.generation, + self.next_exec.fetch_add(1, Ordering::Relaxed) + ); + let mut environment = self.config.child_env.clone(); + environment.extend(lock(&self.provider_environment).1.clone()); + environment.extend(spec.env.iter().cloned()); + let process = match ManagedProcess::spawn( + id.clone(), + ProcessKindWire::Exec, + spec.program, + spec.args, + spec.workdir, + environment, + ) + .await + { + Ok(process) => Arc::new(process), + Err(error) => return guest_error(BoundaryErrorKind::Process, error), + }; + lock(&self.processes).insert(id.clone(), process); + Response::ExecStarted { + process_id: id, + pty: false, + } + } + + fn process(&self, id: &str) -> Result, Response> { + lock(&self.processes) + .get(id) + .cloned() + .ok_or_else(|| guest_error(BoundaryErrorKind::Invalid, "unknown MXC process ID")) + } + + async fn terminate_all(&self) { + self.connections.mark_terminal(); + let processes = lock(&self.processes).values().cloned().collect::>(); + for process in processes { + let _ = process.terminate().await; + } + *lock(&self.lifecycle) = Lifecycle::Terminal; + } +} + +fn current_process_is_appcontainer() -> Result { + use windows::Win32::Foundation::{CloseHandle, HANDLE}; + use windows::Win32::Security::{GetTokenInformation, TOKEN_QUERY, TokenIsAppContainer}; + use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; + + // SAFETY: the token handle is initialized by OpenProcessToken, queried into + // a correctly sized u32 buffer, and closed on every path after acquisition. + unsafe { + let mut token = HANDLE::default(); + OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) + .map_err(|error| format!("open MXC sandbox process token: {error}"))?; + let mut is_appcontainer = 0_u32; + let mut returned = 0_u32; + let query = GetTokenInformation( + token, + TokenIsAppContainer, + Some(std::ptr::from_mut(&mut is_appcontainer).cast()), + size_of_val(&is_appcontainer) as u32, + &mut returned, + ); + let close = CloseHandle(token); + query.map_err(|error| format!("query MXC sandbox AppContainer token: {error}"))?; + close.map_err(|error| format!("close MXC sandbox process token: {error}"))?; + if returned != size_of_val(&is_appcontainer) as u32 { + return Err(format!( + "query MXC sandbox AppContainer token returned {returned} bytes" + )); + } + Ok(is_appcontainer != 0) + } +} + +async fn serve_one( + mut stream: tokio::io::DuplexStream, + runtime: Arc, + principal: SandboxProtocolPrincipal, +) -> Result<(), String> { + let envelope: RequestEnvelope = read_frame_async(&mut stream) + .await + .map_err(|error| format!("read MXC control request: {error}"))?; + runtime.authorize(&principal, &envelope.request)?; + if envelope.validate_payload_digest().is_err() { + return write_response( + &mut stream, + &envelope.request_id, + guest_error(BoundaryErrorKind::Denied, "control payload digest mismatch"), + ) + .await; + } + + match envelope.request.clone() { + Request::Attach { + supervisor_instance_id, + .. + } => { + let response = runtime.dispatch(&envelope); + if matches!(response, Response::Attached { .. }) { + runtime.commit_attach(&principal, supervisor_instance_id)?; + } + write_response(&mut stream, &envelope.request_id, response).await + } + Request::Confirm => { + let response = runtime.dispatch(&envelope); + if matches!(response, Response::Confirmed { .. }) { + runtime.commit_confirm(&principal)?; + } + write_response(&mut stream, &envelope.request_id, response).await + } + Request::StartAgent { .. } => { + let response = runtime.start_agent(&envelope).await; + write_response(&mut stream, &envelope.request_id, response).await + } + Request::Exec { spec } => { + let response = runtime.start_exec(&envelope, spec).await; + let process_id = match &response { + Response::ExecStarted { process_id, .. } => Some(process_id.clone()), + _ => None, + }; + write_response(&mut stream, &envelope.request_id, response).await?; + if let Some(process_id) = process_id { + let process = runtime.process(&process_id).map_err(response_error)?; + bridge_process(stream, process).await?; + } + Ok(()) + } + Request::AttachProcess { process_id } => { + let process = runtime.process(&process_id).map_err(response_error)?; + write_response( + &mut stream, + &envelope.request_id, + Response::ProcessAttached { terminal: false }, + ) + .await?; + bridge_process(stream, process).await + } + Request::Wait { process_id } => { + let response = match runtime.process(&process_id) { + Ok(process) => Response::Exited { + status: process.wait().await, + }, + Err(response) => response, + }; + write_response(&mut stream, &envelope.request_id, response).await + } + Request::Signal { process_id, signal } | Request::ExecSignal { process_id, signal } => { + let response = match runtime.process(&process_id) { + Ok(process) => process.signal(signal).await.map_or_else( + |error| guest_error(BoundaryErrorKind::Process, error), + |()| Response::Signaled, + ), + Err(response) => response, + }; + write_response(&mut stream, &envelope.request_id, response).await + } + Request::Terminate { process_id } => { + let response = match runtime.process(&process_id) { + Ok(process) => process.terminate().await.map_or_else( + |error| guest_error(BoundaryErrorKind::Process, error), + |()| Response::Terminated, + ), + Err(response) => response, + }; + write_response(&mut stream, &envelope.request_id, response).await + } + Request::TerminateBoundary => { + runtime.terminate_all().await; + write_response( + &mut stream, + &envelope.request_id, + Response::BoundaryTerminated, + ) + .await + } + Request::LoopbackConnect { host, port } => { + if !host.is_loopback() || port == 0 { + return write_response( + &mut stream, + &envelope.request_id, + guest_error(BoundaryErrorKind::Denied, "forward target is not loopback"), + ) + .await; + } + match tokio::net::TcpStream::connect((host, port)).await { + Ok(mut target) => { + openshell_core::net::set_tcp_nodelay_best_effort(&target); + write_response(&mut stream, &envelope.request_id, Response::PortConnected) + .await?; + tokio::io::copy_bidirectional(&mut stream, &mut target) + .await + .map(|_| ()) + .map_err(|error| format!("bridge MXC loopback connection: {error}")) + } + Err(error) => { + write_response( + &mut stream, + &envelope.request_id, + guest_error(BoundaryErrorKind::Process, error.to_string()), + ) + .await + } + } + } + _ => { + let response = runtime.dispatch(&envelope); + write_response(&mut stream, &envelope.request_id, response).await + } + } +} + +async fn write_response( + stream: &mut tokio::io::DuplexStream, + request_id: &str, + response: Response, +) -> Result<(), String> { + let frame = encode_frame(&ResponseEnvelope { + request_id: request_id.to_string(), + response, + }) + .map_err(|error| format!("encode MXC control response: {error}"))?; + stream + .write_all(&frame) + .await + .map_err(|error| format!("write MXC control response: {error}"))?; + stream + .flush() + .await + .map_err(|error| format!("flush MXC control response: {error}")) +} + +fn response_error(response: Response) -> String { + match response { + Response::Error { message, .. } => message, + other => format!("unexpected MXC process response: {other:?}"), + } +} + +fn agent_command(spec: AgentSpecWire) -> (String, Vec) { + if spec.program.trim().is_empty() { + ( + std::env::var("COMSPEC") + .unwrap_or_else(|_| "C:\\Windows\\System32\\cmd.exe".to_string()), + vec!["/D".to_string(), "/Q".to_string()], + ) + } else { + (spec.program, spec.args) + } +} + +fn install_ca_material( + generation: &str, + certificate: Option<&[u8]>, + bundle: Option<&[u8]>, +) -> Result, String> { + let (Some(certificate), Some(bundle)) = (certificate, bundle) else { + return Ok(None); + }; + let directory = std::env::temp_dir().join(format!("openshell-ca-{generation}")); + std::fs::create_dir_all(&directory) + .map_err(|error| format!("create MXC CA directory: {error}"))?; + let certificate_path = directory.join("openshell-ca.pem"); + let bundle_path = directory.join("ca-bundle.pem"); + std::fs::write(&certificate_path, certificate) + .map_err(|error| format!("write MXC proxy CA: {error}"))?; + std::fs::write(&bundle_path, bundle) + .map_err(|error| format!("write MXC proxy CA bundle: {error}"))?; + Ok(Some((certificate_path, bundle_path))) +} + +struct ManagedProcess { + id: String, + kind: ProcessKindWire, + child: Arc>, + stdin: tokio::sync::Mutex>, + output: Arc, + exit: tokio::sync::watch::Receiver>, + attached: Arc, +} + +impl ManagedProcess { + async fn spawn( + id: String, + kind: ProcessKindWire, + program: String, + args: Vec, + workdir: Option, + environment: HashMap, + ) -> Result { + if program.trim().is_empty() { + return Err("MXC workload program is empty".to_string()); + } + let mut command = Command::new(&program); + command + .args(args) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .envs(environment); + if let Some(workdir) = workdir.filter(|path| !path.trim().is_empty()) { + command.current_dir(workdir); + } + let mut child = command + .spawn() + .map_err(|error| format!("spawn MXC workload executable {program:?}: {error}"))?; + let stdin = child.stdin.take(); + let stdout = child + .stdout + .take() + .ok_or_else(|| "MXC workload stdout pipe is unavailable".to_string())?; + let stderr = child + .stderr + .take() + .ok_or_else(|| "MXC workload stderr pipe is unavailable".to_string())?; + let output = OutputLog::new(); + output.spawn_reader(stdout, STREAM_STDOUT); + output.spawn_reader(stderr, STREAM_STDERR); + let (exit_tx, exit) = tokio::sync::watch::channel(None); + let process = Self { + id, + kind, + child: Arc::new(tokio::sync::Mutex::new(child)), + stdin: tokio::sync::Mutex::new(stdin), + output, + exit, + attached: Arc::new(AtomicBool::new(false)), + }; + process.start_monitor(exit_tx); + Ok(process) + } + + fn start_monitor(&self, exit_tx: tokio::sync::watch::Sender>) { + let child = self.child.clone(); + let output = self.output.clone(); + tokio::spawn(async move { + loop { + let result = { + let mut child = child.lock().await; + child.try_wait() + }; + match result { + Ok(Some(status)) => { + let status = ExitStatusWire::Exited(status.code().unwrap_or(1)); + output.publish_exit(status); + exit_tx.send_replace(Some(status)); + return; + } + Ok(None) => tokio::time::sleep(Duration::from_millis(25)).await, + Err(_) => { + let status = ExitStatusWire::Exited(1); + output.publish_exit(status); + exit_tx.send_replace(Some(status)); + return; + } + } + } + }); + } + + fn acquire_attachment(&self) -> Result { + self.attached + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .map_err(|_| "MXC process already has an active attachment".to_string())?; + Ok(AttachmentGuard(self.attached.clone())) + } + + async fn wait(&self) -> ExitStatusWire { + let mut exit = self.exit.clone(); + loop { + if let Some(status) = *exit.borrow_and_update() { + return status; + } + if exit.changed().await.is_err() { + return ExitStatusWire::Exited(1); + } + } + } + + fn exit_status(&self) -> Option { + *self.exit.borrow() + } + + async fn signal(&self, signal: SignalWire) -> Result<(), String> { + match signal { + SignalWire::Term | SignalWire::Kill => self.terminate().await, + SignalWire::Int | SignalWire::Hup => Err( + "MXC ProcessContainer does not provide POSIX interrupt or hangup signals" + .to_string(), + ), + } + } + + async fn terminate(&self) -> Result<(), String> { + if self.exit_status().is_some() { + return Ok(()); + } + self.child + .lock() + .await + .start_kill() + .map_err(|error| format!("terminate MXC process: {error}")) + } +} + +struct AttachmentGuard(Arc); + +impl Drop for AttachmentGuard { + fn drop(&mut self) { + self.0.store(false, Ordering::Release); + } +} + +#[derive(Clone)] +struct OutputEvent { + sequence: u64, + channel: u8, + payload: Vec, +} + +struct OutputState { + events: VecDeque, + retained_bytes: usize, + next_sequence: u64, +} + +struct OutputLog { + state: Mutex, + version: tokio::sync::watch::Sender, +} + +impl OutputLog { + fn new() -> Arc { + let (version, _) = tokio::sync::watch::channel(0); + Arc::new(Self { + state: Mutex::new(OutputState { + events: VecDeque::new(), + retained_bytes: 0, + next_sequence: 0, + }), + version, + }) + } + + fn spawn_reader( + self: &Arc, + mut reader: impl tokio::io::AsyncRead + Send + Unpin + 'static, + channel: u8, + ) { + let output = self.clone(); + tokio::spawn(async move { + let mut buffer = vec![0_u8; 16 * 1024]; + loop { + match reader.read(&mut buffer).await { + Ok(0) | Err(_) => return, + Ok(read) => output.publish(channel, buffer[..read].to_vec()), + } + } + }); + } + + fn publish_exit(&self, status: ExitStatusWire) { + if let Ok(payload) = serde_json::to_vec(&status) { + self.publish(STREAM_EXIT, payload); + } + } + + fn publish(&self, channel: u8, payload: Vec) { + let version = { + let mut state = lock(&self.state); + let sequence = state.next_sequence; + state.next_sequence = state.next_sequence.saturating_add(1); + state.retained_bytes = state.retained_bytes.saturating_add(payload.len()); + state.events.push_back(OutputEvent { + sequence, + channel, + payload, + }); + while state.retained_bytes > OUTPUT_BUFFER_BYTES { + let Some(removed) = state.events.pop_front() else { + break; + }; + state.retained_bytes = state.retained_bytes.saturating_sub(removed.payload.len()); + } + state.next_sequence + }; + self.version.send_replace(version); + } + + fn cursor(self: &Arc) -> OutputCursor { + let state = lock(&self.state); + let next_sequence = state + .events + .front() + .map_or(state.next_sequence, |event| event.sequence); + drop(state); + OutputCursor { + output: self.clone(), + next_sequence, + version: self.version.subscribe(), + } + } + + fn window(&self) -> OutputWindowWire { + let state = lock(&self.state); + let first_sequence = state + .events + .front() + .map_or(state.next_sequence, |event| event.sequence); + OutputWindowWire { + first_sequence, + next_sequence: state.next_sequence, + truncated: first_sequence != 0, + } + } +} + +struct OutputCursor { + output: Arc, + next_sequence: u64, + version: tokio::sync::watch::Receiver, +} + +impl OutputCursor { + async fn recv(&mut self) -> Option { + loop { + let event = { + let state = lock(&self.output.state); + let oldest = state + .events + .front() + .map_or(state.next_sequence, |event| event.sequence); + if self.next_sequence < oldest { + self.next_sequence = oldest; + } + state + .events + .get(usize::try_from(self.next_sequence.saturating_sub(oldest)).ok()?) + .cloned() + }; + if let Some(event) = event { + self.next_sequence = event.sequence.saturating_add(1); + return Some(event); + } + if self.version.changed().await.is_err() { + return None; + } + } + } +} + +async fn bridge_process( + stream: tokio::io::DuplexStream, + process: Arc, +) -> Result<(), String> { + let _guard = process.acquire_attachment()?; + let (mut reader, mut writer) = tokio::io::split(stream); + let input_process = process.clone(); + let mut input = tokio::spawn(async move { + while let Some((channel, payload)) = read_stream_frame(&mut reader).await? { + match channel { + STREAM_STDIN => { + let mut stdin = input_process.stdin.lock().await; + let Some(stdin) = stdin.as_mut() else { + return Err(io::Error::new(io::ErrorKind::BrokenPipe, "stdin closed")); + }; + stdin.write_all(&payload).await?; + stdin.flush().await?; + } + STREAM_STDIN_CLOSED => { + input_process.stdin.lock().await.take(); + } + _ => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "unexpected MXC input stream channel", + )); + } + } + } + Ok::<(), io::Error>(()) + }); + let mut cursor = process.output.cursor(); + loop { + tokio::select! { + result = &mut input => { + return result + .map_err(|error| format!("join MXC process input: {error}"))? + .map_err(|error| format!("read MXC process input: {error}")); + } + event = cursor.recv() => { + let Some(event) = event else { return Ok(()) }; + write_stream_frame(&mut writer, event.channel, &event.payload) + .await + .map_err(|error| format!("write MXC process output: {error}"))?; + if event.channel == STREAM_EXIT { + return Ok(()); + } + } + } + } +} + +fn guest_error(kind: BoundaryErrorKind, message: impl Into) -> Response { + Response::Error { + kind, + message: message.into(), + } +} + +fn lock(mutex: &Mutex) -> std::sync::MutexGuard<'_, T> { + mutex + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} diff --git a/crates/openshell-sandbox/src/lib.rs b/crates/openshell-sandbox/src/lib.rs index 4166d8d2c1..ee2caed813 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -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 diff --git a/crates/openshell-sandbox/src/main.rs b/crates/openshell-sandbox/src/main.rs index dbdc8733ec..9b55195e2c 100644 --- a/crates/openshell-sandbox/src/main.rs +++ b/crates/openshell-sandbox/src/main.rs @@ -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; diff --git a/crates/openshell-server/src/compute/mod.rs b/crates/openshell-server/src/compute/mod.rs index d89430c860..1d36b8bbc5 100644 --- a/crates/openshell-server/src/compute/mod.rs +++ b/crates/openshell-server/src/compute/mod.rs @@ -698,9 +698,6 @@ pub struct ComputeRuntime { lifecycle_gates: Arc, gateway_listener_requirements: Vec, replica_id: String, - /// Dynamic TCP forward capability contributed by the active in-process - /// driver, if it has one. See `forward_sink`. - forward_sink: Option>, /// Gateway-issued staging slots for rootfs tar archives. Shared across /// clones: `ServerState` holds `ComputeRuntime` by value, so a per-clone /// table would make a token minted on one clone invisible to another. @@ -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) { - self.forward_sink = Some(sink); - } - - /// A driver-owned dynamic TCP forward capability, when the active driver - /// has one. `handle_forward_tcp` uses this as a fallback path for - /// sandboxes with no live `ConnectSupervisor` session (e.g. MXC, which - /// has no in-sandbox supervisor at all). `None` for every other driver. - #[must_use] - pub fn forward_sink(&self) -> Option<&Arc> { - self.forward_sink.as_ref() - } - #[must_use] pub fn default_image(&self) -> &str { &self.default_image @@ -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()), } } diff --git a/crates/openshell-server/src/grpc/sandbox.rs b/crates/openshell-server/src/grpc/sandbox.rs index afc7de439a..9331cb2289 100644 --- a/crates/openshell-server/src/grpc/sandbox.rs +++ b/crates/openshell-server/src/grpc/sandbox.rs @@ -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::>(256); - let sandbox_id_bridge = sandbox_id.clone(); - tokio::spawn(async move { - let _connection_guard = connection_guard; - // Held for the bridge's lifetime; dropping it (bridge exits, - // this task ends) stops the ephemeral relay listener and closes - // Phase A, which is what tells the sandbox's dynamic bridge to - // stop too -- no separate teardown message needed. - let _relay_handle = relay_handle; - bridge_forward_tcp_stream(inbound, relay_stream, tx, &sandbox_id_bridge, "mxc-dynamic") - .await; - }); - - let stream: Pin< - Box> + Send + 'static>, - > = Box::pin(ReceiverStream::new(rx)); - return Ok(Response::new(stream)); - } - let (channel_id, relay_rx) = state .supervisor_sessions .open_relay_with_target( diff --git a/crates/openshell-server/src/lib.rs b/crates/openshell-server/src/lib.rs index 540676c185..6a7cb1daf9 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -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, Box), 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, - forward_sink: Arc>>>, } 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) { - *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>>> = - 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( diff --git a/crates/openshell-supervisor-network/src/run.rs b/crates/openshell-supervisor-network/src/run.rs index b3b62c9ffb..b67b53b595 100644 --- a/crates/openshell-supervisor-network/src/run.rs +++ b/crates/openshell-supervisor-network/src/run.rs @@ -203,6 +203,7 @@ pub async fn run_networking( host_gateway_ip: Option, #[cfg(target_os = "linux")] transparent_runtime: Option, network_mediation_source: Option>, + direct_proxy: Option, ) -> Result { // 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::::from(proxy.authorization.clone())), ) .await?; Some(proxy_handle) diff --git a/crates/openshell-supervisor-process/src/delegated.rs b/crates/openshell-supervisor-process/src/delegated.rs index dde9a7812e..0b7e465da3 100644 --- a/crates/openshell-supervisor-process/src/delegated.rs +++ b/crates/openshell-supervisor-process/src/delegated.rs @@ -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" - )); + 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" - )); + 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), - }; + _ => (None, None), + }; - Ok(BoundaryAccess { - instance_id, - terminating, - ssh_task: Some(ssh_task), - session_task, - session_readiness, - main_session: Some(main_session), - }) + 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. diff --git a/crates/openshell-supervisor-process/src/lib.rs b/crates/openshell-supervisor-process/src/lib.rs index 023a6c8e73..df0f21a519 100644 --- a/crates/openshell-supervisor-process/src/lib.rs +++ b/crates/openshell-supervisor-process/src/lib.rs @@ -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; diff --git a/crates/openshell-supervisor-process/src/main_session.rs b/crates/openshell-supervisor-process/src/main_session.rs index 3ffa24d1d0..7aee0265f1 100644 --- a/crates/openshell-supervisor-process/src/main_session.rs +++ b/crates/openshell-supervisor-process/src/main_session.rs @@ -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>, output: Arc, input_owner: Mutex>, next_owner: AtomicU64, + #[cfg(unix)] pty_master: Option>, boundary_process: Option>, boundary_terminal: Option>, @@ -213,12 +221,14 @@ impl MainSession { pub fn inert() -> Arc { 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, 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 { let terminal = matches!(io, ProcessIo::Pty(_)); let (input, input_rx) = tokio::sync::mpsc::channel::>(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::>(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, 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]); } diff --git a/crates/openshell-supervisor-process/src/ssh.rs b/crates/openshell-supervisor-process/src/ssh.rs index c93f361e24..a4f39aab6e 100644 --- a/crates/openshell-supervisor-process/src/ssh.rs +++ b/crates/openshell-supervisor-process/src/ssh.rs @@ -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()) diff --git a/crates/openshell-supervisor-process/src/supervisor_session.rs b/crates/openshell-supervisor-process/src/supervisor_session.rs index 32cbc2d1ee..6ea25ae890 100644 --- a/crates/openshell-supervisor-process/src/supervisor_session.rs +++ b/crates/openshell-supervisor-process/src/supervisor_session.rs @@ -769,22 +769,29 @@ async fn open_target( port_forward: &Arc, expected_ssh_peer_pid: Option, ) -> Result, Box> { + #[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)) } } } diff --git a/crates/openshell-supervisor-relay/Cargo.toml b/crates/openshell-supervisor-relay/Cargo.toml deleted file mode 100644 index 678f08acb4..0000000000 --- a/crates/openshell-supervisor-relay/Cargo.toml +++ /dev/null @@ -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 diff --git a/crates/openshell-supervisor-relay/src/imp.rs b/crates/openshell-supervisor-relay/src/imp.rs deleted file mode 100644 index fe22bf2343..0000000000 --- a/crates/openshell-supervisor-relay/src/imp.rs +++ /dev/null @@ -1,1111 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Generic process spawner + WebSocket relay bridge for `OpenShell` MXC -//! `ProcessContainer` sandboxes. -//! -//! Unlike `mxc-ws-agent.rs` (a self-contained echo-server test harness), this -//! binary has exactly one job: launch an arbitrary command inside the -//! `AppContainer` and, on request, expose one of its TCP ports to the host -//! through a gateway relay, without requiring any changes to the launched -//! command itself. It is the `agent_command` the mxc driver spawns in place -//! of the target application directly, whenever a sandbox config asks for -//! relay bridging (see `driver.rs`'s handling of `pc_relay_spawner_path` / -//! `pc_relay_target_port`). -//! -//! There is no bridge at startup — relay bridging is entirely on-demand, -//! driven by `forward` requests over the control channel below (used for -//! `openshell forward service`). Each bridge is independent, short-lived, and -//! parameterized per-request (relay address + target port supplied in the -//! request), not declared anywhere in the gateway TOML. -//! -//! ```text -//! Host TCP client (via `openshell forward service`) -//! | -//! v -//! Gateway relay (127.0.0.1:) <-- driver binds one per forward -//! ^ request, tells us the address -//! | outbound WS (via egress_proxy) over the control channel -//! | -//! openshell-supervisor-relay <-- dials out on "forward", proxies traffic -//! | for the lifetime of that one request -//! | loopback WS (AppContainer-internal) -//! v -//! <-- launched from the "launch" control-channel -//! request (see below), no relay knowledge required -//! ``` -//! -//! Usage: `openshell-supervisor-relay.exe ` -- `` -//! is the TCP port the launched command is expected to bind (an early -//! liveness check, `wait_for_port_ready`: if the target never binds it -//! within 60s, this process exits with an error instead of sitting around -//! with a target that will never work). This binary uses no `share_dir` files -//! at all -- command/env and shutdown both travel over the control channel. -//! -//! Shutdown: driver sends a `"shutdown"` request over the control channel -//! (see below) and separately kills the wxc-exec process (`AppContainer` -//! teardown) as a backstop regardless of whether that message gets through. -//! This binary's `run_lifecycle` reacts to the request by killing the -//! target cleanly if it's still running. -//! -//! ## Control channel (this process's own stdin/stdout) -//! -//! `wxc-exec` runs this process with STDIO passthrough, which forwards its -//! own stdin/stdout down from the driver -- the driver pipes them (see -//! `mxc.rs`'s `run_oneshot`) instead of the usual `null`/`piped`-for-logging -//! split, giving the gateway a write channel straight into the `AppContainer`. -//! This needs **no `AppContainer` network capability at all**: it's inherited -//! process handles, not network traffic, so none of `egress_proxy` / -//! `network.proxy` / `privateNetworkClientServer` are involved. -//! -//! Protocol: newline-delimited JSON, mirroring MXC's own `pipe_server` tool: -//! Request: `{"id": , "op": "", "data": }` -//! Response: `{"id": , "ok": true, "data": }` -//! or `{"id": , "ok": false, "error": ""}` -//! Event (unsolicited, no id): `{"event": ""}` -//! -//! Startup handshake: before spawning anything, this process emits -//! `{"event":"ready","protocol_version":N}` on stdout (`N` = `PROTOCOL_VERSION` -//! below -- the driver rejects a mismatched/missing version immediately, -//! so an independently staged, out-of-sync binary fails fast instead of -//! hanging or misbehaving later), then blocks waiting for a `"launch"` -//! request carrying `data: {"command": [...], "env": [...]}` (one arg per -//! `command` element, first is the executable; `env` is `"KEY=VALUE"` -//! strings, replacing the inherited environment entirely when non-empty -- -//! lets runtimes that choke on an unrecognized host env, e.g. node.js -//! `STATUS_DLL_INIT_FAILED`, get a curated one instead). The driver sends -//! this once its stdout-reader observes the ready event (see driver.rs). -//! Command/env travel over this channel rather than as `agent-cmd.txt`/ -//! `agent-env.txt` files in `share_dir` -- keeps them (which can carry -//! secrets) off disk, and avoids the file ever going stale. -//! -//! Ops: `launch` (see above), `shutdown` (no data; acked, then wakes -//! `run_lifecycle` to kill the target and exit -- see Shutdown above), -//! `ping`, `echo`, and `forward` -- `forward` opens a new, independent relay -//! bridge for the target port and relay address given in the request (see -//! `handle_control_request`'s doc comment for the full shape). -//! -//! Because this channel owns our stdout exclusively, the target process's own -//! stdout/stderr are piped (not inherited) and forwarded to *our* stderr with -//! a `[target stdout]`/`[target stderr]` tag instead, so they stay visible in -//! the gateway log without colliding with control-channel responses. - -use base64::Engine; -use futures::{SinkExt, StreamExt}; -use std::collections::HashMap; -use std::sync::Arc; -use std::time::Duration; -use tokio::sync::oneshot; -use tokio_tungstenite::tungstenite::Message; - -/// Wire protocol version reported in the startup `"ready"` event (see -/// `run_control_channel`). Must match `openshell-driver-mxc`'s -/// `REQUIRED_SUPERVISOR_RELAY_PROTOCOL_VERSION` constant -- duplicated -/// rather than shared via a common crate, matching how the rest of this -/// wire protocol is already duplicated across the two sides. Bump both -/// together whenever the control-channel protocol changes in a way an -/// out-of-sync peer can't safely ignore, so an independently staged, stale -/// binary on either side fails fast with a clear version-mismatch error -/// instead of hanging or misbehaving against a field/event it predates. -const PROTOCOL_VERSION: u64 = 3; - -struct ForwardSession { - reader: tokio::sync::Mutex, - writer: tokio::sync::Mutex, -} - -type ForwardSessions = tokio::sync::Mutex>>; - -pub async fn run() -> anyhow::Result<()> { - let port: u16 = std::env::args() - .nth(1) - .ok_or_else(|| anyhow::anyhow!("usage: openshell-supervisor-relay "))? - .parse() - .map_err(|e| anyhow::anyhow!("invalid : {e}"))?; - - // Wait for the driver's "launch" request (see module docs' startup - // handshake) before spawning anything -- command/env arrive over the - // control channel, not as files read from share_dir. Shutdown notice - // arrives the same way (a later "shutdown" request) -- no share_dir - // files are used by this process at all. - let (launch_tx, launch_rx) = oneshot::channel::<(Vec, Vec)>(); - let launch_slot = Arc::new(tokio::sync::Mutex::new(Some(launch_tx))); - let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); - let shutdown_slot = Arc::new(tokio::sync::Mutex::new(Some(shutdown_tx))); - let forward_sessions = Arc::new(ForwardSessions::new(HashMap::new())); - // Lets main() ask run_control_channel's task to announce "target_ready" - // on stdout once the target is actually up (see below) -- routed - // through that task rather than a second independent stdout handle - // here, since concurrent writers to tokio's stdout can interleave. - let (target_ready_tx, target_ready_rx) = oneshot::channel::<()>(); - tokio::spawn(run_control_channel( - launch_slot, - shutdown_slot, - forward_sessions, - target_ready_rx, - )); - - eprintln!("[openshell-supervisor-relay] waiting for launch request from driver..."); - let (command, env) = launch_rx - .await - .map_err(|_| anyhow::anyhow!("control channel closed before a launch request arrived"))?; - - let mut child = spawn_target(command, env)?; - - eprintln!("[openshell-supervisor-relay] waiting for target on 127.0.0.1:{port} ..."); - // Race the (up to ~300s worst case) port-readiness wait against a - // "shutdown" control-channel request, rather than only observing - // shutdown once run_lifecycle's select starts below. Without this, a - // shutdown arriving while the target is still coming up (or never - // binds) gets acknowledged immediately by run_control_channel -- which - // just fires this oneshot, nothing more -- but nothing actually acts on - // it until wait_for_port_ready returns on its own, leaving this process - // (and the target it spawned) alive for up to the full port-readiness - // budget after a caller was told shutdown succeeded. - let mut shutdown_rx = shutdown_rx; - tokio::select! { - result = wait_for_port_ready(&mut child, port, Duration::from_mins(1)) => { - result?; - } - _ = &mut shutdown_rx => { - eprintln!( - "[openshell-supervisor-relay] shutdown request -- stopping before target became ready" - ); - let _ = child.kill().await; - let _ = child.wait().await; - eprintln!("[openshell-supervisor-relay] done"); - // Not `return Ok(())`: run_control_channel loops on - // stdin.next_line() for this process's entire lifetime and - // only sees EOF once the driver closes its end, which it has - // no reason to do before observing this process actually exit - // -- returning normally here would leave that task (and so - // this whole process) alive indefinitely, exactly the - // "acknowledged but still alive" symptom this fix exists to - // close. std::process::exit terminates unconditionally, - // matching run_lifecycle's own shutdown branch below. - std::process::exit(0); - } - } - eprintln!("[openshell-supervisor-relay] target is up on port {port}"); - // Unsolicited event, distinct from the "launch" control-channel - // response (which only confirmed the command/env arrived, not that the - // target is actually reachable) -- driver.rs awaits this before - // publishing the sandbox Ready=True. A send failure just means the - // control-channel task already exited; nothing to do about that here. - let _ = target_ready_tx.send(()); - - // No bridge at startup -- relay bridging is entirely on-demand via the - // control channel's "forward" op (see module docs). Just run the target - // process's lifecycle from here. - run_lifecycle(child, shutdown_rx).await; - - Ok(()) -} - -// ── Helpers ─────────────────────────────────────────────────────────────────── - -/// Render the first `n` bytes of `data` as a printable-ASCII preview -/// (non-printable bytes shown as `.`), for hop-by-hop diagnostic logging. -/// Not a general-purpose formatter -- just enough to eyeball whether e.g. an -/// HTTP/WS handshake looks intact versus corrupted or empty. -/// -/// Not called anywhere: forwarded traffic can carry auth headers, cookies, or -/// other sensitive payload, and helper stderr is forwarded into gateway logs, -/// so no byte preview is ever logged. Kept only so a future opt-in diagnostic -/// mode has a ready-made (still-redaction-worthy) formatter to start from. -#[allow(dead_code)] -fn byte_preview(data: &[u8]) -> String { - const MAX: usize = 120; - let n = data.len().min(MAX); - let mut s: String = data[..n] - .iter() - .map(|&b| { - if b.is_ascii_graphic() || b == b' ' { - b as char - } else { - '.' - } - }) - .collect(); - if data.len() > MAX { - s.push_str("..."); - } - s -} - -// ── Target process ──────────────────────────────────────────────────────────── - -/// Spawn `command` (first element is the executable, rest are args) with -/// `env` (`"KEY=VALUE"` strings) as its entire environment when non-empty -- -/// both arrive over the control channel's `"launch"` request (see module -/// docs), not read from `share_dir` files. The child's stdout/stderr are piped -/// and forwarded (tagged) to our own stderr — not inherited directly — -/// because our stdout is reserved exclusively for the control-channel -/// protocol with the driver. The child's stdin is closed; it isn't part of -/// this channel. -fn spawn_target(command: Vec, env: Vec) -> anyhow::Result { - if command.is_empty() { - anyhow::bail!("launch command must not be empty"); - } - - let mut cmd = tokio::process::Command::new(&command[0]); - cmd.args(&command[1..]); - cmd.stdin(std::process::Stdio::null()); - cmd.stdout(std::process::Stdio::piped()); - cmd.stderr(std::process::Stdio::piped()); - - if !env.is_empty() { - let child_env: Vec<(String, String)> = env - .iter() - .filter(|l| l.contains('=')) - .filter_map(|l| { - let pos = l.find('=')?; - Some((l[..pos].to_string(), l[pos + 1..].to_string())) - }) - .collect(); - eprintln!( - "[openshell-supervisor-relay] using {} child env vars from launch request", - child_env.len() - ); - cmd.env_clear().envs(child_env); - } - - // Not the full command line: it's a control-channel payload (see the - // "launch" handshake in this module's docs) and can carry secrets in - // its arguments (e.g. a token passed via CLI flag) -- log only the - // executable and an argument count, matching how the driver side - // avoids writing agent_command/env to disk for the same reason. - eprintln!( - "[openshell-supervisor-relay] starting program {:?} with {} arg(s)", - command[0], - command.len().saturating_sub(1) - ); - let mut child = cmd.spawn()?; - - if let Some(stdout) = child.stdout.take() { - tokio::spawn(forward_tagged_lines(stdout, "target stdout")); - } - if let Some(stderr) = child.stderr.take() { - tokio::spawn(forward_tagged_lines(stderr, "target stderr")); - } - - Ok(child) -} - -/// Read lines from `reader` and re-emit them on our own stderr, tagged, so -/// the target's output stays visible in the gateway log without touching our -/// stdout (reserved for the control channel). -async fn forward_tagged_lines(reader: impl tokio::io::AsyncRead + Unpin, label: &'static str) { - use tokio::io::{AsyncBufReadExt, BufReader}; - let mut lines = BufReader::new(reader).lines(); - while let Ok(Some(line)) = lines.next_line().await { - eprintln!("[{label}] {line}"); - } -} - -// ── Control channel ─────────────────────────────────────────────────────────── -// -// See module docs for the protocol and why this is safe to run with no -// AppContainer network capability. Runs for the lifetime of the process, -// independent of target/relay state. - -/// Holds the one-shot sender the `"launch"` op fires, carrying `(command, -/// env)` to `main()`. `None` after the first successful launch (or if -/// `main()` already gave up on it) -- a second `"launch"` is rejected. -type LaunchSlot = tokio::sync::Mutex, Vec)>>>; -/// Holds the one-shot sender the `"shutdown"` op fires, waking -/// `run_lifecycle`'s select so it can kill the target and exit. `None` -/// after the first shutdown request -- a second one is a no-op ack. -type ShutdownSlot = tokio::sync::Mutex>>; - -async fn run_control_channel( - launch: Arc, - shutdown: Arc, - forward_sessions: Arc, - target_ready_rx: oneshot::Receiver<()>, -) { - use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; - - let stdin = tokio::io::stdin(); - let mut stdout = tokio::io::stdout(); - - // Announce readiness before entering the request loop: this is the - // signal driver.rs's stdout-reader waits for to know it's safe to send - // "launch" (see module docs' startup handshake). Unsolicited -- no - // correlation id, since it isn't a reply to anything the driver sent. - let ready = serde_json::json!({"event": "ready", "protocol_version": PROTOCOL_VERSION}) - .to_string() - + "\n"; - if stdout.write_all(ready.as_bytes()).await.is_err() || stdout.flush().await.is_err() { - eprintln!("[openshell-supervisor-relay] control channel: failed to announce ready"); - return; - } - eprintln!("[openshell-supervisor-relay] control channel ready (stdin/stdout)"); - - // Read and execute requests independently so a slow target socket write - // or long poll on one forwarding session cannot block shutdown, launch, - // or traffic for another session. Correlation ids make response ordering - // irrelevant; stdout remains single-owner below to prevent interleaving. - let (response_tx, mut response_rx) = tokio::sync::mpsc::unbounded_channel::(); - let request_reader = tokio::spawn({ - let launch = launch.clone(); - let shutdown = shutdown.clone(); - let forward_sessions = forward_sessions.clone(); - async move { - let mut lines = BufReader::new(stdin).lines(); - while let Ok(Some(line)) = lines.next_line().await { - let trimmed = line.trim(); - if trimmed.is_empty() { - continue; - } - eprintln!( - "[openshell-supervisor-relay] control request: {}", - describe_control_request(trimmed) - ); - let request = trimmed.to_string(); - let launch = launch.clone(); - let shutdown = shutdown.clone(); - let forward_sessions = forward_sessions.clone(); - let response_tx = response_tx.clone(); - tokio::spawn(async move { - let mut response = - handle_control_request(&request, &launch, &shutdown, &forward_sessions) - .await - .to_string(); - response.push('\n'); - let _ = response_tx.send(response); - }); - } - eprintln!("[openshell-supervisor-relay] control channel: stdin closed"); - } - }); - - // `None` once fired (or once main()'s sender is dropped without firing, - // e.g. wait_for_port_ready failed) -- the `if` guard below then disables - // that select arm instead of it firing repeatedly on every subsequent - // poll of an already-resolved oneshot. - let mut target_ready_rx = Some(target_ready_rx); - - loop { - tokio::select! { - response = response_rx.recv() => { - let Some(out) = response else { break }; - if stdout.write_all(out.as_bytes()).await.is_err() || stdout.flush().await.is_err() { - eprintln!("[openshell-supervisor-relay] control channel write failed"); - break; - } - } - // See module docs' startup handshake -- distinct from the - // "launch" response, which only confirms the command/env - // arrived. Unsolicited, like "ready" above. - result = async { target_ready_rx.as_mut().unwrap().await }, if target_ready_rx.is_some() => { - target_ready_rx = None; - if result.is_ok() { - let event = serde_json::json!({"event": "target_ready"}).to_string() + "\n"; - if stdout.write_all(event.as_bytes()).await.is_err() || stdout.flush().await.is_err() { - eprintln!("[openshell-supervisor-relay] control channel: failed to announce target_ready"); - } - } - // A dropped sender (main() bailed before the target ever - // came up, e.g. wait_for_port_ready's own error) means - // there's nothing to announce -- driver.rs's timeout on the - // corresponding event will surface that as a launch failure - // on its own. - } - } - } - request_reader.abort(); -} - -/// `forward` opens a new, independent relay bridge for a target port, e.g. -/// for `openshell forward service`. -/// -/// `data`: `{"relay_addr": "", "target_port": }` -- the -/// caller (the driver) has already started a fresh relay listener on the -/// gateway side for this one request and tells us its address here; we dial -/// out to it (Phase A). Replies once Phase A actually connects (or on -/// failure/timeout), so the caller knows whether the bridge is really usable -/// before it starts sending Phase B clients at the relay address it created. -/// -/// No explicit "stop" for this bridge: it runs until Phase A closes, which -/// happens when the caller drops its relay listener (the gRPC forward -/// stream ending) -- see the driver-side `ForwardSink::open_dynamic_forward`. -/// Summarizes an inbound control-channel request for logging as `op=... id=...` -/// -- deliberately never includes `data`, since `launch` (and, in principle, -/// `echo`) can carry secrets. Falls back to a fixed placeholder rather than -/// printing anything from `line` if it doesn't even parse, so a malformed -/// request can't smuggle sensitive-looking text into the log via a JSON -/// parse failure either. -fn describe_control_request(line: &str) -> String { - serde_json::from_str::(line).map_or_else( - |_| "".to_string(), - |v| { - let op = v.get("op").and_then(|x| x.as_str()).unwrap_or(""); - let id = v.get("id").cloned().unwrap_or(serde_json::Value::Null); - format!("op={op} id={id}") - }, - ) -} - -async fn handle_control_request( - line: &str, - launch: &LaunchSlot, - shutdown: &ShutdownSlot, - forward_sessions: &ForwardSessions, -) -> serde_json::Value { - use tokio::io::{AsyncReadExt, AsyncWriteExt}; - - let req: serde_json::Value = match serde_json::from_str(line) { - Ok(v) => v, - Err(e) => { - return serde_json::json!({"id": null, "ok": false, "error": format!("parse error: {e}")}); - } - }; - let id = req.get("id").cloned().unwrap_or(serde_json::Value::Null); - let op = req.get("op").and_then(|v| v.as_str()).unwrap_or(""); - - match op { - // Driver sends this on sandbox delete instead of writing a - // openshell-shutdown.signal file -- wakes run_lifecycle's select so - // it can kill the target and exit. Acked even on a repeat (the - // slot's already empty by then), since the driver's request has a - // short timeout and shouldn't be left hanging either way. - "shutdown" => { - let slot = shutdown.lock().await.take(); - if let Some(tx) = slot { - let _ = tx.send(()); - } - serde_json::json!({"id": id, "ok": true}) - } - // See module docs' startup handshake: sent once, right after the - // "ready" event, carrying the real command/env instead of them - // being written to share_dir as agent-cmd.txt/agent-env.txt. - "launch" => { - let data = req.get("data").cloned().unwrap_or(serde_json::Value::Null); - let command: Vec = data - .get("command") - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|v| v.as_str().map(str::to_string)) - .collect() - }) - .unwrap_or_default(); - if command.is_empty() { - return serde_json::json!({ - "id": id, "ok": false, - "error": "launch requires data.command (non-empty array of strings)" - }); - } - let env: Vec = data - .get("env") - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|v| v.as_str().map(str::to_string)) - .collect() - }) - .unwrap_or_default(); - - let slot = launch.lock().await.take(); - slot.map_or_else( - || serde_json::json!({"id": id, "ok": false, "error": "launch already requested"}), - |tx| { - let _ = tx.send((command, env)); - serde_json::json!({"id": id, "ok": true}) - }, - ) - } - "ping" => serde_json::json!({"id": id, "ok": true, "data": "pong"}), - "echo" => { - let data = req.get("data").cloned().unwrap_or(serde_json::Value::Null); - serde_json::json!({"id": id, "ok": true, "data": data}) - } - "forward_open" => { - let data = req.get("data").cloned().unwrap_or(serde_json::Value::Null); - let session_id = data - .get("session_id") - .and_then(|v| v.as_str()) - .filter(|v| v.len() == 64 && v.bytes().all(|b| b.is_ascii_hexdigit())) - .map(str::to_string); - let target_port = data - .get("target_port") - .and_then(serde_json::Value::as_u64) - .and_then(|n| u16::try_from(n).ok()); - let (Some(session_id), Some(target_port)) = (session_id, target_port) else { - return serde_json::json!({ - "id": id, "ok": false, - "error": "forward_open requires a 64-character hexadecimal session_id and target_port (u16)" - }); - }; - if forward_sessions.lock().await.contains_key(&session_id) { - return serde_json::json!({ - "id": id, "ok": false, "error": "forward session already exists" - }); - } - let stream = match connect_forward_target(|| { - tokio::net::TcpStream::connect(("127.0.0.1", target_port)) - }) - .await - { - Ok(stream) => stream, - Err(error) => { - return serde_json::json!({ - "id": id, "ok": false, - "error": format!("target connect failed: {error}") - }); - } - }; - if let Err(error) = stream.set_nodelay(true) { - eprintln!( - "[openshell-supervisor-relay] failed to set TCP_NODELAY on target connection: {error}" - ); - } - let (reader, writer) = stream.into_split(); - forward_sessions.lock().await.insert( - session_id, - Arc::new(ForwardSession { - reader: tokio::sync::Mutex::new(reader), - writer: tokio::sync::Mutex::new(writer), - }), - ); - serde_json::json!({"id": id, "ok": true}) - } - "forward_write" => { - let data = req.get("data").cloned().unwrap_or(serde_json::Value::Null); - let session_id = data.get("session_id").and_then(|v| v.as_str()); - let encoded = data.get("bytes").and_then(|v| v.as_str()); - let (Some(session_id), Some(encoded)) = (session_id, encoded) else { - return serde_json::json!({ - "id": id, "ok": false, - "error": "forward_write requires session_id and base64 bytes" - }); - }; - if encoded.len() > 16_384 { - return serde_json::json!({"id": id, "ok": false, "error": "forward_write chunk is too large"}); - } - let bytes = match base64::engine::general_purpose::STANDARD.decode(encoded) { - Ok(bytes) => bytes, - Err(error) => { - return serde_json::json!({ - "id": id, "ok": false, "error": format!("invalid forward bytes: {error}") - }); - } - }; - let session = forward_sessions.lock().await.get(session_id).cloned(); - let Some(session) = session else { - return serde_json::json!({"id": id, "ok": false, "error": "forward session not found"}); - }; - match session.writer.lock().await.write_all(&bytes).await { - Ok(()) => serde_json::json!({"id": id, "ok": true}), - Err(error) => { - serde_json::json!({"id": id, "ok": false, "error": format!("target write failed: {error}")}) - } - } - } - "forward_read" => { - let data = req.get("data").cloned().unwrap_or(serde_json::Value::Null); - let Some(session_id) = data.get("session_id").and_then(|v| v.as_str()) else { - return serde_json::json!({"id": id, "ok": false, "error": "forward_read requires session_id"}); - }; - let session = forward_sessions.lock().await.get(session_id).cloned(); - let Some(session) = session else { - return serde_json::json!({"id": id, "ok": false, "error": "forward session not found"}); - }; - let mut bytes = vec![0_u8; 8192]; - match tokio::time::timeout( - Duration::from_millis(100), - session.reader.lock().await.read(&mut bytes), - ) - .await - { - Ok(Ok(0)) => { - serde_json::json!({"id": id, "ok": true, "data": {"bytes": "", "eof": true}}) - } - Ok(Ok(n)) => { - let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes[..n]); - serde_json::json!({"id": id, "ok": true, "data": {"bytes": encoded, "eof": false}}) - } - Ok(Err(error)) => { - serde_json::json!({"id": id, "ok": false, "error": format!("target read failed: {error}")}) - } - Err(_) => { - serde_json::json!({"id": id, "ok": true, "data": {"bytes": "", "eof": false}}) - } - } - } - "forward_shutdown" => { - let data = req.get("data").cloned().unwrap_or(serde_json::Value::Null); - let Some(session_id) = data.get("session_id").and_then(|v| v.as_str()) else { - return serde_json::json!({"id": id, "ok": false, "error": "forward_shutdown requires session_id"}); - }; - let session = forward_sessions.lock().await.get(session_id).cloned(); - let Some(session) = session else { - return serde_json::json!({"id": id, "ok": false, "error": "forward session not found"}); - }; - match session.writer.lock().await.shutdown().await { - Ok(()) => serde_json::json!({"id": id, "ok": true}), - Err(error) => { - serde_json::json!({"id": id, "ok": false, "error": format!("target shutdown failed: {error}")}) - } - } - } - "forward_close" => { - let data = req.get("data").cloned().unwrap_or(serde_json::Value::Null); - let Some(session_id) = data.get("session_id").and_then(|v| v.as_str()) else { - return serde_json::json!({"id": id, "ok": false, "error": "forward_close requires session_id"}); - }; - forward_sessions.lock().await.remove(session_id); - serde_json::json!({"id": id, "ok": true}) - } - "forward" => { - let data = req.get("data").cloned().unwrap_or(serde_json::Value::Null); - let relay_addr = data - .get("relay_addr") - .and_then(|v| v.as_str()) - .map(str::to_string); - let target_port = data - .get("target_port") - .and_then(serde_json::Value::as_u64) - .and_then(|n| u16::try_from(n).ok()); - // Proves to the relay (a loopback-bound listener any other local - // process could otherwise race to connect to first) that this - // is the real Phase A peer -- see openshell-driver-mxc's - // relay.rs module docs. Sent verbatim as the WS auth message - // below, not decoded here; the driver and the relay agree on - // the hex encoding independently. - let nonce = data - .get("nonce") - .and_then(|v| v.as_str()) - .map(str::to_string); - - let (Some(relay_addr), Some(target_port), Some(nonce)) = - (relay_addr, target_port, nonce) - else { - return serde_json::json!({ - "id": id, "ok": false, - "error": "forward requires data.relay_addr (string), data.target_port (u16), and data.nonce (string)" - }); - }; - - let (ready_tx, ready_rx) = oneshot::channel::>(); - - eprintln!( - "[openshell-supervisor-relay] forward: dynamic bridge ws://{relay_addr} <-> 127.0.0.1:{target_port}" - ); - tokio::spawn(run_relay_bridge(relay_addr, target_port, nonce, ready_tx)); - - match tokio::time::timeout(Duration::from_secs(5), ready_rx).await { - Ok(Ok(Ok(()))) => { - serde_json::json!({"id": id, "ok": true, "data": {"target_port": target_port}}) - } - Ok(Ok(Err(e))) => serde_json::json!({"id": id, "ok": false, "error": e}), - Ok(Err(_)) => { - serde_json::json!({"id": id, "ok": false, "error": "relay bridge task dropped"}) - } - Err(_) => serde_json::json!({ - "id": id, "ok": false, "error": "timed out waiting for relay connection" - }), - } - } - _ => serde_json::json!({"id": id, "ok": false, "error": format!("unknown op: {op}")}), - } -} - -/// Bound session opening below the driver's 10-second control-request timeout. -/// Five one-second attempts plus four 300ms retry delays take at most 6.2s -/// of timer budget. A stuck connect is cancelled before another is attempted. -async fn connect_forward_target(mut connect: F) -> std::io::Result -where - F: FnMut() -> Fut, - Fut: Future>, -{ - let mut last_error = std::io::Error::from(std::io::ErrorKind::TimedOut); - for attempt in 1..=5 { - match tokio::time::timeout(Duration::from_secs(1), connect()).await { - Ok(Ok(stream)) => return Ok(stream), - Ok(Err(error)) => last_error = error, - Err(_) => last_error = std::io::Error::from(std::io::ErrorKind::TimedOut), - } - if attempt < 5 { - tokio::time::sleep(Duration::from_millis(300)).await; - } - } - Err(last_error) -} - -/// Number of full-budget tries `wait_for_port_ready` makes -- each try gets -/// its own complete `per_try_timeout` window, not a slice of it. Worst case -/// total wait is `max_tries * per_try_timeout` (3 * 60s = 180s today). -const PORT_READY_MAX_TRIES: u32 = 3; - -/// Poll for the target port accepting TCP connections, bailing out early -/// (rather than waiting out all tries) if the child process exits first — a -/// dead child will never open the port, so there's no reason to wait. Makes -/// up to `PORT_READY_MAX_TRIES` tries, each given the full `per_try_timeout` -/// budget, logging the start of every try so a long cold-start wait (Node.js -/// first-run JIT/module resolution, first-touch AV scan of freshly staged -/// binaries, etc.) is visible rather than silent until success or final -/// timeout. -async fn wait_for_port_ready( - child: &mut tokio::process::Child, - port: u16, - per_try_timeout: Duration, -) -> anyhow::Result<()> { - // Bound each individual connect attempt: on at least one observed - // wxc-exec build, a connect() against a not-yet-listening loopback port - // inside the AppContainer never resolved at all (no fast ECONNREFUSED, - // no error) instead of failing quickly like an ordinary closed-port - // connect. Without this, a single early attempt can hang forever and - // this function -- and the whole readiness wait -- never returns even - // after the target's port genuinely opens, since nothing ever retries. - const ATTEMPT_TIMEOUT: Duration = Duration::from_secs(2); - - let overall_start = tokio::time::Instant::now(); - for try_num in 1..=PORT_READY_MAX_TRIES { - eprintln!( - "[openshell-supervisor-relay] port readiness try {try_num}/{PORT_READY_MAX_TRIES} (up to {per_try_timeout:?}) for port {port}" - ); - let try_deadline = tokio::time::Instant::now() + per_try_timeout; - loop { - let attempt = tokio::time::timeout( - ATTEMPT_TIMEOUT, - tokio::net::TcpStream::connect(("127.0.0.1", port)), - ) - .await; - if let Ok(Ok(_)) = attempt { - eprintln!( - "[openshell-supervisor-relay] port {port} ready after {:?} (try {try_num}/{PORT_READY_MAX_TRIES})", - overall_start.elapsed() - ); - return Ok(()); - } - if let Ok(Some(status)) = child.try_wait() { - anyhow::bail!("target process exited before port {port} came up: {status}"); - } - if tokio::time::Instant::now() >= try_deadline { - eprintln!( - "[openshell-supervisor-relay] port readiness try {try_num}/{PORT_READY_MAX_TRIES} timed out after {per_try_timeout:?} (elapsed {:?} total)", - overall_start.elapsed() - ); - break; - } - tokio::time::sleep(Duration::from_millis(300)).await; - } - } - anyhow::bail!( - "timed out after {PORT_READY_MAX_TRIES} tries ({:?} total) waiting for port {port}", - overall_start.elapsed() - ); -} - -// ── Relay bridge ────────────────────────────────────────────────────────────── -// -// Implements the sandbox side of relay.rs's Phase A protocol exactly: -// -// TEXT "SESSION_START" — relay opened a new Phase B TCP connection; open a -// FRESH raw TCP connection to the target and start -// forwarding its bytes back as BINARY frames. -// BINARY — bytes from the Phase B TCP stream; write them -// as-is to the active target connection. -// TEXT "SESSION_END" — Phase B TCP connection closed; drop the target -// connection. -// WS Close — relay shutting down. -// -// This is a raw byte tunnel, not a WS-to-WS message bridge: each session gets -// its own genuine TCP connection to the target with bytes passed through -// untouched, so the host's own protocol (e.g. a real WS handshake it performs -// against the tunnel) reaches the target exactly as sent. A single persistent -// connection re-used across sessions, forwarding opaque message payloads, -// would not preserve that — the target would never see a valid handshake. - -async fn run_relay_bridge( - relay_addr: String, - port: u16, - nonce: String, - ready_tx: oneshot::Sender>, -) { - use tokio::io::{AsyncReadExt, AsyncWriteExt}; - - let relay_url = format!("ws://{relay_addr}"); - - // The gateway already bound this listener before sending us the - // "forward" request, so it's expected to be immediately reachable -- - // single attempt, no retry needed here. - // - // Connect the raw TCP socket ourselves rather than letting - // tokio_tungstenite::connect_async do it internally, so TCP_NODELAY can - // be set before the WS handshake: this is a latency-sensitive - // request/response tunnel, including on loopback, and small WS frames - // can otherwise stall behind delayed ACK behavior. Best-effort -- - // failure to set it doesn't fail the connection, just costs a bit of - // latency. - let tcp = match tokio::net::TcpStream::connect(&relay_addr).await { - Ok(stream) => { - if let Err(e) = stream.set_nodelay(true) { - eprintln!( - "[openshell-supervisor-relay] failed to set TCP_NODELAY on relay connection: {e}" - ); - } - stream - } - Err(e) => { - let msg = format!("relay connect failed: {e}"); - eprintln!("[openshell-supervisor-relay] {msg}"); - let _ = ready_tx.send(Err(msg)); - return; - } - }; - let relay_ws = match tokio_tungstenite::client_async(&relay_url, tcp).await { - Ok((ws, _)) => { - eprintln!("[openshell-supervisor-relay] relay connected: {relay_url}"); - ws - } - Err(e) => { - let msg = format!("relay handshake failed: {e}"); - eprintln!("[openshell-supervisor-relay] {msg}"); - let _ = ready_tx.send(Err(msg)); - return; - } - }; - let (mut relay_write, mut relay_read) = relay_ws.split(); - - // Must be the very first message: the relay won't trust anything else - // from this connection (including SESSION_START/BINARY frames) until - // this matches -- see relay.rs module docs. - if let Err(e) = relay_write - .send(Message::Text(format!("AUTH:{nonce}").into())) - .await - { - let msg = format!("relay auth send failed: {e}"); - eprintln!("[openshell-supervisor-relay] {msg}"); - let _ = ready_tx.send(Err(msg)); - return; - } - - eprintln!("[openshell-supervisor-relay] relay bridge active"); - let _ = ready_tx.send(Ok(())); - - let mut session: Option = None; - let mut read_buf = vec![0u8; 8192]; - // Byte counters, reset per session -- mirror the instrumentation in - // relay.rs. Together the two sides let a hung request be localized to a - // specific hop instead of just "the client timed out": relay.rs's - // host_to_sandbox_* should match this session's phase_a_to_target_* - // (same bytes, different name each side of the WS tunnel), and - // target_to_phase_a_* should match relay.rs's sandbox_to_host_*. A - // mismatch or a stuck-at-zero counter on one side pinpoints exactly - // where bytes stop moving. No payload content is ever logged -- see the - // module-level note on `byte_preview`. - let mut phase_a_to_target_bytes: u64 = 0; - let mut phase_a_to_target_chunks: u64 = 0; - let mut target_to_phase_a_bytes: u64 = 0; - let mut target_to_phase_a_chunks: u64 = 0; - - loop { - let session_read = - futures::future::OptionFuture::from(session.as_mut().map(|s| s.read(&mut read_buf))); - - tokio::select! { - msg = relay_read.next() => match msg { - Some(Ok(Message::Text(t))) => { - if t == "SESSION_START" { - // Retry briefly: even though wait_for_port_ready() already - // confirmed a raw TCP accept succeeds once, that doesn't - // guarantee the target's listener stays continuously - // accept-ready under a freshly-started process (observed as - // a genuine, reproducible ~500ms startup race elsewhere in - // this codebase -- see mxc-ws-agent.rs's local-connect - // retry). A session-open failure here would otherwise - // silently drop the host's connection attempt. - match connect_forward_target(|| tokio::net::TcpStream::connect(("127.0.0.1", port))).await { - Ok(s) => { - // Latency-sensitive request/response tunnel -- - // see the matching comment on the relay - // connection above. Best-effort. - if let Err(e) = s.set_nodelay(true) { - eprintln!("[openshell-supervisor-relay] failed to set TCP_NODELAY on target connection: {e}"); - } - eprintln!("[openshell-supervisor-relay] session start -- connected to target 127.0.0.1:{port}"); - session = Some(s); - phase_a_to_target_bytes = 0; - phase_a_to_target_chunks = 0; - target_to_phase_a_bytes = 0; - target_to_phase_a_chunks = 0; - } - Err(last_err) => { - eprintln!("[openshell-supervisor-relay] session start -- target connect failed: {last_err}"); - // Tell the relay so it can close the host's - // TCP connection promptly instead of leaving - // it hanging until the client's own timeout. - let _ = relay_write - .send(Message::Text(format!("SESSION_FAILED:{last_err}").into())) - .await; - } - } - } else if t == "SESSION_END" { - eprintln!("[openshell-supervisor-relay] session end phase_a_to_target_bytes={phase_a_to_target_bytes} phase_a_to_target_chunks={phase_a_to_target_chunks} target_to_phase_a_bytes={target_to_phase_a_bytes} target_to_phase_a_chunks={target_to_phase_a_chunks}"); - session = None; - } - } - Some(Ok(Message::Binary(b))) => { - if let Some(s) = session.as_mut() { - phase_a_to_target_chunks += 1; - phase_a_to_target_bytes += b.len() as u64; - if phase_a_to_target_chunks == 1 { - eprintln!("[openshell-supervisor-relay] first phase-A->target chunk: {} bytes", b.len()); - } - if s.write_all(&b).await.is_err() { - eprintln!("[openshell-supervisor-relay] target write failed"); - session = None; - } - } else { - eprintln!("[openshell-supervisor-relay] BINARY with no active session (dropped {} bytes)", b.len()); - } - } - Some(Ok(Message::Close(_))) | None => { - eprintln!("[openshell-supervisor-relay] relay closed"); - break; - } - Some(Ok(_)) => {} // ping/pong handled by tungstenite - Some(Err(e)) => { - eprintln!("[openshell-supervisor-relay] relay read error: {e}"); - break; - } - }, - Some(result) = session_read => { - match result { - Ok(0) => { - eprintln!("[openshell-supervisor-relay] target connection closed phase_a_to_target_bytes={phase_a_to_target_bytes} phase_a_to_target_chunks={phase_a_to_target_chunks} target_to_phase_a_bytes={target_to_phase_a_bytes} target_to_phase_a_chunks={target_to_phase_a_chunks}"); - session = None; - // Tell the relay so it can close the host's Phase B - // TCP connection promptly (see relay.rs's handling - // of this message) instead of leaving the host - // client waiting for more bytes until its own - // timeout -- the target won't send any more. - let _ = relay_write.send(Message::Text("SESSION_END".into())).await; - } - Ok(n) => { - target_to_phase_a_chunks += 1; - target_to_phase_a_bytes += n as u64; - if target_to_phase_a_chunks == 1 { - eprintln!("[openshell-supervisor-relay] first target->phase-A chunk: {n} bytes"); - } - if relay_write.send(Message::Binary(read_buf[..n].to_vec().into())).await.is_err() { - break; - } - } - Err(e) => { - eprintln!("[openshell-supervisor-relay] target read error: {e}"); - session = None; - } - } - } - } - } - eprintln!( - "[openshell-supervisor-relay] relay bridge stopped phase_a_to_target_bytes={phase_a_to_target_bytes} phase_a_to_target_chunks={phase_a_to_target_chunks} target_to_phase_a_bytes={target_to_phase_a_bytes} target_to_phase_a_chunks={target_to_phase_a_chunks}" - ); -} - -// ── Lifecycle ───────────────────────────────────────────────────────────────── - -/// Wait for the target process to exit or a "shutdown" control-channel -/// request to arrive (see `handle_control_request`), whichever comes first. -/// Any active dynamic relay bridges are tokio tasks in this same process, so -/// `std::process::exit` below tears them down too -- no separate stop signal -/// needed. -async fn run_lifecycle(mut child: tokio::process::Child, shutdown_rx: oneshot::Receiver<()>) { - tokio::select! { - status = child.wait() => { - let code = status.map_or(1, |s| s.code().unwrap_or(1)); - eprintln!("[openshell-supervisor-relay] target exited with code {code}"); - std::process::exit(code); - } - _ = shutdown_rx => { - eprintln!("[openshell-supervisor-relay] shutdown request -- stopping"); - let _ = child.kill().await; - let _ = child.wait().await; - eprintln!("[openshell-supervisor-relay] done"); - std::process::exit(0); - } - } -} - -#[cfg(test)] -mod forward_connect_tests { - use super::connect_forward_target; - use std::io::{Error, ErrorKind}; - use std::sync::Arc; - use std::sync::atomic::{AtomicUsize, Ordering}; - use std::time::Duration; - - #[tokio::test(start_paused = true)] - async fn forward_connect_returns_success_without_retry_delay() { - let start = tokio::time::Instant::now(); - let result = connect_forward_target(|| std::future::ready(Ok(42))).await; - assert_eq!(result.unwrap(), 42); - assert_eq!(start.elapsed(), Duration::ZERO); - } - - #[tokio::test(start_paused = true)] - async fn forward_connect_retries_refused_connections_and_preserves_last_error() { - let mut attempts = 0; - let start = tokio::time::Instant::now(); - let result = connect_forward_target(|| { - attempts += 1; - std::future::ready(Err::<(), _>(Error::new( - ErrorKind::ConnectionRefused, - attempts.to_string(), - ))) - }) - .await; - assert_eq!(attempts, 5); - assert_eq!(result.unwrap_err().kind(), ErrorKind::ConnectionRefused); - assert_eq!(start.elapsed(), Duration::from_millis(1200)); - } - - #[tokio::test(start_paused = true)] - async fn forward_connect_cancels_stuck_attempts_before_driver_timeout() { - struct Cancelled(Arc); - impl Drop for Cancelled { - fn drop(&mut self) { - self.0.fetch_add(1, Ordering::SeqCst); - } - } - let cancelled = Arc::new(AtomicUsize::new(0)); - let start = tokio::time::Instant::now(); - let result = connect_forward_target(|| { - let guard = Cancelled(cancelled.clone()); - async move { - let _guard = guard; - std::future::pending::>().await - } - }) - .await; - assert_eq!(result.unwrap_err().kind(), ErrorKind::TimedOut); - assert_eq!(cancelled.load(Ordering::SeqCst), 5); - assert_eq!(start.elapsed(), Duration::from_millis(6200)); - assert!(start.elapsed() < Duration::from_secs(10)); - } - - #[tokio::test(start_paused = true)] - async fn forward_connect_recovers_after_a_stuck_attempt() { - let mut attempts = 0; - let start = tokio::time::Instant::now(); - let result = connect_forward_target(|| { - attempts += 1; - let attempt = attempts; - async move { - if attempt == 1 { - std::future::pending::<()>().await; - } - Ok(42) - } - }) - .await; - assert_eq!(result.unwrap(), 42); - assert_eq!(attempts, 2); - assert_eq!(start.elapsed(), Duration::from_millis(1300)); - } -} diff --git a/crates/openshell-supervisor-relay/src/main.rs b/crates/openshell-supervisor-relay/src/main.rs deleted file mode 100644 index c1117b248b..0000000000 --- a/crates/openshell-supervisor-relay/src/main.rs +++ /dev/null @@ -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); -} diff --git a/crates/openshell-supervisor-relay/tests/control_channel_contract.rs b/crates/openshell-supervisor-relay/tests/control_channel_contract.rs deleted file mode 100644 index 85c11574e7..0000000000 --- a/crates/openshell-supervisor-relay/tests/control_channel_contract.rs +++ /dev/null @@ -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>, - // `None` for `spawn()` (stderr is discarded there -- see its doc - // comment). `spawn_capturing_stderr()` populates this so a test can - // deterministically wait for a specific diagnostic line instead of - // guessing a sleep duration. - stderr_lines: Option>, -} - -impl RelayProcess { - /// Spawn the real binary. `target_port` is the CLI arg the binary - /// expects (its own liveness-check port for whatever `launch` later - /// starts) -- irrelevant to tests that never send `launch`. - async fn spawn(target_port: u16) -> Self { - let mut child = Command::new(env!("CARGO_BIN_EXE_openshell-supervisor-relay")) - .arg(target_port.to_string()) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - // Not read by these tests -- null rather than piped-and-ignored - // so the child can never block on a full stderr pipe buffer. - .stderr(Stdio::null()) - .kill_on_drop(true) - .spawn() - .expect("spawn openshell-supervisor-relay.exe (build it first: cargo build -p openshell-supervisor-relay)"); - let stdin = child.stdin.take().expect("piped stdin"); - let stdout = child.stdout.take().expect("piped stdout"); - Self { - child, - stdin, - lines: BufReader::new(stdout).lines(), - stderr_lines: None, - } - } - - /// Like `spawn`, but pipes stderr instead of discarding it, so a test - /// can wait for a specific diagnostic line via `wait_for_stderr_line`. - /// A background task drains it continuously for the process's entire - /// lifetime (forwarding every line over an unbounded channel) -- reading - /// only until the sought-after line arrives and then stopping (as an - /// earlier version of this helper did) leaves the pipe unread from then - /// on; this process's own ongoing diagnostic output (plus anything the - /// launched target itself prints, forwarded through it) then fills the - /// OS pipe buffer and makes its *next* `eprintln!` block synchronously - /// forever -- including ones on the exact shutdown path a test wants to - /// observe. Confirmed by hand: without continuous draining, this - /// deadlocks the relay process itself, not just this test. - async fn spawn_capturing_stderr(target_port: u16) -> Self { - let mut child = Command::new(env!("CARGO_BIN_EXE_openshell-supervisor-relay")) - .arg(target_port.to_string()) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .kill_on_drop(true) - .spawn() - .expect("spawn openshell-supervisor-relay.exe (build it first: cargo build -p openshell-supervisor-relay)"); - let stdin = child.stdin.take().expect("piped stdin"); - let stdout = child.stdout.take().expect("piped stdout"); - let stderr = child.stderr.take().expect("piped stderr"); - let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); - tokio::spawn(async move { - let mut lines = BufReader::new(stderr).lines(); - while let Ok(Some(line)) = lines.next_line().await { - if tx.send(line).is_err() { - break; - } - } - }); - Self { - child, - stdin, - lines: BufReader::new(stdout).lines(), - stderr_lines: Some(rx), - } - } - - /// Consume forwarded stderr lines (discarding non-matching ones) until - /// one contains `pattern`, or `timeout` elapses. `timeout` is - /// deliberately a caller argument, not the shared `TIMEOUT` constant -- - /// spawning a real OS process for this to wait on can occasionally take - /// far longer than this file's other, purely protocol-level waits - /// (observed on this host: real-time AV scanning stalling - /// `CreateProcess` well past 10s, unrelated to anything this binary - /// controls). - async fn wait_for_stderr_line(&mut self, pattern: &str, timeout: Duration) { - let rx = self - .stderr_lines - .as_mut() - .expect("wait_for_stderr_line requires spawn_capturing_stderr"); - tokio::time::timeout(timeout, async { - loop { - match rx.recv().await { - Some(line) if line.contains(pattern) => return, - Some(_) => {} - None => { - panic!("relay stderr closed before printing a line containing {pattern:?}") - } - } - } - }) - .await - .unwrap_or_else(|_| { - panic!("timed out after {timeout:?} waiting for a stderr line containing {pattern:?}") - }); - } - - async fn next_line(&mut self) -> String { - tokio::time::timeout(TIMEOUT, self.lines.next_line()) - .await - .expect("timed out waiting for a control-channel line") - .expect("stdout read error") - .expect("relay exited before producing the expected line") - } - - async fn next_json(&mut self) -> Value { - let line = self.next_line().await; - serde_json::from_str(&line) - .unwrap_or_else(|e| panic!("non-JSON control-channel line {line:?}: {e}")) - } - - async fn send(&mut self, value: Value) { - let mut line = serde_json::to_string(&value).expect("serialize request"); - line.push('\n'); - self.stdin - .write_all(line.as_bytes()) - .await - .expect("write control-channel request"); - self.stdin - .flush() - .await - .expect("flush control-channel request"); - } - - /// Consume and validate the startup handshake event -- see - /// `control_channel::try_route_ready` on the driver side, which this - /// mirrors. - async fn expect_ready(&mut self) { - let v = self.next_json().await; - assert_eq!(v["event"], "ready"); - assert_eq!(v["protocol_version"], 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:` -- matching what -/// `run_relay_bridge` in `imp.rs` sends. Panics (failing the test) if -/// anything else arrives first, same as a real relay would just silently -/// distrust and drop the connection. -async fn accept_and_authenticate( - listener: &TcpListener, - expected_nonce: &str, -) -> WebSocketStream { - let (stream, _addr) = tokio::time::timeout(TIMEOUT, listener.accept()) - .await - .expect("timed out waiting for the relay's Phase A connection") - .expect("accept failed"); - let mut ws = tokio_tungstenite::accept_async(stream) - .await - .expect("WS upgrade failed"); - let msg = tokio::time::timeout(TIMEOUT, ws.next()) - .await - .expect("timed out waiting for the AUTH message") - .expect("relay closed before sending AUTH") - .expect("WS read error"); - let expected = format!("AUTH:{expected_nonce}"); - match msg { - Message::Text(t) if t == expected => {} - other => panic!("expected {expected:?} as the first message, got {other:?}"), - } - ws -} - -// ── Startup handshake ──────────────────────────────────────────────────── - -#[tokio::test(flavor = "multi_thread")] -async fn ready_event_reports_protocol_version() { - let mut relay = RelayProcess::spawn(0).await; - relay.expect_ready().await; -} - -// ── ping / echo (protocol sanity, no launch required) ────────────────────── - -#[tokio::test(flavor = "multi_thread")] -async fn ping_and_echo_round_trip() { - let mut relay = RelayProcess::spawn(0).await; - relay.expect_ready().await; - - relay.send(json!({"id": 1, "op": "ping"})).await; - assert_eq!( - relay.next_json().await, - json!({"id": 1, "ok": true, "data": "pong"}) - ); - - relay - .send(json!({"id": 2, "op": "echo", "data": {"x": 1, "y": "two"}})) - .await; - let resp = relay.next_json().await; - assert_eq!(resp["id"], 2); - assert_eq!(resp["ok"], true); - assert_eq!(resp["data"], json!({"x": 1, "y": "two"})); -} - -// ── launch: success and failure ───────────────────────────────────────── - -#[tokio::test(flavor = "multi_thread")] -async fn launch_fails_fast_when_command_is_empty() { - let mut relay = RelayProcess::spawn(0).await; - relay.expect_ready().await; - - let resp = relay.launch(1, &[]).await; - assert_eq!(resp["id"], 1); - assert_eq!(resp["ok"], false); - assert!( - resp["error"].as_str().unwrap().contains("non-empty array"), - "unexpected error message: {resp}" - ); -} - -#[tokio::test(flavor = "multi_thread")] -async fn launch_success_then_target_ready_ordering() { - // Reserve a free port, then launch a target that binds exactly it -- - // small a-priori race (something else could steal the port between the - // bind-and-drop below and the launch), acceptable for a test. - let port = { - let l = TcpListener::bind("127.0.0.1:0").await.unwrap(); - l.local_addr().unwrap().port() - }; - let script = format!( - "$l=[System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback,{port}); \ - $l.Start(); Start-Sleep -Seconds 30" - ); - - let mut relay = RelayProcess::spawn(port).await; - relay.expect_ready().await; - - let ack = relay - .launch( - 1, - &[ - "powershell", - "-NoProfile", - "-NonInteractive", - "-Command", - &script, - ], - ) - .await; - assert_eq!(ack["id"], 1); - assert_eq!(ack["ok"], true, "launch ack: {ack}"); - - // The "launch" response only confirms the command/env arrived -- the - // unsolicited "target_ready" event (no correlation id) is the actual - // liveness confirmation once the port readiness poll succeeds, and it - // must not have been sent already (it can't have been: nothing before - // this point in the protocol lets the spawner know the port bound). - // Reading it as the very next line asserts the ordering directly. - let target_ready = relay.next_json().await; - assert_eq!(target_ready["event"], "target_ready"); - assert!( - target_ready.get("id").is_none(), - "target_ready must not carry a correlation id" - ); -} - -#[tokio::test(flavor = "multi_thread")] -async fn shutdown_is_acked_and_the_process_exits() { - let port = { - let l = TcpListener::bind("127.0.0.1:0").await.unwrap(); - l.local_addr().unwrap().port() - }; - let script = format!( - "$l=[System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback,{port}); \ - $l.Start(); Start-Sleep -Seconds 30" - ); - - let mut relay = RelayProcess::spawn(port).await; - relay.expect_ready().await; - let ack = relay - .launch( - 1, - &[ - "powershell", - "-NoProfile", - "-NonInteractive", - "-Command", - &script, - ], - ) - .await; - assert_eq!(ack["ok"], true); - let target_ready = relay.next_json().await; - assert_eq!(target_ready["event"], "target_ready"); - - relay.send(json!({"id": 2, "op": "shutdown"})).await; - let ack = relay.next_json().await; - assert_eq!(ack, json!({"id": 2, "ok": true})); - - let status = tokio::time::timeout(TIMEOUT, relay.child.wait()) - .await - .expect("relay did not exit within the timeout after shutdown") - .expect("wait() failed"); - assert!(status.success(), "expected a clean exit, got {status:?}"); -} - -/// Reproduces the reported race: a "shutdown" request arriving while the -/// target is still coming up (here, one that never binds the port at all) -/// must stop this process promptly, not leave it waiting out the full -/// port-readiness budget (~300s worst case, see `wait_for_port_ready`) -/// before ever observing the shutdown that `run_control_channel` already -/// acknowledged. Without racing that wait against shutdown, the final -/// `child.wait()` below would time out instead of completing within -/// `SHUTDOWN_TIMEOUT`. -/// -/// Waits for the relay's own "waiting for target on ..." stderr line -/// before sending shutdown, rather than sending it right after the launch -/// ack -- the ack fires as soon as the request is parsed, before this -/// process's own `spawn_target()` (a plain `CreateProcess` call) has -/// necessarily completed, and *that* call is a separate, pre-existing -/// source of multi-second-to-multi-minute stalls on this host (real-time -/// AV scanning a freshly-launched process) that this fix does not -- and is -/// not meant to -- address. Anchoring on that line instead isolates the -/// assertion to the one thing this fix actually changed: how promptly a -/// shutdown arriving *during the port-readiness poll itself* is observed -/// and acted on. -/// -/// Does not close `stdin` before waiting on process exit -- on purpose, -/// matching driver.rs, which never closes its end of the control channel -/// before observing the relay exit either. An earlier version of this -/// fix returned normally from `run()` on this path instead of calling -/// `std::process::exit`; `run_control_channel` loops on -/// `stdin.next_line()` for the process's entire lifetime, so with `stdin` -/// still open (as it always is against a real driver) that task -- and so -/// the whole process -- stayed alive indefinitely even after `run()` had -/// already returned. Confirmed by hand with internal timestamps: the fix -/// fired and `run()` returned within single-digit milliseconds while the -/// process, observed externally, never exited. This test would have -/// caught that. -#[tokio::test(flavor = "multi_thread")] -async fn shutdown_during_port_wait_stops_promptly() { - // Generous: covers this host's observed CreateProcess stalls (up to - // ~60s) plus real margin, not just the fast path. - const SPAWN_TIMEOUT: Duration = Duration::from_mins(2); - // Well under the ~300s port-readiness budget this fix exists to avoid - // waiting out -- generous only relative to `TIMEOUT`, since a bare - // `std::process::exit` completes in well under a second. - const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(20); - - let port = { - let l = TcpListener::bind("127.0.0.1:0").await.unwrap(); - l.local_addr().unwrap().port() - }; - - let mut relay = RelayProcess::spawn_capturing_stderr(port).await; - relay.expect_ready().await; - // A real target that never binds `port` -- port-readiness polling never - // succeeds on its own. cmd.exe rather than powershell.exe: smaller, - // simpler, and this test's target just needs to run for a while and - // never bind `port`, not do anything powershell-specific. - let ack = relay - .launch(1, &["cmd", "/c", "timeout /t 300 /nobreak >nul"]) - .await; - assert_eq!(ack["ok"], true, "launch ack: {ack}"); - - // Confirms spawn_target() has returned and wait_for_port_ready has - // started -- only from this point on is the fix under test actually - // in play. - relay - .wait_for_stderr_line("waiting for target on", SPAWN_TIMEOUT) - .await; - - relay.send(json!({"id": 2, "op": "shutdown"})).await; - let ack = relay.next_json().await; - assert_eq!(ack, json!({"id": 2, "ok": true})); - - let status = tokio::time::timeout(SHUTDOWN_TIMEOUT, relay.child.wait()) - .await - .expect( - "relay did not exit promptly after shutdown during port-wait \ - (see imp.rs's port-wait/shutdown race)", - ) - .expect("wait() failed"); - assert!(status.success(), "expected a clean exit, got {status:?}"); -} - -// ── forward: authenticated relay association + byte bridging ─────────────── - -#[tokio::test(flavor = "multi_thread")] -async fn control_channel_forward_round_trips_bytes_without_host_callback_networking() { - let target_port = spawn_echo_target().await; - let mut relay = RelayProcess::spawn(0).await; - relay.expect_ready().await; - let session_id = "a".repeat(64); - - relay - .send(json!({ - "id": 1, "op": "forward_open", - "data": {"session_id": session_id, "target_port": target_port}, - })) - .await; - assert_eq!(relay.next_json().await, json!({"id": 1, "ok": true})); - - let payload = b"stdio-forward-round-trip"; - relay - .send(json!({ - "id": 2, "op": "forward_write", - "data": { - "session_id": session_id, - "bytes": base64::engine::general_purpose::STANDARD.encode(payload), - }, - })) - .await; - assert_eq!(relay.next_json().await, json!({"id": 2, "ok": true})); - - let echoed = loop { - relay - .send(json!({ - "id": 3, "op": "forward_read", "data": {"session_id": session_id}, - })) - .await; - let response = relay.next_json().await; - let encoded = response["data"]["bytes"].as_str().unwrap(); - if !encoded.is_empty() { - break base64::engine::general_purpose::STANDARD - .decode(encoded) - .unwrap(); - } - }; - assert_eq!(echoed, payload); - - relay - .send(json!({ - "id": 4, "op": "forward_close", "data": {"session_id": session_id}, - })) - .await; - assert_eq!(relay.next_json().await, json!({"id": 4, "ok": true})); -} - -#[tokio::test(flavor = "multi_thread")] -async fn forward_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:?}"), - } -} diff --git a/crates/openshell-supervisor/src/lib.rs b/crates/openshell-supervisor/src/lib.rs index e05da07067..14b9230fdb 100644 --- a/crates/openshell-supervisor/src/lib.rs +++ b/crates/openshell-supervisor/src/lib.rs @@ -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>, + ) -> Result { + 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")) diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index e30844d684..e1fcf0a96b 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -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 `/.openshell-proxy//`. A non-empty per-sandbox `cwd` is therefore required when governed egress is enabled; sandbox-specific subdirectories prevent concurrent sandboxes from overwriting each other's trust files. MXC denies direct Internet egress but allows `127.0.0.1/32`; this permits dynamic forwarding but also means the sandbox can reach unrelated host services bound to loopback. - -MXC rejects policies containing `network_middlewares` before launch because this host-proxy path does not receive the gateway middleware registry. +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. -Supply the workload command and optional working directory through `sandbox create --driver-config-json`, for example `{"mxc":{"command":["cmd","/c","echo hello"],"cwd":"C:\\work"}}`. Supply workload environment through `sandbox create --env` or `--env-from`; it is not part of gateway configuration. +Supply the workload command 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. -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 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. -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 diff --git a/e2e/configs/gateway/schema-v2-capability-parity.toml b/e2e/configs/gateway/schema-v2-capability-parity.toml index 3927f81df1..305974eca6 100644 --- a/e2e/configs/gateway/schema-v2-capability-parity.toml +++ b/e2e/configs/gateway/schema-v2-capability-parity.toml @@ -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" diff --git a/rfc/0013-native-windows-mxc/README.md b/rfc/0013-native-windows-mxc/README.md index 515e7387dd..a9229e9468 100644 --- a/rfc/0013-native-windows-mxc/README.md +++ b/rfc/0013-native-windows-mxc/README.md @@ -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 - - ## 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. - -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. +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 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. - -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. +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. + +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
CLI · TUI · SDK"] - upstreams["Internet / configured upstreams"] - - subgraph host["Windows host"] - direction LR - - subgraph gateway["openshell-gateway.exe - one native Windows process"] - direction TB - - control["Control plane
auth · sandbox state · policy · audit"] - driver["openshell-driver-mxc (in-process)
one backend · N sandbox entries"] +flowchart TD + Gateway[Gateway / in-process MXC driver] + Supervisor[openshell-supervisor
role=isolation-backend] + Sandbox[openshell-sandbox
inside MXC ProcessContainer] + Workload[Workload process tree] + + Gateway -->|policy + launch authentication| Supervisor + Gateway -->|MXC config + one-use bootstrap| Sandbox + Supervisor <-->|generation-scoped TLS + sandbox JWT| Sandbox + Sandbox --> Workload +``` - subgraph proxies["Per-sandbox host proxy tasks and listeners (N)"] - direction TB - proxy_a["Proxy A
127.0.0.1:port_A
policy A · agent identity A"] - proxy_n["Proxy N
127.0.0.1:port_N
policy N · agent identity N"] - end +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. + +### Outer fence and confirmation + +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. + +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. + +### Networking and credentials + +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 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. + +### Process lifecycle and forwarding + +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. + +ProcessContainer teardown remains the outer kill boundary. ConPTY terminal +resize is deferred; non-terminal exec and byte-stream I/O are supported first. + +### Configuration and packaging + +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. + +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. - control --> driver - driver -->|"owns HostProxyHandle A"| proxy_a - driver -->|"owns HostProxyHandle N"| proxy_n - end +## Implementation plan - wxc["wxc-exec.exe invocation(s)"] +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. - subgraph sandboxes["MXC AppContainers (N)"] - direction TB - sandbox_a["Sandbox A
agent workload A only
no OpenShell supervisor"] - sandbox_n["Sandbox N
agent workload N only
no OpenShell supervisor"] - end +## Risks - driver -->|"launch and configure"| wxc - wxc -->|"creates and runs"| sandbox_a - wxc -->|"creates and runs"| sandbox_n +- 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. - sandbox_a -.->|"MXC network.proxy
localhost:port_A"| proxy_a - sandbox_n -.->|"MXC network.proxy
localhost:port_N"| proxy_n - end +## Alternatives - clients -->|"gRPC + mTLS"| control - proxy_a -->|"policy-filtered egress"| upstreams - proxy_n -->|"policy-filtered egress"| upstreams -``` +### Driver-owned relay and proxy -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 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. - -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. - -### Part 1 - Native Windows build - -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. - -- 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. - -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. - -### Part 2 - The MXC compute driver - -`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. - -| 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>` 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:` listener, applies that sandbox's trimmed network policy and static agent identity, and retains its `HostProxyHandle` in the driver registry. | - -#### Workload and software availability - -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. - -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 --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. +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. -```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 --> [*] -``` +### Supervisor-only host proxy without `openshell-sandbox` -`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:` 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. +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. -## Implementation plan +### Windows VM or WSL2 -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. +The existing Linux runtime can run inside a VM, but that does not deliver the +native, low-overhead Windows isolation workflow this RFC targets. -## Risks +### Do nothing -| 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. | - -## Alternatives Considered - -- **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. +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? diff --git a/tasks/scripts/windows-msvc.ps1 b/tasks/scripts/windows-msvc.ps1 index 7d8714039f..16d8a6b67b 100644 --- a/tasks/scripts/windows-msvc.ps1 +++ b/tasks/scripts/windows-msvc.ps1 @@ -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