diff --git a/sdk/src/sandbox.ts b/sdk/src/sandbox.ts index 6ca0a3198..76f812cb7 100644 --- a/sdk/src/sandbox.ts +++ b/sdk/src/sandbox.ts @@ -9,6 +9,7 @@ import { parse as semverParse } from 'semver'; import { SandboxPolicy, ContainerConfig, ContainmentType, ContainmentBackend } from './types.js'; import { prepareSpawn, diagLogVersion } from './helper.js'; import { diagLog } from './diagnostic.js'; +import { MxcError, mxcErrorFromCode } from './errors.js'; const SUPPORTED_VERSION = '0.6.0-alpha'; const MIN_VERSION = '0.4.0-alpha'; @@ -697,6 +698,15 @@ export function spawnSandboxAsync( ptyProcess.onExit((event: { exitCode: number; signal?: number }) => { // Note: wxc-exec doesn't separate stdout/stderr when using PTY // All output is combined + // + // Check for structured error envelopes from wxc-exec on failure. + if (event.exitCode !== 0) { + const mxcError = tryParseErrorEnvelopeFromLines(output); + if (mxcError) { + reject(mxcError); + return; + } + } resolve({ stdout: output, stderr: '', @@ -708,3 +718,27 @@ export function spawnSandboxAsync( } }); } + +/** + * Scans a multi-line string for a JSON error envelope emitted by wxc-exec + * on stderr. Returns the first matching envelope, or null if none found. + * The envelope format is: `{"error": {"code": "...", "message": "...", ...}}` + */ +function tryParseErrorEnvelopeFromLines(output: string): MxcError | null { + for (const line of output.split('\n')) { + const trimmed = line.trim(); + if (!trimmed.startsWith('{')) continue; + try { + const parsed = JSON.parse(trimmed); + if (parsed && typeof parsed === 'object' && 'error' in parsed) { + const env = parsed.error; + if (env && typeof env.code === 'string' && typeof env.message === 'string') { + return mxcErrorFromCode(env.code, env.message, env.details); + } + } + } catch { + // Not valid JSON on this line, continue scanning. + } + } + return null; +} diff --git a/sdk/tests/integration/package-lock.json b/sdk/tests/integration/package-lock.json index 9bc44d008..ad597d8f6 100644 --- a/sdk/tests/integration/package-lock.json +++ b/sdk/tests/integration/package-lock.json @@ -20,7 +20,7 @@ }, "../..": { "name": "@microsoft/mxc-sdk", - "version": "0.2.0", + "version": "0.2.1", "license": "MIT", "dependencies": { "node-pty": "^1.2.0-beta.12", diff --git a/src/bwrap_common/src/bwrap_runner.rs b/src/bwrap_common/src/bwrap_runner.rs index 0a2777ac5..fd7522409 100644 --- a/src/bwrap_common/src/bwrap_runner.rs +++ b/src/bwrap_common/src/bwrap_runner.rs @@ -170,6 +170,7 @@ impl ScriptRunner for BubblewrapScriptRunner { "Bubblewrap: script timed out after {}ms", request.script_timeout ), + ..Default::default() }; } Err(WaitError::Io(error)) => { @@ -186,6 +187,7 @@ impl ScriptRunner for BubblewrapScriptRunner { standard_out: join_reader(stdout_handle), standard_err: join_reader(stderr_handle), error_message: String::new(), + ..Default::default() } } } diff --git a/src/lxc_common/src/lxc_runner.rs b/src/lxc_common/src/lxc_runner.rs index 34a1ab9ad..86aba9760 100644 --- a/src/lxc_common/src/lxc_runner.rs +++ b/src/lxc_common/src/lxc_runner.rs @@ -223,6 +223,7 @@ impl LxcScriptRunner { standard_out: stdout, standard_err: stderr, error_message: String::new(), + ..Default::default() }, Err(e) => ScriptResponse::error(&format!("Execution failed: {}", e)), }; diff --git a/src/seatbelt_common/src/seatbelt_runner.rs b/src/seatbelt_common/src/seatbelt_runner.rs index 614d9c0ca..2bb1c325b 100644 --- a/src/seatbelt_common/src/seatbelt_runner.rs +++ b/src/seatbelt_common/src/seatbelt_runner.rs @@ -102,6 +102,7 @@ impl ScriptRunner for SeatbeltScriptRunner { standard_out: String::new(), standard_err: String::new(), error_message: e, + ..Default::default() } } }; @@ -344,21 +345,18 @@ impl SeatbeltScriptRunner { let result = match wait_with_timeout(&mut child, timeout) { Ok(status) => ScriptResponse { exit_code: status.code().unwrap_or(-1), - standard_out: String::new(), - standard_err: String::new(), - error_message: String::new(), + ..Default::default() }, Err(WaitError::Timeout) => { let _ = child.kill(); let _ = child.wait(); ScriptResponse { exit_code: -1, - standard_out: String::new(), - standard_err: String::new(), error_message: format!( "Seatbelt: terminal timed out after {}ms", request.script_timeout ), + ..Default::default() } } Err(WaitError::Io(error)) => error_response(format!("wait failed: {error}")), @@ -427,9 +425,8 @@ fn build_sandbox_command( fn error_response(message: String) -> ScriptResponse { ScriptResponse { exit_code: -1, - standard_out: String::new(), - standard_err: String::new(), error_message: message, + ..Default::default() } } diff --git a/src/wslc_common/src/wsl_container_runner.rs b/src/wslc_common/src/wsl_container_runner.rs index 4be96ef10..1009dfb48 100644 --- a/src/wslc_common/src/wsl_container_runner.rs +++ b/src/wslc_common/src/wsl_container_runner.rs @@ -834,6 +834,7 @@ impl WSLContainerRunner { } else { String::new() }, + ..Default::default() } } diff --git a/src/wxc/src/main.rs b/src/wxc/src/main.rs index 1ebcd2bff..bb8707d2b 100644 --- a/src/wxc/src/main.rs +++ b/src/wxc/src/main.rs @@ -573,5 +573,26 @@ fn main() { if !response.standard_err.is_empty() { eprint!("{}", response.standard_err); } + + // Emit a structured JSON error envelope on stderr for SDK/caller consumption + // when the runner produced an error message (one-shot flows only). + // In PTY mode stderr is merged into the PTY output stream, so the envelope + // appears inline -- callers (e.g. copilot) can parse it from the output. + if response.exit_code != 0 && !response.error_message.is_empty() { + let mut envelope = serde_json::json!({ + "error": { + "code": "backend_error", + "message": response.error_message, + } + }); + if !response.extended_error.is_empty() { + envelope["error"]["extended_error"] = + serde_json::Value::String(response.extended_error.clone()); + } + if let Ok(json) = serde_json::to_string(&envelope) { + eprintln!("{json}"); + } + } + process::exit(response.exit_code); } diff --git a/src/wxc_common/src/appcontainer_runner.rs b/src/wxc_common/src/appcontainer_runner.rs index 78fde8eb5..2399c99a7 100644 --- a/src/wxc_common/src/appcontainer_runner.rs +++ b/src/wxc_common/src/appcontainer_runner.rs @@ -465,6 +465,19 @@ impl AppContainerScriptRunner { // pass CREATE_NEW_CONSOLE or DETACH_PROCESS, the child shares our console. let mut pi = PROCESS_INFORMATION::default(); + // Pre-launch check: abort if policy paths are on ReFS (Dev Drive) volumes + // where BFS cannot enforce filesystem policy. + if let Some(diag) = crate::launch_diagnostics::check_refs_volumes( + &request.policy.readonly_paths, + &request.policy.readwrite_paths, + ) { + logger.log_line(&format!( + "Error: Pre-launch diagnostic [{}]: {}", + diag.kind, diag.message + )); + return Err(WxcError::Process(diag.message)); + } + unsafe { CreateProcessW( PCWSTR::null(), @@ -564,6 +577,7 @@ impl AppContainerScriptRunner { standard_out: String::new(), standard_err: String::new(), error_message: String::new(), + ..Default::default() }) } @@ -605,6 +619,8 @@ impl Default for AppContainerScriptRunner { impl ScriptRunner for AppContainerScriptRunner { fn execute(&mut self, request: &CodexRequest, logger: &mut Logger) -> ScriptResponse { use crate::filesystem_bfs::FileSystemBfsManager; + use crate::launch_diagnostics::diagnose_process_exit; + use crate::models::FailurePhase; use crate::network_manager::NetworkManager; // Apply experimental features when flag is set @@ -658,13 +674,35 @@ impl ScriptRunner for AppContainerScriptRunner { } } - let response = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let mut response = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { self.run_internal(request, logger) })) { Ok(r) => r, Err(_) => ScriptResponse::error("Unknown error during script execution."), }; + // Post-failure diagnostics: if the child failed, check for known + // environment issues and enrich the error message. + if response.exit_code != 0 { + response.failure_phase = FailurePhase::ProcessExited; + if let Some(diag) = diagnose_process_exit( + &request.script_code, + &request.policy.readonly_paths, + &request.policy.readwrite_paths, + response.exit_code as u32, + ) { + logger.log_line(&format!( + "Error: Launch diagnostic [{}]: {}", + diag.kind, diag.message + )); + if !response.error_message.is_empty() { + response.extended_error = response.error_message.clone(); + } + response.error_message = diag.message.clone(); + response.standard_err.push_str(&diag.message); + } + } + network_manager.stop_all(!request.lifecycle.preserve_policy, logger); if bfs_manager.configured() && !request.lifecycle.preserve_policy { bfs_manager.remove_configuration(logger); diff --git a/src/wxc_common/src/base_container_runner.rs b/src/wxc_common/src/base_container_runner.rs index 82af9763f..3ee2dbb92 100644 --- a/src/wxc_common/src/base_container_runner.rs +++ b/src/wxc_common/src/base_container_runner.rs @@ -22,12 +22,13 @@ use windows::Win32::System::Threading::{ }; use windows_core::PCWSTR; +use crate::launch_diagnostics::{diagnose_create_process_failure, diagnose_process_exit}; use crate::log_symbols::{ EMOJI_ALLOWED, EMOJI_BLOCKED, EMOJI_NEUTRAL, EMOJI_SECTION, EMOJI_WARNING, }; use crate::logger::Logger; use crate::models::{ - CodexRequest, NetworkEnforcementMode, NetworkPolicy, ProxyAddress, ScriptResponse, + CodexRequest, FailurePhase, NetworkEnforcementMode, NetworkPolicy, ProxyAddress, ScriptResponse, }; use crate::proxy_coordinator::ProxyCoordinator; use crate::sandbox_tracking::{self, TrackingEntry}; @@ -85,10 +86,6 @@ pub struct BaseContainerRunner { proxy_coordinator: ProxyCoordinator, } -/// Windows error code for a function that exists but is not implemented -/// (e.g., disabled via feature-enablement mechanisms). -const ERROR_CALL_NOT_IMPLEMENTED: u32 = 120; - /// SandboxSpec FlatBuffer schema version embedded in every spec payload. const SANDBOX_SPEC_VERSION: &str = "0.1.0"; @@ -595,6 +592,26 @@ impl ScriptRunner for BaseContainerRunner { let ac_sid_str = derive_sid_string_from_name(&identity); let _ = writeln!(logger, "AppContainerSID: {ac_sid_str}"); + // Pre-launch check: abort if policy paths are on ReFS (Dev Drive) volumes + // where BFS cannot enforce filesystem policy. + if let Some(diag) = crate::launch_diagnostics::check_refs_volumes( + &request.policy.readonly_paths, + &request.policy.readwrite_paths, + ) { + let _ = writeln!( + logger, + "Error: Pre-launch diagnostic [{}]: {}", + diag.kind, diag.message + ); + return ScriptResponse { + exit_code: -1, + error_message: diag.message.clone(), + standard_err: diag.message, + failure_phase: FailurePhase::LaunchFailed, + ..Default::default() + }; + } + // 4. Call Experimental_CreateProcessInSandbox. let success = unsafe { create_process_in_sandbox( @@ -634,18 +651,34 @@ impl ScriptRunner for BaseContainerRunner { logger, ); } + + // + // Diagnose the launch failure (FailurePhase::LaunchFailed). + // let err = unsafe { GetLastError() }; - if err.0 == ERROR_CALL_NOT_IMPLEMENTED { - return ScriptResponse::error( - "Experimental_CreateProcessInSandbox returned ERROR_CALL_NOT_IMPLEMENTED. \ - The BaseContainer feature may be disabled on this OS build \ - (e.g., via feature-enablement mechanisms). \ - Use schema version '0.4.0-alpha' to fall back to the AppContainer backend.", - ); - } - return ScriptResponse::error(&format!( - "Experimental_CreateProcessInSandbox failed: {err:?}" - )); + let diag = diagnose_create_process_failure( + err.0, + &request.script_code, + &request.policy.readonly_paths, + ); + + let extended_error = format!("Experimental_CreateProcessInSandbox failed: {err:?}"); + let _ = writeln!(logger, "Error: {extended_error}"); + + let _ = writeln!( + logger, + "Error: Launch diagnostic [{}]: {}", + diag.kind, diag.message + ); + + return ScriptResponse { + exit_code: -1, + error_message: diag.message.clone(), + standard_err: diag.message, + extended_error, + failure_phase: FailurePhase::LaunchFailed, + ..Default::default() + }; } let _ = writeln!(logger, "process created (PID: {})", pi.dwProcessId); @@ -699,11 +732,36 @@ impl ScriptRunner for BaseContainerRunner { // Stop the builtin test proxy if it was started. self.proxy_coordinator.stop(logger); + // + // Diagnose the application failure (FailurePhase::ProcessExited). + // + let (error_message, failure_phase) = if exit_code != 0 { + if let Some(diag) = diagnose_process_exit( + &request.script_code, + &request.policy.readonly_paths, + &request.policy.readwrite_paths, + exit_code, + ) { + let _ = writeln!( + logger, + "Error: Launch diagnostic [{}]: {}", + diag.kind, diag.message + ); + (diag.message, FailurePhase::ProcessExited) + } else { + (String::new(), FailurePhase::ProcessExited) + } + } else { + (String::new(), FailurePhase::None) + }; + ScriptResponse { exit_code: exit_code as i32, standard_out: String::new(), - standard_err: String::new(), - error_message: String::new(), + standard_err: error_message.clone(), + error_message, + failure_phase, + ..Default::default() } } } diff --git a/src/wxc_common/src/isolation_session/one_shot.rs b/src/wxc_common/src/isolation_session/one_shot.rs index c7bad7b15..e2b81b28f 100644 --- a/src/wxc_common/src/isolation_session/one_shot.rs +++ b/src/wxc_common/src/isolation_session/one_shot.rs @@ -133,6 +133,7 @@ impl ScriptRunner for IsolationSessionRunner { standard_out: String::new(), standard_err: String::new(), error_message: String::new(), + ..Default::default() } } } diff --git a/src/wxc_common/src/launch_diagnostics.rs b/src/wxc_common/src/launch_diagnostics.rs new file mode 100644 index 000000000..311dcd4ed --- /dev/null +++ b/src/wxc_common/src/launch_diagnostics.rs @@ -0,0 +1,589 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Post-failure launch diagnostics. +//! +//! When a process-creation call (`CreateProcessW` or +//! `Experimental_CreateProcessInSandbox`) fails, or when the child exits +//! with a non-zero code immediately, the caller can invoke +//! [`diagnose_create_process_failure`] or [`diagnose_process_exit`] to check +//! for well-known environment conditions and produce an actionable message. +//! +//! This module is intentionally decoupled from the runner implementations +//! so both `AppContainerScriptRunner` and `BaseContainerRunner` share the +//! same detection logic. + +use std::path::Path; + +/// A structured diagnostic describing *why* a sandboxed process launch failed +/// and what the user can do about it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LaunchDiagnostic { + /// Machine-readable discriminator (e.g. `"packaged_app"`, + /// `"missing_filesystem_access"`). + pub kind: &'static str, + /// Human-readable explanation of the failure including remediation guidance. + pub message: String, +} + +// -- Public API -------------------------------------------------------------- + +/// Diagnose a failed `CreateProcess` / `Experimental_CreateProcessInSandbox` +/// call. Inspects the Win32 error code and the command line to identify known +/// failure conditions. +/// +/// Always returns a `LaunchDiagnostic` -- if no specific heuristic matches, +/// a generic message is produced from the raw error code. +pub fn diagnose_create_process_failure( + win32_error: u32, + command_line: &str, + readonly_paths: &[String], +) -> LaunchDiagnostic { + // Check for feature-not-enabled (velocity keys). + if win32_error == ERROR_CALL_NOT_IMPLEMENTED || win32_error == E_NOTIMPL { + return diagnose_api_not_implemented(); + } + + // Resolve the exe from the command line for further heuristics. + let bare_exe = Path::new(extract_exe_from_command_line(command_line)); + let resolved_exe = resolve_exe_on_path(bare_exe); + + if let Some(diag) = check_exe_heuristics(&resolved_exe, readonly_paths, None) { + return diag; + } + + // Generic fallback. + LaunchDiagnostic { + kind: "create_process_failed", + message: format!( + "CreateProcessInSandbox failed with error code {win32_error} (0x{win32_error:08X})." + ), + } +} + +/// Diagnose a process that launched successfully but exited with a non-zero +/// code. Returns `None` when no recognized condition matches. +pub fn diagnose_process_exit( + command_line: &str, + readonly_paths: &[String], + readwrite_paths: &[String], + exit_code: u32, +) -> Option { + let bare_exe = Path::new(extract_exe_from_command_line(command_line)); + let resolved_exe = resolve_exe_on_path(bare_exe); + if let Some(diag) = check_exe_heuristics(&resolved_exe, readonly_paths, Some(exit_code)) { + return Some(diag); + } + check_refs_volumes(readonly_paths, readwrite_paths) +} + +// -- Constants --------------------------------------------------------------- + +/// Windows error code for a function that exists but is not implemented. +const ERROR_CALL_NOT_IMPLEMENTED: u32 = 120; + +/// HRESULT E_NOTIMPL -- the OS may set this via GetLastError when the +/// feature is gated behind velocity keys. +const E_NOTIMPL: u32 = 0x8000_4001; + +/// Velocity key IDs required by the BaseContainer feature. +const REQUIRED_VELOCITY_KEYS: &[(u32, &str)] = &[ + (61389575, "BaseContainer core"), + (61155944, "BaseContainer sandbox spec"), +]; + +/// STATUS_DLL_INIT_FAILED -- typically means Win32k (UI subsystem) was blocked. +const STATUS_DLL_INIT_FAILED: u32 = 0xC000_0142; + +// -- Internal heuristics ----------------------------------------------------- + +/// Checks exe-path-based heuristics (packaged app, DLL init failure, missing +/// root access). Returns `None` if nothing matches. +fn check_exe_heuristics( + exe_path: &Path, + readonly_paths: &[String], + exit_code: Option, +) -> Option { + if is_packaged_app(exe_path) { + return Some(LaunchDiagnostic { + kind: "packaged_app", + message: format!( + "The target executable '{}' appears to be a packaged (MSIX) app. \ + Packaged apps cannot be launched inside a sandboxed container. \ + Uninstall the packaged version and install an unpackaged build.", + exe_path.display() + ), + }); + } + + if exit_code == Some(STATUS_DLL_INIT_FAILED) && is_powershell(exe_path) { + return Some(LaunchDiagnostic { + kind: "dll_init_failed_ui_required", + message: "PowerShell exited with STATUS_DLL_INIT_FAILED (0xC0000142). \ + This typically means the sandbox is blocking Win32k system calls \ + (UI subsystem access), which PowerShell requires to initialize. \ + Enable UI access in your sandbox policy (set `ui.allowWindows: true`)." + .to_string(), + }); + } + + if missing_root_readonly(exe_path, readonly_paths) { + let root = drive_root(exe_path); + return Some(LaunchDiagnostic { + kind: "missing_filesystem_access", + message: format!( + "pwsh.exe versions before 7.7 require read-only access to the \ + root drive ({root}) to start. The current sandbox policy does \ + not grant this access. Add \"{root}\" to `readonlyPaths` in your \ + sandbox policy, or upgrade to pwsh 7.7+." + ), + }); + } + + None +} + +/// Produces a diagnostic when the API returns E_NOTIMPL/ERROR_CALL_NOT_IMPLEMENTED, +/// indicating the feature is gated behind velocity keys. +fn diagnose_api_not_implemented() -> LaunchDiagnostic { + let key_status = check_velocity_keys(); + + let message = if key_status.is_empty() { + "Experimental_CreateProcessInSandbox returned E_NOTIMPL. \ + The BaseContainer feature is not enabled on this OS build. \ + Ensure the required velocity keys are enabled, or use schema \ + version '0.4.0-alpha' to fall back to the AppContainer backend." + .to_string() + } else { + let disabled: Vec<_> = key_status.iter().filter(|(_, enabled)| !enabled).collect(); + if disabled.is_empty() { + "Experimental_CreateProcessInSandbox returned E_NOTIMPL. \ + All known velocity keys appear enabled, but the feature is \ + still gated. The OS build may require additional enablement. \ + Use schema version '0.4.0-alpha' to fall back to the AppContainer backend." + .to_string() + } else { + let disabled_list: Vec = + disabled.iter().map(|(id, _)| id.to_string()).collect(); + format!( + "Experimental_CreateProcessInSandbox returned E_NOTIMPL. \ + The following velocity keys are not enabled: {}. \ + Enable them and retry, or use schema version '0.4.0-alpha' \ + to fall back to the AppContainer backend.", + disabled_list.join(", ") + ) + } + }; + + LaunchDiagnostic { + kind: "feature_not_enabled", + message, + } +} + +/// Query the Windows Feature Store registry to check whether each required +/// velocity key is enabled. Returns a list of `(key_id, is_enabled)` pairs. +/// Returns an empty vec if the registry cannot be read. +fn check_velocity_keys() -> Vec<(u32, bool)> { + #[cfg(target_os = "windows")] + { + use winreg::enums::HKEY_LOCAL_MACHINE; + use winreg::RegKey; + + let mut results = Vec::new(); + for &(key_id, _label) in REQUIRED_VELOCITY_KEYS { + let enabled = [4u32, 8].iter().any(|priority| { + let path = format!( + r"SOFTWARE\Microsoft\Windows NT\CurrentVersion\FeatureManagement\Overrides\{}\{}", + priority, key_id + ); + if let Ok(reg_key) = RegKey::predef(HKEY_LOCAL_MACHINE).open_subkey(&path) { + if let Ok(state) = reg_key.get_value::("EnabledState") { + return state == 2; + } + } + false + }); + results.push((key_id, enabled)); + } + results + } + #[cfg(not(target_os = "windows"))] + { + Vec::new() + } +} + +/// Pre-launch check: detect if any sandbox policy paths reference ReFS volumes +/// (e.g. Dev Drives). BFS (Bind Filter) does not work correctly on ReFS, so +/// filesystem policy will not be enforced on those volumes. +/// +/// Call this **before** launching the sandboxed process. If it returns `Some`, +/// the caller should abort launch and surface the diagnostic to the user. +pub fn check_refs_volumes( + readonly_paths: &[String], + readwrite_paths: &[String], +) -> Option { + #[cfg(target_os = "windows")] + { + let system_drive = std::env::var("SystemDrive") + .unwrap_or_else(|_| "C:".to_string()) + .to_uppercase(); + + // Collect unique non-system drive letters from all policy paths. + let mut non_system_drives: Vec = Vec::new(); + for path in readonly_paths.iter().chain(readwrite_paths.iter()) { + if let Some(drive_letter) = extract_drive_letter(path) { + let upper = drive_letter.to_ascii_uppercase(); + let drive_prefix = format!("{}:", upper); + if drive_prefix != system_drive && !non_system_drives.contains(&upper) { + non_system_drives.push(upper); + } + } + } + + if non_system_drives.is_empty() { + return None; + } + + // Check which of these drives are ReFS. + let refs_drives: Vec = non_system_drives + .into_iter() + .filter(|&d| is_refs_volume(d)) + .collect(); + + if refs_drives.is_empty() { + return None; + } + + let drive_list: String = refs_drives + .iter() + .map(|d| format!("{d}:")) + .collect::>() + .join(", "); + Some(LaunchDiagnostic { + kind: "refs_volume_unsupported", + message: format!( + "The sandbox policy references paths on ReFS volume(s) ({drive_list}) which \ + may be a Dev Drive. The Bind Filter (BFS) used to enforce filesystem policy \ + does not work correctly on ReFS volumes, so sandboxed processes may not be \ + able to access files on those paths. Move your working directory to an NTFS \ + volume, or remove those paths from readonlyPaths/readwritePaths." + ), + }) + } + #[cfg(not(target_os = "windows"))] + { + let _ = (readonly_paths, readwrite_paths); + None + } +} + +/// Extract the drive letter from a path like "D:\foo" or "d:/bar". +fn extract_drive_letter(path: &str) -> Option { + let bytes = path.as_bytes(); + if bytes.len() >= 2 && bytes[1] == b':' && bytes[0].is_ascii_alphabetic() { + Some(bytes[0] as char) + } else { + None + } +} + +/// Check if a volume uses ReFS by calling GetVolumeInformationW. +#[cfg(target_os = "windows")] +fn is_refs_volume(drive_letter: char) -> bool { + use windows::Win32::Storage::FileSystem::GetVolumeInformationW; + + let root = format!("{}:\\", drive_letter); + let root_wide: Vec = root.encode_utf16().chain(std::iter::once(0)).collect(); + + let mut fs_name_buf = [0u16; 64]; + let success = unsafe { + GetVolumeInformationW( + windows::core::PCWSTR(root_wide.as_ptr()), + None, // volume name (not needed) + None, // serial number + None, // max component length + None, // filesystem flags + Some(&mut fs_name_buf), // filesystem name + ) + }; + + if success.is_err() { + return false; + } + + let fs_name = String::from_utf16_lossy( + &fs_name_buf[..fs_name_buf + .iter() + .position(|&c| c == 0) + .unwrap_or(fs_name_buf.len())], + ); + fs_name.eq_ignore_ascii_case("ReFS") +} + +/// Attempt to resolve a potentially bare executable name (e.g. `pwsh.exe`) +/// to its full path by searching the system PATH. Returns the original path +/// if resolution fails or the input is already absolute. +pub fn resolve_exe_on_path(exe: &Path) -> std::path::PathBuf { + if exe.is_absolute() { + return exe.to_path_buf(); + } + if let Some(path_var) = std::env::var_os("PATH") { + for dir in std::env::split_paths(&path_var) { + let candidate = dir.join(exe); + if candidate.exists() { + return candidate; + } + } + } + exe.to_path_buf() +} + +/// Extract the executable path from a command line string. +/// +/// Handles both quoted paths (`"C:\Program Files\...\pwsh.exe" -args`) and +/// unquoted paths (`pwsh.exe -args`). Strips surrounding quotes if present. +pub fn extract_exe_from_command_line(command_line: &str) -> &str { + let trimmed = command_line.trim(); + if let Some(after_quote) = trimmed.strip_prefix('"') { + match after_quote.find('"') { + Some(end) => &after_quote[..end], + None => trimmed.split_whitespace().next().unwrap_or(""), + } + } else { + trimmed.split_whitespace().next().unwrap_or("") + } +} + +// -- Internal detection helpers ---------------------------------------------- + +fn is_packaged_app(exe_path: &Path) -> bool { + let normalized = exe_path.to_string_lossy().to_lowercase(); + normalized.contains("\\windowsapps\\") || normalized.contains("/windowsapps/") +} + +fn is_powershell(exe_path: &Path) -> bool { + let filename = exe_path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_lowercase(); + filename == "pwsh.exe" || filename == "powershell.exe" +} + +fn missing_root_readonly(exe_path: &Path, readonly_paths: &[String]) -> bool { + let filename = exe_path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_lowercase(); + if filename != "pwsh.exe" { + return false; + } + let root = drive_root(exe_path); + !readonly_paths + .iter() + .any(|p| p.eq_ignore_ascii_case(&root) || p == "\\") +} + +fn drive_root(exe_path: &Path) -> String { + let s = exe_path.to_string_lossy(); + if s.len() >= 3 && s.as_bytes()[1] == b':' { + format!("{}\\", &s[..2]) + } else { + "C:\\".to_string() + } +} + +// -- Tests ------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + // -- diagnose_create_process_failure tests -- + + #[test] + fn api_not_implemented_triggers_feature_diagnostic() { + let diag = diagnose_create_process_failure(ERROR_CALL_NOT_IMPLEMENTED, "pwsh.exe", &[]); + assert_eq!(diag.kind, "feature_not_enabled"); + assert!(diag.message.contains("velocity")); + } + + #[test] + fn e_notimpl_triggers_feature_diagnostic() { + let diag = diagnose_create_process_failure(E_NOTIMPL, "pwsh.exe", &[]); + assert_eq!(diag.kind, "feature_not_enabled"); + } + + #[test] + fn packaged_app_detected_from_command_line() { + let cmd = + r#""C:\Program Files\WindowsApps\Microsoft.PowerShell_7.4.0\pwsh.exe" -NoProfile"#; + let diag = diagnose_create_process_failure(87, cmd, &[]); + assert_eq!(diag.kind, "packaged_app"); + assert!(diag.message.contains("packaged")); + } + + #[test] + fn generic_fallback_for_unknown_error() { + let diag = diagnose_create_process_failure(5, "cmd.exe", &["C:\\".to_string()]); + assert_eq!(diag.kind, "create_process_failed"); + assert!(diag.message.contains("5")); + } + + // -- diagnose_process_exit tests -- + + #[test] + fn dll_init_failed_pwsh_triggers_ui_diagnostic() { + let diag = diagnose_process_exit( + r#""C:\Program Files\PowerShell\7\pwsh.exe" -NoProfile"#, + &["C:\\".to_string()], + &[], + STATUS_DLL_INIT_FAILED, + ); + assert!(diag.is_some()); + let d = diag.unwrap(); + assert_eq!(d.kind, "dll_init_failed_ui_required"); + assert!(d.message.contains("STATUS_DLL_INIT_FAILED")); + assert!(d.message.contains("UI access")); + } + + #[test] + fn dll_init_failed_powershell_exe_triggers_ui_diagnostic() { + let diag = diagnose_process_exit( + r#""C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe""#, + &["C:\\".to_string()], + &[], + STATUS_DLL_INIT_FAILED, + ); + assert!(diag.is_some()); + assert_eq!(diag.unwrap().kind, "dll_init_failed_ui_required"); + } + + #[test] + fn dll_init_failed_non_powershell_does_not_trigger() { + let diag = diagnose_process_exit( + r"C:\tools\myapp.exe", + &["C:\\".to_string()], + &[], + STATUS_DLL_INIT_FAILED, + ); + assert!(diag.is_none()); + } + + #[test] + fn different_exit_code_pwsh_does_not_trigger_ui_diagnostic() { + let diag = diagnose_process_exit( + r#""C:\Program Files\PowerShell\7\pwsh.exe""#, + &["C:\\".to_string()], + &[], + 1, + ); + assert!(diag.is_none()); + } + + #[test] + fn missing_root_readonly_from_exit() { + let diag = + diagnose_process_exit(r#""C:\Program Files\PowerShell\7\pwsh.exe""#, &[], &[], 1); + assert!(diag.is_some()); + assert_eq!(diag.unwrap().kind, "missing_filesystem_access"); + } + + #[test] + fn pwsh_with_root_readonly_no_diagnostic() { + let diag = diagnose_process_exit( + r#""C:\Program Files\PowerShell\7\pwsh.exe""#, + &["C:\\".to_string()], + &[], + 1, + ); + assert!(diag.is_none()); + } + + #[test] + fn packaged_app_takes_priority_over_missing_access() { + let cmd = r#""C:\Program Files\WindowsApps\Microsoft.PowerShell_7.4.0\pwsh.exe""#; + let diag = diagnose_process_exit(cmd, &[], &[], 1); + assert!(diag.is_some()); + assert_eq!(diag.unwrap().kind, "packaged_app"); + } + + // -- extract_exe_from_command_line tests -- + + #[test] + fn extract_exe_quoted_path_with_spaces() { + let cmd = r#""C:\Program Files\WindowsApps\Microsoft.PowerShell_7.6.1.0_x64__8wekyb3d8bbwe\pwsh.exe" -NoProfile -NoLogo"#; + let exe = extract_exe_from_command_line(cmd); + assert_eq!( + exe, + r"C:\Program Files\WindowsApps\Microsoft.PowerShell_7.6.1.0_x64__8wekyb3d8bbwe\pwsh.exe" + ); + } + + #[test] + fn extract_exe_unquoted() { + assert_eq!( + extract_exe_from_command_line("pwsh.exe -NoProfile"), + "pwsh.exe" + ); + } + + #[test] + fn extract_exe_quoted_no_args() { + let cmd = r#""C:\Program Files\PowerShell\7\pwsh.exe""#; + assert_eq!( + extract_exe_from_command_line(cmd), + r"C:\Program Files\PowerShell\7\pwsh.exe" + ); + } + + #[test] + fn extract_exe_empty() { + assert_eq!(extract_exe_from_command_line(""), ""); + } + + // -- case sensitivity / edge cases -- + + #[test] + fn case_insensitive_root_path_match() { + let diag = diagnose_process_exit( + r#""C:\Program Files\PowerShell\7\pwsh.exe""#, + &["c:\\".to_string()], + &[], + 1, + ); + assert!(diag.is_none()); + } + + #[test] + fn backslash_only_matches_as_root() { + let diag = diagnose_process_exit( + r#""C:\Program Files\PowerShell\7\pwsh.exe""#, + &["\\".to_string()], + &[], + 1, + ); + assert!(diag.is_none()); + } + + // -- extract_drive_letter tests -- + + #[test] + fn extract_drive_letter_absolute() { + assert_eq!(extract_drive_letter(r"D:\myrepo"), Some('D')); + assert_eq!(extract_drive_letter(r"c:\users"), Some('c')); + } + + #[test] + fn extract_drive_letter_none_for_unc() { + assert_eq!(extract_drive_letter(r"\\server\share"), None); + } + + #[test] + fn extract_drive_letter_none_for_relative() { + assert_eq!(extract_drive_letter("relative/path"), None); + } +} diff --git a/src/wxc_common/src/lib.rs b/src/wxc_common/src/lib.rs index f50c5df43..778e535d2 100644 --- a/src/wxc_common/src/lib.rs +++ b/src/wxc_common/src/lib.rs @@ -57,6 +57,8 @@ pub mod diagnostic; #[cfg(target_os = "windows")] pub mod base_container_runner; #[cfg(target_os = "windows")] +pub mod launch_diagnostics; +#[cfg(target_os = "windows")] pub mod sandbox_tracking; // Isolation Session (IsoEnvBroker Session API) support diff --git a/src/wxc_common/src/models.rs b/src/wxc_common/src/models.rs index a849fc871..d24021627 100644 --- a/src/wxc_common/src/models.rs +++ b/src/wxc_common/src/models.rs @@ -551,6 +551,19 @@ impl Default for CodexRequest { } } +/// Distinguishes whether an error occurred during process creation (launch) +/// or after the process started but exited with a failure code. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum FailurePhase { + /// No failure (process exited successfully, or has not been evaluated yet). + #[default] + None, + /// The CreateProcess (or equivalent) API call itself failed. + LaunchFailed, + /// The process was created but exited with a non-zero code. + ProcessExited, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] pub struct ScriptResponse { @@ -558,6 +571,14 @@ pub struct ScriptResponse { pub standard_out: String, pub standard_err: String, pub error_message: String, + /// Raw system/API error detail intended for developers and diagnostics + /// (e.g. "Experimental_CreateProcessInSandbox failed: WIN32_ERROR(1920)"). + /// Kept separate from `error_message` which holds user-friendly text. + #[serde(skip_serializing_if = "String::is_empty")] + pub extended_error: String, + /// Indicates at what phase the failure occurred. + #[serde(default)] + pub failure_phase: FailurePhase, } impl Default for ScriptResponse { @@ -567,6 +588,8 @@ impl Default for ScriptResponse { standard_out: String::new(), standard_err: String::new(), error_message: String::new(), + extended_error: String::new(), + failure_phase: FailurePhase::None, } } } diff --git a/src/wxc_common/src/windows_sandbox_runner.rs b/src/wxc_common/src/windows_sandbox_runner.rs index e2adcb01e..8caba7aba 100644 --- a/src/wxc_common/src/windows_sandbox_runner.rs +++ b/src/wxc_common/src/windows_sandbox_runner.rs @@ -173,6 +173,7 @@ impl WindowsSandboxScriptRunner { } else { result.error_message }, + ..Default::default() } } Err(err) => ScriptResponse::error(&err),