diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcLifecycle.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcLifecycle.cs index 32be52804..d2ac2e755 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcLifecycle.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcLifecycle.cs @@ -126,7 +126,16 @@ public static MxcSandboxProcess ExecInSandbox(SandboxId id, string command) { NativeSandbox* handle = null; MxcErrorDetail error = default; - var status = NativeMethods.mxc_state_aware_exec(requestPtr, &handle, &error); + // `experimental` is 0 deliberately. Two things are missing: the engine's + // `isolation_session` feature is not enabled in the library this project + // builds, so that backend has no dispatch arm and the request ends at + // `unsupported_phase`; and the request shape emitted here predates that + // backend's Preview migration. Passing 1 without fixing both trades + // `backend_unavailable` for `unsupported_phase`, which + // `AssertNoUsableBackend` already tolerates — the tests would stay green + // while nothing worked. Change it together with the feature and the shape. + var status = NativeMethods.mxc_state_aware_exec( + requestPtr, /*experimental*/ 0, &handle, &error); if (status != (int)ErrorCode.Success) { // See MxcSandbox.Spawn: the release belongs in `finally` so a throw @@ -250,7 +259,11 @@ private static void SetBackendConfig(JsonObject envelope, string phase, string k fixed (byte* requestPtr = requestBuf) { MxcStateAwareResult result = default; - var status = NativeMethods.mxc_state_aware(requestPtr, /*dry_run*/ 0, &result); + // `experimental` is 0 for the reason given in ExecInSandbox: the backend + // is not compiled into this build and the request shape is stale, so + // opting in would only change which error surfaces. + var status = NativeMethods.mxc_state_aware( + requestPtr, /*dry_run*/ 0, /*experimental*/ 0, &result); try { if (status != (int)ErrorCode.Success) diff --git a/src/backends/isolation_session/common/src/state_aware.rs b/src/backends/isolation_session/common/src/state_aware.rs index 57efa8ee0..063c43934 100644 --- a/src/backends/isolation_session/common/src/state_aware.rs +++ b/src/backends/isolation_session/common/src/state_aware.rs @@ -263,18 +263,17 @@ impl StatefulSandboxBackend for IsolationSessionRunner { /// /// 1. It needs a host running the OS-side isolation-session service, which /// CI and most dev machines do not have. - /// 2. More fundamentally, the path is unreachable through the public - /// in-process entry points even *on* such a host: - /// `mxc_engine::exec_state_aware` calls `require_experimental_optin`, - /// and `config_parser` hardcodes `experimental_enabled: false` with the - /// only setter sitting on the one-shot builder. Until the state-aware - /// entry points take an `experimental` parameter, every in-process call - /// against this backend is rejected before it reaches `exec`. + /// 2. The path additionally needs the `mxc_engine/isolation_session` + /// feature to be forwarded by `mxc-sdk`, which declares only `wslc` + /// today — so an in-process caller cannot select this backend even on + /// such a host. The experimental gate is no longer the obstacle: the + /// state-aware entry points now take an `experimental` parameter, so + /// `require_experimental_optin` is satisfiable from a library caller. /// /// A test could fake its way past (2) by building the request in-crate with - /// the flag set, but that scaffolding would have to be deleted as soon as - /// the real opt-in lands. The end-to-end proof belongs with the change that - /// makes this path publicly reachable. + /// the feature forced, but that scaffolding would have to be deleted as soon + /// as the feature is forwarded for real. The end-to-end proof belongs with + /// the change that makes this path publicly reachable. /// /// What *is* pinned here: the consumer split itself /// (`wants_interactive_console`), and — in `wxc_common` — that diff --git a/src/core/mxc-sdk/README.md b/src/core/mxc-sdk/README.md index 8b62c307e..030ba5457 100644 --- a/src/core/mxc-sdk/README.md +++ b/src/core/mxc-sdk/README.md @@ -285,32 +285,47 @@ backend the library supports. Beyond the one-shot `run` / `spawn_sandbox` paths, the SDK exposes the state-aware sandbox lifecycle from a wire-format request JSON string: -- `run_state_aware_json(request_json, dry_run)` drives the **envelope phases** — - `provision`, `start`, `stop`, `deprovision` (and a dry run of any phase) — and - returns the response-envelope JSON string. -- `exec_sandbox(request_json)` runs the `exec` phase as a **live streaming** - `Sandbox` (the same handle `spawn_sandbox` returns). +- `run_state_aware_json(request_json, dry_run, experimental)` drives the + **envelope phases** — `provision`, `start`, `stop`, `deprovision` (and a dry + run of any phase) — and returns the response-envelope JSON string. +- `exec_sandbox(request_json, experimental)` runs the `exec` phase as a **live + streaming** `Sandbox` (the same handle `spawn_sandbox` returns). + +Every state-aware backend is experimental, so `experimental` is the in-process +equivalent of the executor's `--experimental` flag: without it the request is +refused with `ErrorCode::BackendUnavailable` before any work happens. It is an +API parameter, not a field in the request JSON. + +The example below is illustrative: it selects IsolationSession, which needs +`mxc_engine/isolation_session`, and this crate does not forward that feature +today — so it returns `ErrorCode::UnsupportedPhase` as written. ```rust,no_run use mxc_sdk::{run_state_aware_json, exec_sandbox}; -// Envelope phase: provision returns { "result": { "sandboxId": ... } }. +// Provision. IsolationSession accepts only the canonical unrestricted-network +// acknowledgment; an absent policy defaults to `block`, which it refuses. let provisioned = run_state_aware_json( - r#"{"phase":"provision","containment":"isolation_session"}"#, - false, + r#"{"phase":"provision","containment":"isolation_session", + "network":{"defaultPolicy":"allow","allowLocalNetwork":true}}"#, + false, // dry_run + true, // experimental )?; // Exec phase: a live streaming handle. let mut proc = exec_sandbox( r#"{"phase":"exec","sandboxId":"iso:...","process":{"commandLine":"echo hi"}}"#, + true, // experimental )?; let _ = proc.wait(); # Ok::<(), Box>(()) ``` -The only in-tree state-aware backend, IsolationSession, is Windows-only and -experimental (it needs its OS-side service); on a host/build without it these -return an `Error` with `ErrorCode::UnsupportedPhase`. +Three backends implement the state-aware lifecycle — IsolationSession, WSLc and +Windows Sandbox — all Windows-only, and only IsolationSession serves a streaming +`exec` in-process. Branch on the error code: a backend whose feature is not +compiled in answers `ErrorCode::BackendUnavailable`, and one with no arm on the +path you called answers `ErrorCode::UnsupportedPhase`. ## Supported backends diff --git a/src/core/mxc-sdk/src/lib.rs b/src/core/mxc-sdk/src/lib.rs index c6c0fc7fe..9c997e94a 100644 --- a/src/core/mxc-sdk/src/lib.rs +++ b/src/core/mxc-sdk/src/lib.rs @@ -112,8 +112,11 @@ //! `stop`, `deprovision` (and a dry run of any phase) — taking the wire-format //! request JSON and returning the response-envelope JSON. [`exec_sandbox`] runs //! the `exec` phase as a live streaming [`Sandbox`], the same handle -//! [`spawn_sandbox`] returns. The only in-tree state-aware backend -//! (IsolationSession) is Windows-only and needs its OS-side service. +//! [`spawn_sandbox`] returns. Three backends implement the lifecycle — +//! IsolationSession, WSLc and Windows Sandbox, all Windows-only — but only +//! IsolationSession serves a streaming `exec` in-process; see [`exec_sandbox`] +//! for what the other two return there, and the crate README for how each one +//! is compiled in. //! //! ## No pty //! @@ -190,8 +193,19 @@ pub fn run(request: SandboxRequest) -> Result { /// The request JSON is the same wire format the executor accepts (an object with /// a `phase` field). Errors (malformed request, unsupported phase, backend /// failures) come back as an [`Error`] with the matching [`ErrorCode`]. -pub fn run_state_aware_json(request_json: &str, dry_run: bool) -> Result { - mxc_engine::run_state_aware_json(request_json, dry_run) +/// +/// `experimental` is the in-process equivalent of the executor's +/// `--experimental` flag. The experimental backends — WindowsSandbox, +/// IsolationSession and WSLc — are refused with +/// [`ErrorCode::BackendUnavailable`] unless it is set, before any work is done. +/// It is an API parameter rather than a field in the request JSON so that a +/// config cannot grant itself experimental access. +pub fn run_state_aware_json( + request_json: &str, + dry_run: bool, + experimental: bool, +) -> Result { + mxc_engine::run_state_aware_json(request_json, dry_run, experimental) } /// Run the `exec` phase of a state-aware request (as a JSON string) as a **live @@ -200,6 +214,15 @@ pub fn run_state_aware_json(request_json: &str, dry_run: bool) -> Result Result { - mxc_engine::exec_state_aware_json(request_json).map(Sandbox::new) +/// +/// `experimental` opts in to the experimental backends, as for +/// [`run_state_aware_json`]. **IsolationSession is the only backend that serves +/// a streaming `exec` in-process**: the streaming dispatcher asks for +/// `ExecConsumer::Library`, which WSLc refuses outright because it relays to the +/// executor's stdio, and Windows Sandbox has no streaming arm at all +/// (`unsupported_phase`). IsolationSession's arm additionally needs +/// `mxc_engine/isolation_session`, which this crate does not forward yet — so +/// today this entry point cannot return a live handle from a stock build. +pub fn exec_sandbox(request_json: &str, experimental: bool) -> Result { + mxc_engine::exec_state_aware_json(request_json, experimental).map(Sandbox::new) } diff --git a/src/core/mxc-sdk/src/sandbox.rs b/src/core/mxc-sdk/src/sandbox.rs index 065190506..dd66123e2 100644 --- a/src/core/mxc-sdk/src/sandbox.rs +++ b/src/core/mxc-sdk/src/sandbox.rs @@ -36,11 +36,17 @@ pub enum WaitOutcome { /// confirms that process, and a descendant the workload backgrounded is /// reclaimed when the sandbox is stopped and deprovisioned rather than here. /// - /// That state-aware route is not reachable from this crate yet: - /// [`exec_sandbox`](crate::exec_sandbox) parses without the experimental - /// opt-in, so an experimental backend is refused before dispatch. The - /// distinction is documented here because it is what implementors build - /// against, and because it becomes observable the moment that opt-in lands. + /// That state-aware route is reachable from this crate once the caller + /// passes the `experimental` opt-in to + /// [`exec_sandbox`](crate::exec_sandbox) and the backend is compiled in via + /// `mxc_engine/isolation_session`, which this crate does not forward yet. + /// The two are refused differently: without the opt-in the experimental gate + /// rejects the request before dispatch + /// ([`ErrorCode::BackendUnavailable`](crate::ErrorCode::BackendUnavailable)), + /// while without the feature it passes the gate and finds no arm to dispatch + /// to ([`ErrorCode::UnsupportedPhase`](crate::ErrorCode::UnsupportedPhase)). + /// The distinction is documented here because it is what implementors build + /// against. TimedOut, } diff --git a/src/core/mxc-sdk/tests/state_aware.rs b/src/core/mxc-sdk/tests/state_aware.rs index 7acde69b0..38518c29f 100644 --- a/src/core/mxc-sdk/tests/state_aware.rs +++ b/src/core/mxc-sdk/tests/state_aware.rs @@ -5,11 +5,18 @@ //! (`run_state_aware_json` / `exec_sandbox`). //! //! These exercise request parsing, phase routing, and error mapping without a -//! live host backend. The only in-tree state-aware backend (IsolationSession) -//! is Windows-only and needs the OS-side IsoSessionOps service, so the actual -//! provision/exec paths are covered by the executor E2E suites; here we assert -//! the SDK facade's contract (parse, reject one-shot, reject non-dry-run exec, -//! surface unsupported_phase for a backend without a state-aware impl). +//! live host backend. All three state-aware backends — IsolationSession, WSLc +//! and Windows Sandbox — are Windows-only, and IsolationSession additionally +//! needs the OS-side IsoSessionOps service, so the real lifecycle paths are +//! exercised by the executor E2E suites instead. +//! +//! Those suites drive the `ExecConsumer::Executor` path, **not** the +//! `ExecConsumer::Library` path [`exec_sandbox`] uses — that one has no +//! end-to-end coverage yet, as the testing-gap note on IsolationSession's `exec` +//! records. So the assertions here deliberately stop at the facade's contract: +//! parse, reject one-shot, reject non-dry-run exec, surface unsupported_phase +//! for a backend without a state-aware impl, and honour the experimental opt-in +//! — which stays host-independent because the gate runs before backend dispatch. use mxc_sdk::{exec_sandbox, run_state_aware_json, ErrorCode}; @@ -17,7 +24,7 @@ use mxc_sdk::{exec_sandbox, run_state_aware_json, ErrorCode}; fn run_state_aware_json_rejects_one_shot_config() { // No `phase` field => one-shot config, not a lifecycle request. let json = r#"{"version":"0.8.0-alpha","process":{"commandLine":"echo hi"}}"#; - let err = run_state_aware_json(json, false).expect_err("one-shot must be rejected"); + let err = run_state_aware_json(json, false, false).expect_err("one-shot must be rejected"); assert_eq!(err.code, ErrorCode::MalformedRequest); } @@ -26,14 +33,16 @@ fn run_state_aware_json_rejects_non_dry_run_exec() { // A non-dry-run exec streams; it must be routed through exec_sandbox, not // the envelope entry point. let json = r#"{"phase":"exec","sandboxId":"isolationsession:abc","process":{"commandLine":"echo hi"}}"#; - let err = run_state_aware_json(json, false).expect_err("non-dry-run exec must be rejected"); + let err = + run_state_aware_json(json, false, false).expect_err("non-dry-run exec must be rejected"); assert_eq!(err.code, ErrorCode::MalformedRequest); assert!(err.message.contains("exec")); } #[test] fn run_state_aware_json_malformed_json_is_malformed_request() { - let err = run_state_aware_json("{ not json", false).expect_err("bad JSON must be rejected"); + let err = + run_state_aware_json("{ not json", false, false).expect_err("bad JSON must be rejected"); assert_eq!(err.code, ErrorCode::MalformedRequest); } @@ -41,7 +50,7 @@ fn run_state_aware_json_malformed_json_is_malformed_request() { fn exec_sandbox_rejects_non_exec_phase() { let json = r#"{"phase":"provision","containment":"isolation_session"}"#; // `Sandbox` is not `Debug`, so match rather than `expect_err`. - match exec_sandbox(json) { + match exec_sandbox(json, false) { Ok(_) => panic!("a provision request is not an exec"), Err(err) => assert_eq!(err.code, ErrorCode::MalformedRequest), } @@ -50,7 +59,7 @@ fn exec_sandbox_rejects_non_exec_phase() { #[test] fn exec_sandbox_rejects_one_shot_config() { let json = r#"{"version":"0.8.0-alpha","process":{"commandLine":"echo hi"}}"#; - match exec_sandbox(json) { + match exec_sandbox(json, false) { Ok(_) => panic!("one-shot must be rejected"), Err(err) => assert_eq!(err.code, ErrorCode::MalformedRequest), } @@ -67,7 +76,85 @@ fn exec_sandbox_rejects_one_shot_config() { #[test] fn unregistered_backend_prefix_is_unsupported_containment() { let json = r#"{"phase":"start","sandboxId":"nosuchbackend:abc123"}"#; - let err = run_state_aware_json(json, false) + let err = run_state_aware_json(json, false, false) .expect_err("an unregistered sandbox-id prefix has no backend"); assert_eq!(err.code, ErrorCode::UnsupportedContainment); } + +/// Without the opt-in, an experimental backend is refused — and the refusal is +/// host- and feature-independent, because `require_experimental_optin` runs +/// before backend dispatch on every platform. +#[test] +fn experimental_backend_is_refused_without_the_optin() { + let json = r#"{"phase":"provision","containment":"windows_sandbox"}"#; + let err = run_state_aware_json(json, true, false) + .expect_err("an experimental backend without the opt-in must be refused"); + assert_eq!(err.code, ErrorCode::BackendUnavailable); + assert!( + err.message.contains("experimental"), + "the refusal should say what is missing, got: {}", + err.message + ); +} + +/// With the opt-in, the same request gets **past** the gate. +/// +/// The assertion is deliberately "not `BackendUnavailable`" rather than a +/// specific success: what happens next varies by host and build features (a dry +/// run on Windows, `unsupported_phase` elsewhere), and pinning that would make +/// this a host test. What it discriminates is the thing this change adds — if +/// the parameter were dropped on the way down, the gate would still refuse and +/// this fails. A dry run keeps it side-effect-free. +#[test] +fn the_optin_admits_an_experimental_backend() { + let json = r#"{"phase":"provision","containment":"windows_sandbox"}"#; + if let Err(err) = run_state_aware_json(json, true, true) { + assert_ne!( + err.code, + ErrorCode::BackendUnavailable, + "the opt-in was passed, so the experimental gate must not refuse: {}", + err.message + ); + } +} + +/// The gate's refusal carries no failing-API detail, because no platform API is +/// in flight when it fires — the same shape a malformed request produces. +#[test] +fn the_refusal_carries_no_api_call_detail() { + let json = r#"{"phase":"provision","containment":"windows_sandbox"}"#; + let err = run_state_aware_json(json, true, false).expect_err("must be refused"); + assert_eq!(err.operation, None); + assert_eq!(err.native_code, None); + assert_eq!(err.remediation, None); +} + +/// The **streaming** entry point honours the opt-in too, not just the envelope +/// one. +/// +/// Both are needed: they take separate paths to the same gate, so a change that +/// hardcoded the flag in only one of them would leave the other's tests green. +/// A `wsb:` id routes to Windows Sandbox, which has no streaming-exec arm — so +/// once past the gate it lands on `unsupported_phase`, and the two outcomes are +/// distinguishable without a host, a feature, or any backend work. (A `wslc:` +/// id would not discriminate: its feature-off arm also answers +/// `backend_unavailable`, which is the very code the gate returns.) +#[test] +fn exec_honours_the_optin_on_its_own_path() { + let json = r#"{"phase":"exec","sandboxId":"wsb:0a1b2c3d","process":{"commandLine":"echo hi"}}"#; + + match exec_sandbox(json, false) { + Ok(_) => panic!("without the opt-in the gate must refuse"), + Err(err) => assert_eq!(err.code, ErrorCode::BackendUnavailable), + } + + match exec_sandbox(json, true) { + Ok(_) => panic!("windows_sandbox serves no streaming exec"), + Err(err) => assert_ne!( + err.code, + ErrorCode::BackendUnavailable, + "the opt-in was passed, so the gate must not refuse: {}", + err.message + ), + } +} diff --git a/src/core/mxc_engine/src/state_aware.rs b/src/core/mxc_engine/src/state_aware.rs index 16f6ef300..2c2d4e532 100644 --- a/src/core/mxc_engine/src/state_aware.rs +++ b/src/core/mxc_engine/src/state_aware.rs @@ -37,9 +37,9 @@ fn wslc_unavailable() -> MxcError { } /// Reject a state-aware request for an experimental backend when the caller has -/// not opted in via `--experimental`. Applied by both the envelope dispatcher +/// not enabled experimental features. Applied by both the envelope dispatcher /// ([`run_state_aware`]) and the streaming exec dispatcher ([`exec_state_aware`]) -/// so no entry point can reach an experimental backend without the flag. +/// so no entry point can reach an experimental backend without the opt-in. fn require_experimental_optin( backend: &wxc_common::models::ContainmentBackend, parsed: &ParsedStateAwareRequest, @@ -52,8 +52,7 @@ fn require_experimental_optin( ) && !parsed.request.experimental_enabled { return Err(MxcError::backend_unavailable(format!( - "{backend:?} is an experimental backend; pass --experimental to enable state-aware \ - dispatch against it" + "{backend:?} is an experimental backend; enable experimental features to use it" ))); } Ok(()) @@ -139,10 +138,21 @@ pub fn exec_state_aware( /// Parse a state-aware request JSON string into a [`ParsedStateAwareRequest`], /// rejecting a one-shot config (no `phase`). -fn parse_state_aware(request_json: &str) -> Result { +/// +/// `experimental` is the in-process equivalent of the executor's +/// `--experimental` flag, and is applied **here, after parsing**, because +/// `config_parser` hardcodes `experimental_enabled: false` for every +/// state-aware request. +fn parse_state_aware( + request_json: &str, + experimental: bool, +) -> Result { let mut logger = Logger::new(Mode::Buffer); match wxc_common::config_parser::load_mxc_request_from_json(request_json, &mut logger) { - Ok(MxcRequest::StateAware(parsed)) => Ok(parsed), + Ok(MxcRequest::StateAware(mut parsed)) => { + parsed.request.experimental_enabled = experimental; + Ok(parsed) + } Ok(MxcRequest::OneShot(_)) => Err(Error::from(MxcError::malformed_request( "expected a state-aware lifecycle request (with a 'phase' field), got a one-shot config", ))), @@ -169,8 +179,16 @@ fn parse_error_to_mxc(e: wxc_common::config_parser::ParseError) -> MxcError { /// Handles the envelope phases (provision / start / stop / deprovision) and a /// dry-run of any phase. A non-dry-run `exec` streams its output and is rejected /// here — drive it through [`exec_state_aware_json`] instead. -pub fn run_state_aware_json(request_json: &str, dry_run: bool) -> Result { - let parsed = parse_state_aware(request_json)?; +/// +/// `experimental` opts in to the experimental backends (WindowsSandbox, +/// IsolationSession, WSLc); without it they are refused with +/// `backend_unavailable` before any work is done. +pub fn run_state_aware_json( + request_json: &str, + dry_run: bool, + experimental: bool, +) -> Result { + let parsed = parse_state_aware(request_json, experimental)?; if matches!(parsed.phase, Phase::Exec) && !dry_run { return Err(Error::from(MxcError::malformed_request( @@ -194,8 +212,14 @@ pub fn run_state_aware_json(request_json: &str, dry_run: bool) -> Result Result, Error> { - let parsed = parse_state_aware(request_json)?; +/// +/// `experimental` opts in to the experimental backends, as for +/// [`run_state_aware_json`]. +pub fn exec_state_aware_json( + request_json: &str, + experimental: bool, +) -> Result, Error> { + let parsed = parse_state_aware(request_json, experimental)?; if !matches!(parsed.phase, Phase::Exec) { return Err(Error::from(MxcError::malformed_request(format!( "streaming exec requires the exec phase, got {}", @@ -213,7 +237,7 @@ mod tests { use wxc_common::state_aware_request::Phase; #[test] - fn experimental_backend_requires_flag() { + fn experimental_backend_requires_optin() { let parsed = ParsedStateAwareRequest { request: ExecutionRequest::default(), phase: Phase::Provision, @@ -227,13 +251,13 @@ mod tests { let error = run_state_aware(parsed, false).unwrap_err(); assert_eq!(error.code, MxcErrorCode::BackendUnavailable); - assert!(error.message.contains("--experimental")); + assert!(error.message.contains("experimental")); } #[test] - fn exec_experimental_backend_requires_flag() { + fn exec_experimental_backend_requires_optin() { // The streaming exec entry point applies the same opt-in gate as the - // envelope dispatcher: a `wslc:` exec without `--experimental` must be + // envelope dispatcher: a `wslc:` exec without the opt-in must be // refused before reaching the backend. let parsed = ParsedStateAwareRequest { request: ExecutionRequest::default(), @@ -247,12 +271,12 @@ mod tests { let error = match exec_state_aware(parsed) { Ok(_) => { - panic!("expected experimental gate to reject wslc exec without --experimental") + panic!("expected the experimental gate to reject a wslc exec without the opt-in") } Err(e) => e, }; assert_eq!(error.code, MxcErrorCode::BackendUnavailable); - assert!(error.message.contains("--experimental")); + assert!(error.message.contains("experimental")); } } diff --git a/src/core/wxc_common/src/exec_stream.rs b/src/core/wxc_common/src/exec_stream.rs index a86ff7368..6c7bc5bad 100644 --- a/src/core/wxc_common/src/exec_stream.rs +++ b/src/core/wxc_common/src/exec_stream.rs @@ -27,10 +27,11 @@ //! [`ExecConsumer::Library`]: it is the only state-aware backend that returns //! real pipe handles, and it closes its own ends when its process object drops. //! Under [`ExecConsumer::Executor`] it relays internally and returns null -//! handles, and the other state-aware backends (Windows Sandbox, WSLc) return -//! null handles on every path. A handle with *all three* streams null is -//! refused here rather than wrapped: it means the backend has not implemented -//! the `Library` contract, and a stream-less `SandboxProcess` would hide that. +//! handles, as do Windows Sandbox and WSLc — which under [`ExecConsumer::Library`] +//! return no handle at all, refusing it up front. A handle with *all three* +//! streams null is refused here rather than wrapped: it means the backend has +//! not implemented the `Library` contract, and a stream-less `SandboxProcess` +//! would hide that. //! //! Each readable duplicate is wrapped as a **cancellable** reader, so a read on //! it can be made to return EOF on demand. That is what lets diff --git a/src/core/wxc_common/src/state_aware_backend.rs b/src/core/wxc_common/src/state_aware_backend.rs index b22826990..efab37079 100644 --- a/src/core/wxc_common/src/state_aware_backend.rs +++ b/src/core/wxc_common/src/state_aware_backend.rs @@ -303,14 +303,13 @@ pub trait StatefulSandboxBackend { /// that blocks on exit and a terminator that kills, and does not touch the /// host console. Under `Executor` it relays internally and returns null /// handles. - /// - **Windows Sandbox** and **WSLc** relay internally and return null - /// handles whatever the caller asked for. Under `Library` the streaming - /// adapter now **refuses** such a handle rather than wrapping it, so an - /// in-process caller gets a typed error naming the reason instead of a - /// process with no streams — treat those two as executor-path only. + /// - **Windows Sandbox** and **WSLc** honor `Executor` only. They relay + /// internally and return null handles; a `Library` request is refused + /// without running the workload. /// /// Reaching any of this from an in-process caller additionally requires the - /// experimental opt-in, which the state-aware entry points do not yet expose. + /// experimental opt-in, which the state-aware entry points expose as an + /// `experimental` parameter. /// /// # Backends that cannot serve `Library` /// diff --git a/src/ffi/mxc_ffi/src/state_aware.rs b/src/ffi/mxc_ffi/src/state_aware.rs index 7882dba61..24293bae1 100644 --- a/src/ffi/mxc_ffi/src/state_aware.rs +++ b/src/ffi/mxc_ffi/src/state_aware.rs @@ -89,7 +89,15 @@ impl MxcStateAwareResult { /// `exec` streams and is rejected here — use [`mxc_state_aware_exec`]. /// /// Returns the resulting status code (also stored in `out->status`). Returns -/// [`MXC_STATUS_NULL_ARGUMENT`] without touching `*out` if `out` is null. +/// [`MXC_STATUS_NULL_ARGUMENT`] **without running the phase** if `out` is null: +/// the caller has nowhere to receive a sandbox id, so provisioning one would +/// strand it — nothing else can reclaim a sandbox whose only handle was +/// discarded. [`mxc_state_aware_exec`] checks its out-parameter first for the +/// same reason. +/// +/// `experimental` is non-zero to opt in to the experimental backends +/// (WindowsSandbox, IsolationSession, WSLc); with zero they are refused with +/// `backend_unavailable` before any work is done. /// /// # Safety /// - `request_json_utf8` must be null or a valid NUL-terminated UTF-8 C string. @@ -99,19 +107,18 @@ impl MxcStateAwareResult { pub unsafe extern "C" fn mxc_state_aware( request_json_utf8: *const c_char, dry_run: i32, + experimental: i32, out: *mut MxcStateAwareResult, ) -> i32 { - let result = catch_unwind(AssertUnwindSafe(|| { - state_aware_inner(request_json_utf8, dry_run != 0) - })) - .unwrap_or_else(|_| MxcStateAwareResult::error(MXC_STATUS_PANIC, "the mxc engine panicked")); - if out.is_null() { - let mut orphan = result; - orphan.free_strings(); return MXC_STATUS_NULL_ARGUMENT; } + let result = catch_unwind(AssertUnwindSafe(|| { + state_aware_inner(request_json_utf8, dry_run != 0, experimental != 0) + })) + .unwrap_or_else(|_| MxcStateAwareResult::error(MXC_STATUS_PANIC, "the mxc engine panicked")); + let status = result.status; // SAFETY: `out` is non-null and caller-guaranteed writable; ownership of the // out-strings transfers to the caller (freed via `mxc_state_aware_result_free`). @@ -119,7 +126,11 @@ pub unsafe extern "C" fn mxc_state_aware( status } -fn state_aware_inner(request_json_utf8: *const c_char, dry_run: bool) -> MxcStateAwareResult { +fn state_aware_inner( + request_json_utf8: *const c_char, + dry_run: bool, + experimental: bool, +) -> MxcStateAwareResult { // SAFETY: caller contract on `mxc_state_aware`; borrowed only within scope. let request_json = match unsafe { cstr_to_str(request_json_utf8) } { Some(s) => s, @@ -134,7 +145,7 @@ fn state_aware_inner(request_json_utf8: *const c_char, dry_run: bool) -> MxcStat } }; - match run_state_aware_json(request_json, dry_run) { + match run_state_aware_json(request_json, dry_run, experimental) { Ok(response_json) => MxcStateAwareResult { status: MXC_STATUS_SUCCESS, response_json_utf8: alloc_cstring(response_json.as_bytes()), @@ -172,6 +183,9 @@ pub unsafe extern "C" fn mxc_state_aware_result_free(r: *mut MxcStateAwareResult /// with [`mxc_error_detail_free`](crate::mxc_error_detail_free)); /// `*out_handle` is set to null. /// +/// `experimental` opts in to the experimental backends, as for +/// [`mxc_state_aware`]. +/// /// # Safety /// - `request_json_utf8` must be null or a valid NUL-terminated UTF-8 C string. /// - `out_handle` must be non-null and point to writable pointer-sized storage @@ -188,6 +202,7 @@ pub unsafe extern "C" fn mxc_state_aware_result_free(r: *mut MxcStateAwareResult #[no_mangle] pub unsafe extern "C" fn mxc_state_aware_exec( request_json_utf8: *const c_char, + experimental: i32, out_handle: *mut *mut MxcSandbox, out_error: *mut MxcErrorDetail, ) -> i32 { @@ -222,7 +237,7 @@ pub unsafe extern "C" fn mxc_state_aware_exec( )) } }; - exec_sandbox(request_json).map_err(|e| { + exec_sandbox(request_json, experimental != 0).map_err(|e| { ( status_from_error_code(e.code), MxcErrorDetail::from_error(&e), @@ -246,10 +261,15 @@ mod tests { use std::ffi::CString; fn call(json: &str, dry_run: bool) -> MxcStateAwareResult { + call_opt(json, dry_run, false) + } + + fn call_opt(json: &str, dry_run: bool, experimental: bool) -> MxcStateAwareResult { let j = CString::new(json).unwrap(); let mut out = MxcStateAwareResult::empty(); // SAFETY: valid string and out pointer. - let status = unsafe { mxc_state_aware(j.as_ptr(), dry_run as i32, &mut out) }; + let status = + unsafe { mxc_state_aware(j.as_ptr(), dry_run as i32, experimental as i32, &mut out) }; assert_eq!(status, out.status); out } @@ -338,7 +358,7 @@ mod tests { fn null_request_reports_null_argument() { let mut out = MxcStateAwareResult::empty(); // SAFETY: null request is explicitly handled; valid out pointer. - let status = unsafe { mxc_state_aware(ptr::null(), 0, &mut out) }; + let status = unsafe { mxc_state_aware(ptr::null(), 0, 0, &mut out) }; assert_eq!(status, MXC_STATUS_NULL_ARGUMENT); assert!(!out.error.message_utf8.is_null()); // SAFETY: filled by `mxc_state_aware`. @@ -349,7 +369,7 @@ mod tests { fn null_out_reports_null_argument() { let j = CString::new(r#"{"phase":"provision","containment":"isolation_session"}"#).unwrap(); // SAFETY: valid string, deliberately-null out. - let status = unsafe { mxc_state_aware(j.as_ptr(), 0, ptr::null_mut()) }; + let status = unsafe { mxc_state_aware(j.as_ptr(), 0, 0, ptr::null_mut()) }; assert_eq!(status, MXC_STATUS_NULL_ARGUMENT); } @@ -357,7 +377,8 @@ mod tests { fn exec_null_out_handle_is_null_argument() { let j = CString::new(r#"{"phase":"exec","sandboxId":"x:y"}"#).unwrap(); // SAFETY: valid string, deliberately-null out_handle. - let status = unsafe { mxc_state_aware_exec(j.as_ptr(), ptr::null_mut(), ptr::null_mut()) }; + let status = + unsafe { mxc_state_aware_exec(j.as_ptr(), 0, ptr::null_mut(), ptr::null_mut()) }; assert_eq!(status, MXC_STATUS_NULL_ARGUMENT); } @@ -367,11 +388,76 @@ mod tests { let mut handle: *mut MxcSandbox = ptr::null_mut(); let mut err = MxcErrorDetail::none(); // SAFETY: valid string and out pointers. - let status = unsafe { mxc_state_aware_exec(j.as_ptr(), &mut handle, &mut err) }; + let status = unsafe { mxc_state_aware_exec(j.as_ptr(), 0, &mut handle, &mut err) }; assert_eq!(status, crate::MXC_STATUS_MALFORMED_REQUEST); assert!(handle.is_null()); assert!(!err.message_utf8.is_null()); // SAFETY: `err` was filled by `mxc_state_aware_exec` and not yet freed. unsafe { crate::mxc_error_detail_free(&mut err) }; } + + /// Without the opt-in the C ABI refuses an experimental backend, and the + /// refusal crosses as a message with **no** API-call detail — nothing was in + /// flight when the gate fired. + #[test] + fn experimental_backend_is_refused_without_the_optin() { + let mut out = call_opt( + r#"{"phase":"provision","containment":"windows_sandbox"}"#, + true, + false, + ); + assert_eq!(out.status, crate::MXC_STATUS_BACKEND_UNAVAILABLE); + assert!(!out.error.message_utf8.is_null()); + assert!(out.error.operation_utf8.is_null()); + assert!(out.error.native_code_utf8.is_null()); + assert!(out.error.remediation_utf8.is_null()); + // SAFETY: filled by `mxc_state_aware`. + unsafe { mxc_state_aware_result_free(&mut out) }; + } + + /// Passing the opt-in gets past the gate. Asserting "not + /// `BACKEND_UNAVAILABLE`" rather than a specific success keeps this + /// host-independent while still failing if the flag is dropped on the way + /// down; the dry run keeps it side-effect-free. + #[test] + fn the_optin_admits_an_experimental_backend() { + let mut out = call_opt( + r#"{"phase":"provision","containment":"windows_sandbox"}"#, + true, + true, + ); + assert_ne!(out.status, crate::MXC_STATUS_BACKEND_UNAVAILABLE); + // SAFETY: filled by `mxc_state_aware`. + unsafe { mxc_state_aware_result_free(&mut out) }; + } + + /// The streaming entry point carries the opt-in on its own path, which the + /// envelope tests above cannot cover: the two reach the same gate by + /// different routes, so hardcoding the flag in one would leave the other + /// green. A `wsb:` id lands on `unsupported_phase` once past the gate, which + /// is distinguishable from the gate's own refusal without a host or a + /// backend. + #[test] + fn exec_honours_the_optin_on_its_own_path() { + let j = CString::new( + r#"{"phase":"exec","sandboxId":"wsb:0a1b2c3d","process":{"commandLine":"echo hi"}}"#, + ) + .unwrap(); + + for (experimental, expect_refused) in [(0, true), (1, false)] { + let mut handle: *mut MxcSandbox = ptr::null_mut(); + let mut err = MxcErrorDetail::none(); + // SAFETY: valid string and out pointers. + let status = + unsafe { mxc_state_aware_exec(j.as_ptr(), experimental, &mut handle, &mut err) }; + assert!(handle.is_null(), "no handle is produced either way"); + if expect_refused { + assert_eq!(status, crate::MXC_STATUS_BACKEND_UNAVAILABLE); + } else { + assert_ne!(status, crate::MXC_STATUS_BACKEND_UNAVAILABLE); + } + // SAFETY: filled by `mxc_state_aware_exec` and not yet freed. + unsafe { crate::mxc_error_detail_free(&mut err) }; + } + } }