From b99010c7297b61f9a7af3fa3c872aa6b751ab8a8 Mon Sep 17 00:00:00 2001 From: adpa-ms <> Date: Sat, 15 Aug 2026 12:14:12 -0700 Subject: [PATCH 1/4] Let an in-process caller opt in to the experimental backends Every state-aware backend is experimental -- WindowsSandbox, IsolationSession and WSLc -- and the engine already refuses all three unless the request carries the experimental flag. No in-process caller could set it: `config_parser` hardcodes `experimental_enabled: false` for state-aware requests, and the only setter sits on the one-shot builder. So the entire in-process state-aware surface was unreachable, refused before dispatch on every call. The two JSON entry points now take the flag alongside the `dry_run` they already took, and it is applied where the parser leaves off. Nothing about the gate changes: `require_experimental_optin` was already written and already applied by both dispatchers, so this rung supplies the missing way to satisfy it rather than inventing a refusal. It is an API parameter rather than a field in the request JSON because the alternative lets a config grant itself experimental access, which is what the flag exists to withhold. The C ABI mirrors the `dry_run` precedent beside it: an `i32` compared against zero. Three doc comments described the absence as permanent and are now false. Two sit in crates this change does not otherwise touch, which is why they were found by searching for the contract rather than by reading the diff: the SDK's `WaitOutcome::TimedOut` note, and the `ExecConsumer` contract in `wxc_common`. The third, on the IsolationSession backend, was only half false -- its end-to-end testing gap has two causes, and this closes exactly one. The remaining one is named there now, so the note still says what it takes to close it. The refusal carries no API-failure detail, and a test pins that: nothing was in flight when the gate fired, so it takes the same shape as a malformed request. Measured rather than assumed: dropping the flag on the way down, so the parameter is accepted and ignored, fails exactly one test at each layer -- the SDK's and the C ABI's `the_optin_admits_an_experimental_backend` -- and leaves every other test green. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 02c36d36-fb3d-446f-9108-4e4a01b01af1 --- .../common/src/state_aware.rs | 19 +++-- src/core/mxc-sdk/src/lib.rs | 24 +++++-- src/core/mxc-sdk/src/sandbox.rs | 11 +-- src/core/mxc-sdk/tests/state_aware.rs | 62 +++++++++++++++-- src/core/mxc_engine/src/state_aware.rs | 56 ++++++++++----- .../wxc_common/src/state_aware_backend.rs | 3 +- src/ffi/mxc_ffi/src/state_aware.rs | 69 ++++++++++++++++--- 7 files changed, 193 insertions(+), 51 deletions(-) diff --git a/src/backends/isolation_session/common/src/state_aware.rs b/src/backends/isolation_session/common/src/state_aware.rs index 57efa8ee0..92a261740 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 this crate's `isolation_session` feature + /// to be forwarded by `mxc-sdk`, which does not declare it yet — 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/src/lib.rs b/src/core/mxc-sdk/src/lib.rs index c6c0fc7fe..16207e43b 100644 --- a/src/core/mxc-sdk/src/lib.rs +++ b/src/core/mxc-sdk/src/lib.rs @@ -190,8 +190,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 +211,11 @@ 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 — the one backend that serves a +/// streaming `exec` — additionally requires this crate to be built with its +/// `isolation_session` feature. +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..42b7f4f48 100644 --- a/src/core/mxc-sdk/src/sandbox.rs +++ b/src/core/mxc-sdk/src/sandbox.rs @@ -36,11 +36,12 @@ 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 crate is built with the + /// `isolation_session` feature; without either, the backend is refused + /// before dispatch. 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..76adff0d7 100644 --- a/src/core/mxc-sdk/tests/state_aware.rs +++ b/src/core/mxc-sdk/tests/state_aware.rs @@ -17,7 +17,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 +26,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 +43,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 +52,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 +69,55 @@ 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); +} 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/state_aware_backend.rs b/src/core/wxc_common/src/state_aware_backend.rs index b22826990..c085ab40a 100644 --- a/src/core/wxc_common/src/state_aware_backend.rs +++ b/src/core/wxc_common/src/state_aware_backend.rs @@ -310,7 +310,8 @@ pub trait StatefulSandboxBackend { /// process with no streams — treat those two as executor-path only. /// /// 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..ec8fee3b4 100644 --- a/src/ffi/mxc_ffi/src/state_aware.rs +++ b/src/ffi/mxc_ffi/src/state_aware.rs @@ -91,6 +91,10 @@ impl MxcStateAwareResult { /// Returns the resulting status code (also stored in `out->status`). Returns /// [`MXC_STATUS_NULL_ARGUMENT`] without touching `*out` if `out` is null. /// +/// `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. /// - `out` must be null or point to writable [`MxcStateAwareResult`]-sized storage. @@ -99,10 +103,11 @@ 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) + state_aware_inner(request_json_utf8, dry_run != 0, experimental != 0) })) .unwrap_or_else(|_| MxcStateAwareResult::error(MXC_STATUS_PANIC, "the mxc engine panicked")); @@ -119,7 +124,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 +143,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()), @@ -188,6 +197,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 +232,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 +256,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 +353,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 +364,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 +372,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 +383,46 @@ 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) }; + } } From ba8c7f7d52786f57e90c4736c457ee46d1e90121 Mon Sep 17 00:00:00 2001 From: adpa-ms <> Date: Sat, 15 Aug 2026 12:18:46 -0700 Subject: [PATCH 2/4] Keep the C# lifecycle refused, and say why it must stay that way The C ABI now requires the opt-in, so these two call sites have to pass something. They pass zero, which keeps every method on this type failing at the engine's experimental gate exactly as it did before the parameter existed. That is deliberate rather than conservative-by-default. MxcLifecycle builds every request with containment = isolation_session, and two separate things stand between it and a working lifecycle. The project builds mxc_ffi without the engine's isolation_session feature, so that backend has no dispatch arm in the library this project produces. And the request shape predates that backend's Preview migration on more than one count. So opting in would not make this surface work; it would only change which refusal the caller sees. Flip the value in the change that clears both obstacles -- the dedicated C# workstream, which owns this type -- so the surface starts working and stops misreporting in the same commit. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 02c36d36-fb3d-446f-9108-4e4a01b01af1 --- sdk/dotnet/Microsoft.Mxc.Sdk/MxcLifecycle.cs | 24 ++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcLifecycle.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcLifecycle.cs index 32be52804..d1f51b24b 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcLifecycle.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcLifecycle.cs @@ -126,7 +126,23 @@ 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, so this call keeps failing at + // the engine's experimental gate with a clean `backend_unavailable`. + // + // Passing 1 would let it reach the backend, where it would fail + // anyway — this type's request shape predates the IsolationSession + // Preview migration on three counts: provision sends no `network` + // (an absent policy defaults to Block, which that backend refuses), + // it can send `filesystem` (rejected outright at every phase), and + // start sends `configurationId` (no longer in the wire model). The + // resulting `policy_validation` errors would read as backend bugs + // rather than as a stale binding. + // + // Flip this to 1 in the change that fixes the request shape — the + // dedicated C# workstream — so the surface starts working and + // stops lying in the same commit. + 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 +266,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: this + // type's request shape predates the Preview migration, so opting + // in would trade a clean refusal for a misleading one. + var status = NativeMethods.mxc_state_aware( + requestPtr, /*dry_run*/ 0, /*experimental*/ 0, &result); try { if (status != (int)ErrorCode.Success) From 01f96a7aa092b9b4f7949385819b5047c724b66b Mon Sep 17 00:00:00 2001 From: adpa-ms <> Date: Sat, 15 Aug 2026 12:46:49 -0700 Subject: [PATCH 3/4] Refuse a null result pointer before running the phase, not after mxc_state_aware ran the requested phase and only then checked whether it had anywhere to put the answer. With no opt-in that ordering was harmless, because every state-aware backend is experimental and the gate refused all of them before any work happened. Making the opt-in reachable makes the ordering reachable too: a non-dry-run provision with a null out-pointer would create a real sandbox, discard the response carrying its id, and return null_argument -- leaving a sandbox nothing can reclaim, because the only handle to it was thrown away. The check moves ahead of the dispatch, where the sibling mxc_state_aware_exec has always had it. The effect is not observable from a unit test: proving no phase ran needs a live backend, and there is no seam to fake one. The evidence is the sibling entry point's ordering and the code path itself. The streaming entry point had no test that passed the opt-in. Both entry points reach the same gate by separate routes, so hardcoding the flag in exec alone left every test green -- the earlier mutation run only covered the shared helper and missed this. A wsb id discriminates without a host: the gate refuses it without the opt-in, and with the opt-in it reaches unsupported_phase because Windows Sandbox has no streaming exec arm. A wslc id would not work here, since its feature-off arm answers with the same code the gate returns. Measured: with the flag dropped in exec_state_aware_json alone, exactly the new test fails at each layer and every envelope test stays green. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 02c36d36-fb3d-446f-9108-4e4a01b01af1 --- .../common/src/state_aware.rs | 10 ++-- src/core/mxc-sdk/README.md | 28 +++++++---- src/core/mxc-sdk/src/lib.rs | 7 +-- src/core/mxc-sdk/src/sandbox.rs | 8 +-- src/core/mxc-sdk/tests/state_aware.rs | 30 ++++++++++++ src/ffi/mxc_ffi/src/state_aware.rs | 49 ++++++++++++++++--- 6 files changed, 104 insertions(+), 28 deletions(-) diff --git a/src/backends/isolation_session/common/src/state_aware.rs b/src/backends/isolation_session/common/src/state_aware.rs index 92a261740..063c43934 100644 --- a/src/backends/isolation_session/common/src/state_aware.rs +++ b/src/backends/isolation_session/common/src/state_aware.rs @@ -263,11 +263,11 @@ 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. The path additionally needs this crate's `isolation_session` feature - /// to be forwarded by `mxc-sdk`, which does not declare it yet — 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 + /// 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 diff --git a/src/core/mxc-sdk/README.md b/src/core/mxc-sdk/README.md index 8b62c307e..8bde707eb 100644 --- a/src/core/mxc-sdk/README.md +++ b/src/core/mxc-sdk/README.md @@ -285,11 +285,17 @@ 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 rather than a field in the request JSON, so that a config cannot +grant itself experimental access. ```rust,no_run use mxc_sdk::{run_state_aware_json, exec_sandbox}; @@ -297,20 +303,24 @@ use mxc_sdk::{run_state_aware_json, exec_sandbox}; // Envelope phase: provision returns { "result": { "sandboxId": ... } }. let provisioned = run_state_aware_json( r#"{"phase":"provision","containment":"isolation_session"}"#, - false, + 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 — and the first two also serve a streaming `exec`. Each is +Windows-only and needs the engine feature that compiles it in, which this crate +forwards only for WSLc today; on a host or build without the selected backend +these return an `Error` with `ErrorCode::UnsupportedPhase`. ## Supported backends diff --git a/src/core/mxc-sdk/src/lib.rs b/src/core/mxc-sdk/src/lib.rs index 16207e43b..c69d450e9 100644 --- a/src/core/mxc-sdk/src/lib.rs +++ b/src/core/mxc-sdk/src/lib.rs @@ -213,9 +213,10 @@ pub fn run_state_aware_json( /// `sandboxId` identifying a started sandbox). No pty is allocated. /// /// `experimental` opts in to the experimental backends, as for -/// [`run_state_aware_json`]. IsolationSession — the one backend that serves a -/// streaming `exec` — additionally requires this crate to be built with its -/// `isolation_session` feature. +/// [`run_state_aware_json`]. The backends that serve a streaming `exec` are +/// IsolationSession and WSLc; each additionally needs the engine feature that +/// compiles it in (`mxc_engine/isolation_session`, `mxc_engine/wslc` — this +/// crate forwards only the latter today, as `wslc`). 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 42b7f4f48..8cbbec53c 100644 --- a/src/core/mxc-sdk/src/sandbox.rs +++ b/src/core/mxc-sdk/src/sandbox.rs @@ -38,10 +38,10 @@ pub enum WaitOutcome { /// /// 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 crate is built with the - /// `isolation_session` feature; without either, the backend is refused - /// before dispatch. The distinction is documented here because it is what - /// implementors build against. + /// [`exec_sandbox`](crate::exec_sandbox) and the backend is compiled in via + /// `mxc_engine/isolation_session`, which this crate does not forward yet; + /// without either, the backend is refused before dispatch. 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 76adff0d7..f28b1b430 100644 --- a/src/core/mxc-sdk/tests/state_aware.rs +++ b/src/core/mxc-sdk/tests/state_aware.rs @@ -121,3 +121,33 @@ fn the_refusal_carries_no_api_call_detail() { 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/ffi/mxc_ffi/src/state_aware.rs b/src/ffi/mxc_ffi/src/state_aware.rs index ec8fee3b4..24293bae1 100644 --- a/src/ffi/mxc_ffi/src/state_aware.rs +++ b/src/ffi/mxc_ffi/src/state_aware.rs @@ -89,7 +89,11 @@ 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 @@ -106,17 +110,15 @@ pub unsafe extern "C" fn mxc_state_aware( experimental: i32, out: *mut MxcStateAwareResult, ) -> i32 { + if out.is_null() { + 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")); - if out.is_null() { - let mut orphan = result; - orphan.free_strings(); - return MXC_STATUS_NULL_ARGUMENT; - } - 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`). @@ -181,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 @@ -425,4 +430,34 @@ mod tests { // 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) }; + } + } } From 8263a4dbfd808627b3def84c366ba91f9d09d9ae Mon Sep 17 00:00:00 2001 From: adpa-ms <> Date: Sat, 15 Aug 2026 15:49:14 -0700 Subject: [PATCH 4/4] Say which backend serves what, and which exec path is covered Several documentation claims in and around this crate outran the code, and the opt-in made three of them load-bearing by putting the state-aware surface within a library caller's reach for the first time. Which backend serves a streaming exec. exec_sandbox reaches dispatch_state_aware_exec, which asks each backend it dispatches to for an ExecConsumer::Library handle. IsolationSession serves it. WSLc refuses that consumer on the first line of its exec, before doing any work, because it relays to the executor's stdio and cannot serve an in-process caller -- it streams only under ExecConsumer::Executor, which is the wxc-exec relay. Windows Sandbox refuses it the same way and has no streaming arm to dispatch to at all. So IsolationSession is the only backend serving this path, and the crate docs, the README, the trait contract and the exec-stream adapter now agree on that. None of those refusals is new: the code did not drift, the documentation did, and an earlier commit on this branch acted on the opposite belief before it was corrected. How many state-aware backends there are. Three -- IsolationSession, WSLc and Windows Sandbox -- since the latter two gained the lifecycle. The claim that IsolationSession is the only one appeared in seven places; the two in this crate are corrected here. One sat directly above tests that drive windows_sandbox through a wsb sandbox id, and one contradicted a paragraph a hundred lines below it in the same file. The adapter's module doc likewise said the other two return null handles on every path, when under Library they return no handle at all -- the same file already said so further down. Five further copies live in the C# SDK, where they describe a surface that really does expose one backend; they belong to the workstream that widens it. Which exec path the end-to-end suites cover. They drive ExecConsumer::Executor, because they go through wxc-exec. The Library path exec_sandbox uses has no end-to-end coverage at all, which the testing-gap note on IsolationSession's exec records. The test module claimed the suites covered the real exec paths; it now names both consumers and explains why its own assertions stop at the facade's contract. What compiling a backend in requires, and how each absence is refused. Windows Sandbox requires nothing: its dependency is not optional and its arm is gated on the target OS alone. IsolationSession and WSLc each need an engine feature, and this crate forwards only the latter. Those two absences are refused differently, which the timeout note now states rather than conflating: without the opt-in the gate rejects the request before dispatch, while without the feature it passes the gate and finds no arm, which is a different code. The README carries the matrix and tells callers to branch on the code for that reason. Its provision example carries the canonical unrestricted-network acknowledgment, without which that backend refuses the phase -- the example previously advertised a sandbox id it could not have returned. Why the C# lifecycle keeps passing zero. Two obstacles stand between that type and a working lifecycle. The project builds mxc_ffi without the engine's isolation_session feature, so the request ends at unsupported_phase without reaching the backend, while build.bat --with-isolation-session enables it across the workspace and cargo unifies it into the engine this library links -- so the shipped binaries and the tested ones differ. And the request shape predates the Preview migration on more than one count. Flipping the value alone would trade backend_unavailable for unsupported_phase, which the C# tests already tolerate, so the change would look green while fixing nothing. Both obstacles are named at the call site, and the file is otherwise untouched: the only lines removed from it are the two native calls whose signature changed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 02c36d36-fb3d-446f-9108-4e4a01b01af1 --- sdk/dotnet/Microsoft.Mxc.Sdk/MxcLifecycle.cs | 29 +++++++------------ src/core/mxc-sdk/README.md | 21 +++++++++----- src/core/mxc-sdk/src/lib.rs | 18 ++++++++---- src/core/mxc-sdk/src/sandbox.rs | 11 +++++-- src/core/mxc-sdk/tests/state_aware.rs | 17 +++++++---- src/core/wxc_common/src/exec_stream.rs | 9 +++--- .../wxc_common/src/state_aware_backend.rs | 8 ++--- 7 files changed, 64 insertions(+), 49 deletions(-) diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcLifecycle.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcLifecycle.cs index d1f51b24b..d2ac2e755 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcLifecycle.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcLifecycle.cs @@ -126,21 +126,14 @@ public static MxcSandboxProcess ExecInSandbox(SandboxId id, string command) { NativeSandbox* handle = null; MxcErrorDetail error = default; - // `experimental` is 0 deliberately, so this call keeps failing at - // the engine's experimental gate with a clean `backend_unavailable`. - // - // Passing 1 would let it reach the backend, where it would fail - // anyway — this type's request shape predates the IsolationSession - // Preview migration on three counts: provision sends no `network` - // (an absent policy defaults to Block, which that backend refuses), - // it can send `filesystem` (rejected outright at every phase), and - // start sends `configurationId` (no longer in the wire model). The - // resulting `policy_validation` errors would read as backend bugs - // rather than as a stale binding. - // - // Flip this to 1 in the change that fixes the request shape — the - // dedicated C# workstream — so the surface starts working and - // stops lying in the same commit. + // `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) @@ -266,9 +259,9 @@ private static void SetBackendConfig(JsonObject envelope, string phase, string k fixed (byte* requestPtr = requestBuf) { MxcStateAwareResult result = default; - // `experimental` is 0 for the reason given in ExecInSandbox: this - // type's request shape predates the Preview migration, so opting - // in would trade a clean refusal for a misleading one. + // `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 diff --git a/src/core/mxc-sdk/README.md b/src/core/mxc-sdk/README.md index 8bde707eb..030ba5457 100644 --- a/src/core/mxc-sdk/README.md +++ b/src/core/mxc-sdk/README.md @@ -294,15 +294,20 @@ state-aware sandbox lifecycle from a wire-format request JSON string: 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 rather than a field in the request JSON, so that a config cannot -grant itself experimental access. +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"}"#, + r#"{"phase":"provision","containment":"isolation_session", + "network":{"defaultPolicy":"allow","allowLocalNetwork":true}}"#, false, // dry_run true, // experimental )?; @@ -317,10 +322,10 @@ let _ = proc.wait(); ``` Three backends implement the state-aware lifecycle — IsolationSession, WSLc and -Windows Sandbox — and the first two also serve a streaming `exec`. Each is -Windows-only and needs the engine feature that compiles it in, which this crate -forwards only for WSLc today; on a host or build without the selected backend -these return an `Error` with `ErrorCode::UnsupportedPhase`. +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 c69d450e9..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 //! @@ -213,10 +216,13 @@ pub fn run_state_aware_json( /// `sandboxId` identifying a started sandbox). No pty is allocated. /// /// `experimental` opts in to the experimental backends, as for -/// [`run_state_aware_json`]. The backends that serve a streaming `exec` are -/// IsolationSession and WSLc; each additionally needs the engine feature that -/// compiles it in (`mxc_engine/isolation_session`, `mxc_engine/wslc` — this -/// crate forwards only the latter today, as `wslc`). +/// [`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 8cbbec53c..dd66123e2 100644 --- a/src/core/mxc-sdk/src/sandbox.rs +++ b/src/core/mxc-sdk/src/sandbox.rs @@ -39,9 +39,14 @@ pub enum WaitOutcome { /// 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; - /// without either, the backend is refused before dispatch. The distinction - /// is documented here because it is what implementors build against. + /// `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 f28b1b430..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}; 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 c085ab40a..efab37079 100644 --- a/src/core/wxc_common/src/state_aware_backend.rs +++ b/src/core/wxc_common/src/state_aware_backend.rs @@ -303,11 +303,9 @@ 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 expose as an