From df4664c6947183ff9ef930ea381617a3205e2e07 Mon Sep 17 00:00:00 2001 From: Soham Das Date: Tue, 25 Aug 2026 14:42:57 -0700 Subject: [PATCH 1/2] Give WSLc errors a typed enum and a real failure phase --- .../wslc/common/src/container_steps.rs | 28 +-- src/backends/wslc/common/src/error.rs | 200 ++++++++++++++++++ src/backends/wslc/common/src/lib.rs | 1 + .../wslc/common/src/wsl_container_runner.rs | 89 +++++--- src/core/mxc_engine/src/dispatch.rs | 9 +- 5 files changed, 275 insertions(+), 52 deletions(-) create mode 100644 src/backends/wslc/common/src/error.rs diff --git a/src/backends/wslc/common/src/container_steps.rs b/src/backends/wslc/common/src/container_steps.rs index b0be01703..6901a7e69 100644 --- a/src/backends/wslc/common/src/container_steps.rs +++ b/src/backends/wslc/common/src/container_steps.rs @@ -43,6 +43,7 @@ use wxc_common::logger::Logger; use wxc_common::models::{PortMapping, ScriptResponse}; use wxc_common::string_util::{to_wide, CoTaskMemPWSTR}; +use crate::error::WslcError; use crate::policy_mapping::VolumeMount; use crate::wsl_container_runner::{wslc_prerequisite_error, WSLContainerRunner}; use crate::wslc_bindings::*; @@ -52,14 +53,10 @@ use crate::wslc_bindings::*; // --------------------------------------------------------------------------- /// Build a `ScriptResponse` error from an HRESULT failure with an optional -/// SDK-provided message. +/// SDK-provided message. Thin wrapper over [`WslcError::Sdk`], which owns the +/// message formatting and the `LaunchFailed` phase attribution. pub(crate) fn sdk_error(context: &str, hr: HRESULT, sdk_msg: &str) -> ScriptResponse { - let msg = if sdk_msg.is_empty() { - format!("{}: HRESULT 0x{:08X}", context, hr as u32) - } else { - format!("{}: {} (HRESULT 0x{:08X})", context, sdk_msg, hr as u32) - }; - ScriptResponse::error(&msg) + WslcError::sdk(context, hr, sdk_msg).into_response() } /// NUL-terminate `value` for a C string the WSLc SDK will read, rejecting an @@ -73,9 +70,10 @@ fn cstr_bytes(field: &str, value: &str) -> Result, ScriptResponse> { std::ffi::CString::new(value) .map(|c| c.into_bytes_with_nul()) .map_err(|_| { - ScriptResponse::error(&format!( + WslcError::Rejected(format!( "{field} contains an interior NUL byte, which is not a valid C string" )) + .into_response() }) } @@ -425,9 +423,10 @@ impl ProcessSettings { let mut cwd_cstr: Option> = None; if !working_directory.is_empty() { if !working_directory.starts_with('/') { - return Err(ScriptResponse::error(&format!( + return Err(WslcError::Rejected(format!( "working_directory must be an absolute in-container path (got {working_directory:?})" - ))); + )) + .into_response()); } let c = cstr_bytes("working_directory", working_directory)?; let hr = sdk.WslcSetProcessSettingsWorkingDirectory(&mut raw, c.as_ptr() as PCSTR); @@ -666,7 +665,7 @@ pub const KEEPALIVE_SCRIPT: &str = "while true; do sleep 86400; done"; /// resolvable. The returned [`WslcSdk`] holds raw function pointers; keep it /// alive for the duration of all SDK use. pub unsafe fn load_sdk_checked(logger: &mut Logger) -> Result { - let sdk = WslcSdk::load().map_err(|e| ScriptResponse::error(&e))?; + let sdk = WslcSdk::load().map_err(|e| WslcError::Unavailable(e).into_response())?; let mut missing = WslcComponentFlags::WSLC_COMPONENT_FLAG_NONE; let hr = sdk.WslcGetMissingComponents(&mut missing); @@ -674,7 +673,7 @@ pub unsafe fn load_sdk_checked(logger: &mut Logger) -> Result (String::new(), String::new()), }; - Err(ScriptResponse::error(&format!( + Err(WslcError::Rejected(format!( "WSLC image '{}' not found locally. Pre-pull it with: \ wxc-exec.exe --setup-wslc --image {}{} \ (or scripts\\setup-wslc.ps1 -Image {}{}). \ MXC does not pull images at run time; \ see docs/wsl/wsl-container-support-plan.md.", image, image, storage_arg_wxc, image, storage_arg_ps, - ))) + )) + .into_response()) } /// Create a daemon-owned container with `keepalive` as its init process, so it diff --git a/src/backends/wslc/common/src/error.rs b/src/backends/wslc/common/src/error.rs new file mode 100644 index 000000000..b21eec828 --- /dev/null +++ b/src/backends/wslc/common/src/error.rs @@ -0,0 +1,200 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Typed failure modes for the WSLc backend. +//! +//! WSLc previously built every failure as a free-text [`ScriptResponse`], which +//! left `failure_phase` at its `None` default. Because +//! `mxc_engine::dispatch::map_spawn_error` discriminates on exactly that field, +//! every WSLc failure reached the Rust SDK as an opaque `backend_error` — so a +//! caller could only tell a missing-WSL host from a rejected policy by parsing +//! the message. Each variant here attributes the failure to a lifecycle phase +//! instead. +//! +//! `Display` reproduces the pre-existing message verbatim and +//! [`WslcError::into_response`] fills the same fields `ScriptResponse::error` +//! did, so the text a user sees is unchanged; `failure_phase` is the only +//! addition. + +use std::fmt; + +use wxc_common::models::{FailurePhase, ScriptResponse}; + +use crate::wslc_bindings::HRESULT; + +/// A WSLc backend failure, tagged with the phase it is attributable to. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum WslcError { + /// A WSLc SDK call returned a failing `HRESULT`. + Sdk { + context: String, + hr: HRESULT, + sdk_msg: String, + }, + /// The SDK, or a WSL component it depends on, is missing on this host. + Unavailable(String), + /// The request cannot be honored as written — a policy or config rejection + /// that the same input will not get past on a retry. + Rejected(String), + /// Host-side bring-up failed before the container process started. + Host(String), + /// The container started, but the run could not be carried to a clean exit. + Runtime(String), +} + +impl WslcError { + /// A failing SDK call. Mirrors the message the former `sdk_error` helper + /// built, including the `HRESULT` rendering. + pub(crate) fn sdk(context: impl Into, hr: HRESULT, sdk_msg: impl Into) -> Self { + WslcError::Sdk { + context: context.into(), + hr, + sdk_msg: sdk_msg.into(), + } + } + + /// Lifecycle phase this failure is attributed to, so a caller can tell a + /// retryable launch failure from a rejection or an unusable host. + pub(crate) fn failure_phase(&self) -> FailurePhase { + match self { + // Host cannot run WSLc at all — callers may fall back to another tier. + WslcError::Unavailable(_) => FailurePhase::BackendUnavailable, + // Non-retryable preflight: the input itself has to change. + WslcError::Rejected(_) => FailurePhase::Rejected, + // The SDK call or host bring-up failed; generally worth retrying. + WslcError::Sdk { .. } | WslcError::Host(_) => FailurePhase::LaunchFailed, + // The container was up but the run broke. + WslcError::Runtime(_) => FailurePhase::PostLaunchFailed, + } + } + + /// Render as a [`ScriptResponse`], preserving the exact message text the + /// untyped construction produced. + pub(crate) fn into_response(self) -> ScriptResponse { + let failure_phase = self.failure_phase(); + let msg = self.to_string(); + ScriptResponse { + exit_code: -1, + standard_err: msg.clone(), + error_message: msg, + failure_phase, + ..Default::default() + } + } +} + +impl fmt::Display for WslcError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + WslcError::Sdk { + context, + hr, + sdk_msg, + } if sdk_msg.is_empty() => { + write!(f, "{}: HRESULT 0x{:08X}", context, *hr as u32) + } + WslcError::Sdk { + context, + hr, + sdk_msg, + } => write!(f, "{}: {} (HRESULT 0x{:08X})", context, sdk_msg, *hr as u32), + WslcError::Unavailable(m) + | WslcError::Rejected(m) + | WslcError::Host(m) + | WslcError::Runtime(m) => f.write_str(m), + } + } +} + +impl From for ScriptResponse { + fn from(err: WslcError) -> Self { + err.into_response() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The former helper, kept verbatim so the typed path can be proven to + /// produce byte-identical text. + fn legacy_sdk_message(context: &str, hr: HRESULT, sdk_msg: &str) -> String { + if sdk_msg.is_empty() { + format!("{}: HRESULT 0x{:08X}", context, hr as u32) + } else { + format!("{}: {} (HRESULT 0x{:08X})", context, sdk_msg, hr as u32) + } + } + + #[test] + fn sdk_message_is_unchanged_from_the_untyped_helper() { + // 0x8007_0490 as a c_long is negative; the `as u32` cast in both the old + // and new renderings must agree on the printed form. + for (ctx, hr, sdk_msg) in [ + ("WslcCreateContainer failed", 0x8007_0490_u32 as HRESULT, ""), + ( + "WslcCreateContainerProcess failed", + 0x8007_0490_u32 as HRESULT, + "no such image", + ), + ("WslcGetMissingComponents failed", -1, ""), + ] { + assert_eq!( + WslcError::sdk(ctx, hr, sdk_msg).to_string(), + legacy_sdk_message(ctx, hr, sdk_msg), + "typed rendering must match the legacy message exactly" + ); + } + } + + #[test] + fn message_carrying_variants_render_verbatim() { + let msg = "WSLc: network.allowLocalNetwork=true is not supported."; + for err in [ + WslcError::Unavailable(msg.to_string()), + WslcError::Rejected(msg.to_string()), + WslcError::Host(msg.to_string()), + WslcError::Runtime(msg.to_string()), + ] { + assert_eq!(err.to_string(), msg, "message must not be reworded"); + } + } + + #[test] + fn response_matches_the_untyped_shape_apart_from_the_phase() { + let typed = WslcError::Rejected("bad path".to_string()).into_response(); + let untyped = ScriptResponse::error("bad path"); + + assert_eq!(typed.exit_code, untyped.exit_code); + assert_eq!(typed.standard_err, untyped.standard_err); + assert_eq!(typed.error_message, untyped.error_message); + // The untyped helper left this at the default, which is the bug. + assert_eq!(untyped.failure_phase, FailurePhase::None); + assert_eq!(typed.failure_phase, FailurePhase::Rejected); + } + + #[test] + fn each_variant_maps_to_its_lifecycle_phase() { + let s = || "x".to_string(); + assert_eq!( + WslcError::Unavailable(s()).failure_phase(), + FailurePhase::BackendUnavailable + ); + assert_eq!( + WslcError::Rejected(s()).failure_phase(), + FailurePhase::Rejected + ); + assert_eq!( + WslcError::sdk("op", -1, "").failure_phase(), + FailurePhase::LaunchFailed + ); + assert_eq!( + WslcError::Host(s()).failure_phase(), + FailurePhase::LaunchFailed + ); + assert_eq!( + WslcError::Runtime(s()).failure_phase(), + FailurePhase::PostLaunchFailed + ); + } +} diff --git a/src/backends/wslc/common/src/lib.rs b/src/backends/wslc/common/src/lib.rs index a93367308..dae262bf9 100644 --- a/src/backends/wslc/common/src/lib.rs +++ b/src/backends/wslc/common/src/lib.rs @@ -17,6 +17,7 @@ pub mod container_steps; pub mod daemon_client; pub mod daemon_protocol; pub mod daemon_record; +pub mod error; pub mod policy; pub mod policy_mapping; pub mod sandbox; diff --git a/src/backends/wslc/common/src/wsl_container_runner.rs b/src/backends/wslc/common/src/wsl_container_runner.rs index bb34676f7..cabd2e4d9 100644 --- a/src/backends/wslc/common/src/wsl_container_runner.rs +++ b/src/backends/wslc/common/src/wsl_container_runner.rs @@ -31,6 +31,7 @@ use wxc_common::string_util::{to_wide, CoTaskMemPWSTR}; use wxc_common::validator::{validate_network_policy_support, NetworkPolicySupport}; use crate::container_steps::sdk_error; +use crate::error::WslcError; use crate::policy_mapping; use crate::stream_buffer::{stream_pair, StreamReader, StreamWriter}; use crate::wslc_bindings::*; @@ -486,11 +487,12 @@ impl WSLContainerRunner { ) -> Result<(), ScriptResponse> { let path = std::path::Path::new(tar_path); if !path.exists() { - return Err(ScriptResponse::error(&format!( + return Err(WslcError::Rejected(format!( "Image tar file not found: '{}'. Provide a valid rootfs tar \ (via 'docker export') or Docker image archive (via 'docker save').", tar_path - ))); + )) + .into_response()); } // Resolve to absolute path, following symlinks. Fall back to the @@ -501,10 +503,11 @@ impl WSLContainerRunner { let tar_format = match Self::detect_tar_format(&tar_path) { Ok(fmt) => fmt, Err(e) => { - return Err(ScriptResponse::error(&format!( + return Err(WslcError::Rejected(format!( "Failed to read tar file '{}': {}", tar_path, e - ))); + )) + .into_response()); } }; let wide_path: Vec = to_wide(&tar_path); @@ -580,11 +583,12 @@ impl WSLContainerRunner { ); } TarFormat::Unknown => { - return Err(ScriptResponse::error(&format!( + return Err(WslcError::Rejected(format!( "Unrecognized tar format: '{}'. Provide a rootfs tar \ (via 'docker export') or a Docker image archive (via 'docker save').", tar_path - ))); + )) + .into_response()); } } @@ -642,20 +646,24 @@ impl ScriptRunner for WSLContainerRunner { /// instead of late in `execute` on the broken in-container iptables path. fn validate_runner(&self, request: &ExecutionRequest) -> Result<(), ScriptResponse> { if request.policy.needs_host_filtering() { - return Err(ScriptResponse::error( + return Err(WslcError::Rejected( "WSLc: per-host egress filtering (allowedHosts with \ defaultPolicy='block', or blockedHosts with defaultPolicy='allow') \ is not supported. A WSLc container has no CAP_NET_ADMIN for in-container \ iptables, and VM-level enforcement is not available without breaking other \ security guarantees (e.g. MDE). Use network.proxy (defaultPolicy='allow') \ - for cooperative host filtering, or remove the host lists.", - )); + for cooperative host filtering, or remove the host lists." + .to_string(), + ) + .into_response()); } if request.policy.allow_local_network { - return Err(ScriptResponse::error( + return Err(WslcError::Rejected( "WSLc: network.allowLocalNetwork=true is not supported. Expose specific \ - ports with experimental.wslc portMappings instead.", - )); + ports with experimental.wslc portMappings instead." + .to_string(), + ) + .into_response()); } validate_network_policy_support(request, NetworkPolicySupport::LEGACY)?; Ok(()) @@ -688,16 +696,16 @@ impl WSLContainerRunner { match ComApartment::enter() { Ok(com) => std::mem::forget(com), Err(e) => { - return Err(ScriptResponse::error(&format!( - "COM initialization failed: {e}" - ))) + return Err( + WslcError::Host(format!("COM initialization failed: {e}")).into_response() + ) } } let _ = writeln!(logger, "[WSLC] COM initialized"); let sdk = match WslcSdk::shared() { Ok(s) => s, - Err(e) => return Err(ScriptResponse::error(&e)), + Err(e) => return Err(WslcError::Unavailable(e).into_response()), }; // Prerequisites check @@ -707,7 +715,7 @@ impl WSLContainerRunner { return Err(sdk_error("WslcGetMissingComponents failed", hr, "")); } if missing.any_missing() { - return Err(ScriptResponse::error(&wslc_prerequisite_error(missing))); + return Err(WslcError::Unavailable(wslc_prerequisite_error(missing)).into_response()); } let _ = writeln!(logger, "[WSLC] Runtime check passed"); @@ -756,11 +764,12 @@ impl WSLContainerRunner { let mem_mb = match u32::try_from(mem_mb) { Ok(v) => v, Err(_) => { - return Err(ScriptResponse::error(&format!( + return Err(WslcError::Rejected(format!( "Invalid config: memory_mb value {} exceeds maximum {} MB", mem_mb, u32::MAX - ))); + )) + .into_response()); } }; let hr = sdk.WslcSetSessionSettingsMemory(&mut settings, mem_mb); @@ -872,14 +881,15 @@ impl WSLContainerRunner { ), None => (String::new(), String::new()), }; - return Err(ScriptResponse::error(&format!( + return Err(WslcError::Rejected(format!( "WSLC image '{}' not found locally. Pre-pull it with: \ wxc-exec.exe --setup-wslc --image {}{} \ (or scripts\\setup-wslc.ps1 -Image {}{}). \ MXC does not pull images at run time; \ see docs/wsl/wsl-container-support-plan.md.", image_name, image_name, storage_arg_wxc, image_name, storage_arg_ps, - ))); + )) + .into_response()); } Ok(()) @@ -1044,7 +1054,10 @@ impl WSLContainerRunner { 30_000, ); if wait_result == windows::Win32::Foundation::WAIT_TIMEOUT { - return Err(ScriptResponse::error("iptables rules timed out after 30s")); + return Err( + WslcError::Runtime("iptables rules timed out after 30s".to_string()) + .into_response(), + ); } } @@ -1058,11 +1071,12 @@ impl WSLContainerRunner { )); } if ipt_exit_code != 0 { - return Err(ScriptResponse::error(&format!( + return Err(WslcError::Runtime(format!( "iptables rules failed with exit code {} \ (image may not have iptables installed)", ipt_exit_code - ))); + )) + .into_response()); } let _ = writeln!(logger, "[WSLC] iptables rules applied successfully"); Ok(()) @@ -1125,11 +1139,12 @@ impl WSLContainerRunner { // told us nothing about the process, so neither "exited" nor // "timed out" can be claimed. Fail rather than guess. let last_error = windows::Win32::Foundation::GetLastError(); - return Err(ScriptResponse::error(&format!( + return Err(WslcError::Runtime(format!( "waiting on the WSLC process exit event failed: WaitForSingleObject returned \ 0x{:08X} (GetLastError 0x{:08X})", wait_result.0, last_error.0 - ))); + )) + .into_response()); } } @@ -1190,10 +1205,12 @@ impl WSLContainerRunner { WaitOutcome::Exited } (false, _) => { - return Err(ScriptResponse::error( + return Err(WslcError::Runtime( "the WSLC process never reported an exit: no exit event was available and the \ - SDK's exit callback did not fire, so the container may still be running", - )); + SDK's exit callback did not fire, so the container may still be running" + .to_string(), + ) + .into_response()); } (true, true) => { let _ = writeln!(logger, "[WSLC] Process killed after timeout"); @@ -1304,7 +1321,7 @@ impl WSLContainerRunner { Ok(None) => request, Err(msg) => { let _ = writeln!(logger, "[WSLC] {}", msg); - return Err(ScriptResponse::error(&msg)); + return Err(WslcError::Rejected(msg).into_response()); } }; @@ -1383,11 +1400,13 @@ impl WSLContainerRunner { { Some(url) => url, None => { - return Err(ScriptResponse::error( + return Err(WslcError::Rejected( "WSLC: network.proxy requires the 'url' form (a routable proxy URL); \ the localhost and builtinTestServer forms are not supported because a \ - WSLc container runs in its own network namespace.", - )); + WSLc container runs in its own network namespace." + .to_string(), + ) + .into_response()); } }; let _ = writeln!( @@ -1515,7 +1534,7 @@ impl WSLContainerRunner { Ok(m) => m, Err(e) => { let _ = writeln!(logger, "[WSLC] {}", e); - return Err(ScriptResponse::error(&e)); + return Err(WslcError::Rejected(e).into_response()); } }; @@ -1864,7 +1883,7 @@ impl StartedContainer { ) -> Result<(i32, WaitOutcome), ScriptResponse> { // The handle is `Send`, so this may run on a thread that never entered // the apartment `init_and_load_sdk` established; join it for the call. - let _com = ComApartment::enter().map_err(|e| ScriptResponse::error(&e))?; + let _com = ComApartment::enter().map_err(|e| WslcError::Host(e).into_response())?; // SAFETY: `self` owns live process / container handles and a live SDK. unsafe { WSLContainerRunner::wait_for_process( diff --git a/src/core/mxc_engine/src/dispatch.rs b/src/core/mxc_engine/src/dispatch.rs index ae4030b62..ba1c11541 100644 --- a/src/core/mxc_engine/src/dispatch.rs +++ b/src/core/mxc_engine/src/dispatch.rs @@ -78,9 +78,10 @@ pub fn spawn_runner( } /// Map a backend's `spawn` failure `ScriptResponse` to an -/// [`MxcError`], preserving the `BackendUnavailable` phase (so callers can fall -/// back to a lower tier) and folding any `extended_error` detail into the -/// message — rather than flattening everything to a generic `BackendError`. +/// [`MxcError`], preserving the failure phase (so callers can fall back to a +/// lower tier, or tell a rejected request from a broken one) and folding any +/// `extended_error` detail into the message — rather than flattening +/// everything to a generic `BackendError`. fn map_spawn_error(resp: ScriptResponse) -> MxcError { use wxc_common::models::FailurePhase; @@ -94,6 +95,8 @@ fn map_spawn_error(resp: ScriptResponse) -> MxcError { } match resp.failure_phase { FailurePhase::BackendUnavailable => MxcError::backend_unavailable(message), + // The request itself cannot be honored, so a blind retry will not help. + FailurePhase::Rejected => MxcError::policy_validation(message), _ => MxcError::backend_error(message), } } From 7712f554d76e52f7752d7eea6addb5a784ee138e Mon Sep 17 00:00:00 2001 From: Soham Das Date: Tue, 25 Aug 2026 15:13:01 -0700 Subject: [PATCH 2/2] Addressed PR comments --- .../wslc/common/src/wsl_container_runner.rs | 26 +++++++++++- src/core/mxc_engine/src/dispatch.rs | 41 ++++++++++++++++++- 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/src/backends/wslc/common/src/wsl_container_runner.rs b/src/backends/wslc/common/src/wsl_container_runner.rs index cabd2e4d9..bbb743f94 100644 --- a/src/backends/wslc/common/src/wsl_container_runner.rs +++ b/src/backends/wslc/common/src/wsl_container_runner.rs @@ -665,7 +665,10 @@ impl ScriptRunner for WSLContainerRunner { ) .into_response()); } - validate_network_policy_support(request, NetworkPolicySupport::LEGACY)?; + // The shared validator returns an untagged response; retag it so its + // rejections reach SDK callers as `policy_validation` like the checks above. + validate_network_policy_support(request, NetworkPolicySupport::LEGACY) + .map_err(|resp| WslcError::Rejected(resp.error_message).into_response())?; Ok(()) } @@ -2379,6 +2382,27 @@ mod tests { assert!(err.error_message.contains("allowLocalNetwork")); } + #[test] + fn validate_runner_tags_shared_validator_rejections() { + // The shared network validator builds untagged responses; WSLc retags them + // so callers get `policy_validation` rather than an opaque backend error. + let mut request = ExecutionRequest { + containment: wxc_common::models::ContainmentBackend::Wslc, + ..Default::default() + }; + request.policy.network_egress = Some(wxc_common::models::NetworkEgressPolicy { + default: wxc_common::models::NetworkAction::Allow, + ..Default::default() + }); + let runner = WSLContainerRunner::new(&WslcConfig::default()); + let err = runner.validate_runner(&request).unwrap_err(); + assert!(err.error_message.contains("network.egress.default")); + assert_eq!( + err.failure_phase, + wxc_common::models::FailurePhase::Rejected + ); + } + #[test] fn validate_runner_accepts_bare_defaults() { // Full cutoff / full NAT (no host lists) is enforceable — must pass. diff --git a/src/core/mxc_engine/src/dispatch.rs b/src/core/mxc_engine/src/dispatch.rs index ba1c11541..d99b4ea2d 100644 --- a/src/core/mxc_engine/src/dispatch.rs +++ b/src/core/mxc_engine/src/dispatch.rs @@ -266,7 +266,7 @@ fn spawn_wslc( #[cfg(test)] mod tests { - use super::{ensure_host_supported, spawn_runner}; + use super::{ensure_host_supported, map_spawn_error, spawn_runner}; use crate::policy::{build_request, SandboxPolicy}; use wxc_common::logger::{Logger, Mode}; use wxc_common::models::ContainmentBackend; @@ -282,6 +282,45 @@ mod tests { } } + fn spawn_failure( + phase: wxc_common::models::FailurePhase, + ) -> wxc_common::models::ScriptResponse { + wxc_common::models::ScriptResponse { + error_message: "wslc rejected the request".to_string(), + failure_phase: phase, + ..Default::default() + } + } + + #[test] + fn rejected_phase_maps_to_policy_validation() { + use wxc_common::models::FailurePhase; + + let err = map_spawn_error(spawn_failure(FailurePhase::Rejected)); + assert_eq!(err.code, MxcErrorCode::PolicyValidation); + assert_eq!(err.message, "wslc rejected the request"); + } + + #[test] + fn unavailable_and_unset_phases_keep_their_codes() { + use wxc_common::models::FailurePhase; + + // `None` is the default, so an unclassified failure must stay a generic + // backend error rather than being mistaken for a rejection. + assert_eq!( + map_spawn_error(spawn_failure(FailurePhase::BackendUnavailable)).code, + MxcErrorCode::BackendUnavailable + ); + assert_eq!( + map_spawn_error(spawn_failure(FailurePhase::None)).code, + MxcErrorCode::BackendError + ); + assert_eq!( + map_spawn_error(spawn_failure(FailurePhase::LaunchFailed)).code, + MxcErrorCode::BackendError + ); + } + #[test] fn streaming_rejects_dry_run() { // `dry_run` ("validate, don't execute") has no process to stream, so the