From 96773fddfa1c2b90bdf59f3e4583295b97a9eeb9 Mon Sep 17 00:00:00 2001 From: Jeff Whiteside Date: Tue, 19 May 2026 15:49:21 -0700 Subject: [PATCH 01/21] Surface launch diagnostics to SDK callers (e.g. copilot.exe) Add post-failure launch diagnostics that detect known environment issues after CreateProcessW or Experimental_CreateProcessInSandbox fails (or the child exits non-zero), and surface actionable remediation guidance. Detected conditions: - packaged_app: MSIX-packaged executables (e.g. Store-installed pwsh.exe) cannot run inside sandboxed containers. - missing_filesystem_access: pwsh.exe < 7.7 requires read-only access to the root drive (C:\) which the sandbox policy may not grant. Changes: - New shared module: wxc_common/src/launch_diagnostics.rs with detection heuristics, PATH resolution, and unit tests. - AppContainer runner: enriches error response and logs diagnostic on failure (Error: prefix for red highlighting in diagnostic console). - BaseContainer runner: same enrichment on both API failure and non-zero child exit paths. - wxc-exec main: emits structured JSON error envelope on stderr for one-shot flows so the SDK can parse it programmatically. - SDK (sandbox.ts): scans for JSON error envelope in PTY output on non-zero exit and rejects with a typed MxcError. - docs/launch-diagnostics.md: design document. Closes #348 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/launch-diagnostics.md | 120 +++++++++++ sdk/src/sandbox.ts | 34 +++ sdk/tests/integration/package-lock.json | 1 + src/wxc/src/main.rs | 14 ++ src/wxc_common/src/appcontainer_runner.rs | 30 ++- src/wxc_common/src/base_container_runner.rs | 53 ++++- src/wxc_common/src/launch_diagnostics.rs | 223 ++++++++++++++++++++ src/wxc_common/src/lib.rs | 2 + 8 files changed, 471 insertions(+), 6 deletions(-) create mode 100644 docs/launch-diagnostics.md create mode 100644 src/wxc_common/src/launch_diagnostics.rs diff --git a/docs/launch-diagnostics.md b/docs/launch-diagnostics.md new file mode 100644 index 000000000..f4bc6fee1 --- /dev/null +++ b/docs/launch-diagnostics.md @@ -0,0 +1,120 @@ +# Launch Diagnostics: Surfacing Errors to Callers (#348) + +## Problem + +Two known errors are silently swallowed today: + +1. **Packaged pwsh.exe** -- AppContainer/BaseContainer cannot launch packaged (MSIX) apps; the process just fails with a generic error. +2. **pwsh < 7.7-preview1 needs r/o access to `C:\`** -- unless the sandbox policy grants it, pwsh crashes on startup. + +Neither is detected or reported as an actionable message to the user (copilot.exe). + +## Design Goals + +- Surface errors as **structured, actionable messages** with remediation guidance. +- Work for **both** one-shot (`spawnSandbox`) and state-aware flows. +- Allow the SDK caller (copilot.exe) to programmatically distinguish these errors from generic failures. +- **Zero overhead on the happy path** -- diagnostics run only after a launch failure. + +--- + +## Implementation + +### Phase 1: Rust -- Post-failure diagnostics (shared module) + +**New file:** `src/wxc_common/src/launch_diagnostics.rs` + +A shared diagnostics module that both runners call **after** a process-creation failure to enrich the error with actionable guidance: + +```rust +pub struct LaunchDiagnostic { + pub kind: &'static str, // e.g. "packaged_app", "missing_filesystem_access" + pub message: String, // human-readable explanation + pub remediation: String, // actionable fix for the user +} + +/// Attempts to diagnose *why* a process launch failed. +/// Returns `None` if no known condition is detected (the original error passes through unchanged). +pub fn diagnose_launch_failure( + exe_path: &Path, + readonly_paths: &[String], + exit_code: Option, +) -> Option { ... } +``` + +**Checks (in order):** + +1. **`packaged_app`** -- If the resolved exe path is under `C:\Program Files\WindowsApps\` (MSIX install location), return: + - message: "The target executable is a packaged (MSIX) app which cannot run inside a sandboxed container." + - remediation: "Uninstall the packaged version and install via `winget install Microsoft.PowerShell`." + +2. **`missing_filesystem_access`** -- If the exe is `pwsh.exe` (not `powershell.exe`) and the policy does NOT grant `readonlyPaths` including the drive root (`C:\`), return: + - message: "pwsh.exe versions before 7.7 require read-only access to the root drive to start." + - remediation: "Add `C:\\` to `readonlyPaths` in your sandbox policy, or upgrade to pwsh 7.7+." + +**Callers:** + +- `appcontainer_runner.rs` -- after `CreateProcessW` fails or child exits non-zero. +- `base_container_runner.rs` -- after `Experimental_CreateProcessInSandbox` returns failure or child exits non-zero. + +Both runners already return `ScriptResponse::error(...)` for failures, so this enriches existing error paths without changing control flow. + +### Phase 2: Rust -- Structured error envelope for one-shot flows + +**File:** `src/wxc/src/main.rs` + +When a `ScriptResponse` contains a diagnostic, emit a JSON error envelope on **stderr**: + +```json +{"error": {"code": "backend_error", "message": "...", "details": {"kind": "packaged_app", "remediation": "..."}}} +``` + +This reuses `MxcErrorCode::BackendError` with a `details.kind` discriminator. The existing human-readable stderr text continues to be emitted for direct CLI users. + +### Phase 3: SDK -- Parse structured errors from one-shot flows + +**File:** `sdk/src/sandbox.ts` + +- Non-PTY path: after child exits non-zero, scan stderr for a JSON `{"error": {...}}` line. If found, throw `MxcError`. +- PTY path (merged stdout/stderr): scan combined output for the error envelope. If found, throw `MxcError`. +- Fallback: existing behavior (return exit code + raw output). + +### Phase 4: Tests and documentation + +1. Unit tests in `src/wxc_common/` for the detection heuristics. +2. Test config under `test_configs/` that triggers the packaged-app detection (manual validation). +3. This document serves as the feature's design reference. + +--- + +## Detection Heuristics + +### Packaged app detection + +```rust +fn is_packaged_app(exe_path: &Path) -> bool { + let normalized = exe_path.to_string_lossy().to_lowercase(); + normalized.contains("\\windowsapps\\") +} +``` + +### Missing filesystem access (pwsh root drive) + +```rust +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 = format!("{}\\", &exe_path.to_string_lossy()[..2]); + !readonly_paths.iter().any(|p| p.eq_ignore_ascii_case(&root) || p == "\\") +} +``` + +Version detection is deferred -- the simpler heuristic is path + filename based. Note from @asklar: this does not apply to `powershell.exe` (inbox Windows PowerShell 5.x), only to `pwsh.exe` < 7.7. + +--- + +## Open Questions + +1. **New error code vs `backend_error` + details?** Adding a first-class code (e.g. `launch_diagnostic`) requires both Rust and SDK changes to the closed union. Using `backend_error` + `details.kind` is additive-only. +2. **PTY path reliability** -- In PTY mode stdout/stderr are merged. Should the envelope use a unique prefix (e.g., `MXC_ERROR:`) for reliable parsing? +3. **pwsh version detection** -- Is it worth spawning `pwsh --version` post-failure, or is path-based detection sufficient? diff --git a/sdk/src/sandbox.ts b/sdk/src/sandbox.ts index bb478e467..b2639bcbf 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'; @@ -601,6 +602,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: '', @@ -612,3 +622,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 f8abe5b58..a31f47e5e 100644 --- a/sdk/tests/integration/package-lock.json +++ b/sdk/tests/integration/package-lock.json @@ -29,6 +29,7 @@ "devDependencies": { "@types/node": "^20.10.0", "@types/semver": "^7.7.1", + "node-gyp": "^12.2.0", "rimraf": "^6.1.3", "typescript": "^5.3.3" }, diff --git a/src/wxc/src/main.rs b/src/wxc/src/main.rs index 9ecdbfdd9..911c82a56 100644 --- a/src/wxc/src/main.rs +++ b/src/wxc/src/main.rs @@ -580,5 +580,19 @@ fn main() { if !response.standard_err.is_empty() { eprint!("{}", response.standard_err); } + + // Emit a structured JSON error envelope on stderr for SDK consumption + // when the runner produced an error message (one-shot flows only). + if response.exit_code != 0 && !response.error_message.is_empty() { + if let Ok(json) = serde_json::to_string(&serde_json::json!({ + "error": { + "code": "backend_error", + "message": response.error_message, + } + })) { + 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 9a39e0e87..cc12d6945 100644 --- a/src/wxc_common/src/appcontainer_runner.rs +++ b/src/wxc_common/src/appcontainer_runner.rs @@ -580,6 +580,7 @@ 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_launch_failure, format_diagnostic}; use crate::network_manager::NetworkManager; // Apply experimental features when flag is set @@ -633,13 +634,40 @@ 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 with actionable + // guidance. + if response.exit_code != 0 { + let bare_exe = + std::path::Path::new(request.script_code.split_whitespace().next().unwrap_or("")); + let resolved_exe = crate::launch_diagnostics::resolve_exe_on_path(bare_exe); + if let Some(diag) = diagnose_launch_failure( + &resolved_exe, + &request.policy.readonly_paths, + Some(response.exit_code as u32), + ) { + let diagnostic_text = format_diagnostic(&diag); + logger.log_line(&format!( + "Error: Launch diagnostic [{}]: {}", + diag.kind, diag.message + )); + logger.log_line(&format!("Error: Remediation: {}", diag.remediation)); + if response.error_message.is_empty() { + response.error_message = diagnostic_text.trim().to_string(); + } else { + response.error_message.push_str(&diagnostic_text); + } + response.standard_err.push_str(&diagnostic_text); + } + } + 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 85510443d..74b4649ac 100644 --- a/src/wxc_common/src/base_container_runner.rs +++ b/src/wxc_common/src/base_container_runner.rs @@ -22,6 +22,7 @@ use windows::Win32::System::Threading::{ }; use windows_core::PCWSTR; +use crate::launch_diagnostics::{diagnose_launch_failure, format_diagnostic}; use crate::log_symbols::{ EMOJI_ALLOWED, EMOJI_BLOCKED, EMOJI_NEUTRAL, EMOJI_SECTION, EMOJI_WARNING, }; @@ -598,11 +599,31 @@ impl ScriptRunner for BaseContainerRunner { Use schema version '0.4.0-alpha' to fall back to the AppContainer backend.", ); } - return ScriptResponse::error(&format!( - "Experimental_CreateProcessInSandbox failed: {err:?}" - )); + let bare_exe = + std::path::Path::new(request.script_code.split_whitespace().next().unwrap_or("")); + let resolved_exe = crate::launch_diagnostics::resolve_exe_on_path(bare_exe); + let mut msg = format!("Experimental_CreateProcessInSandbox failed: {err:?}"); + if let Some(diag) = + diagnose_launch_failure(&resolved_exe, &request.policy.readonly_paths, None) + { + let _ = writeln!( + logger, + "Error: Launch diagnostic [{}]: {}", + diag.kind, diag.message + ); + let _ = writeln!(logger, "Error: Remediation: {}", diag.remediation); + msg.push_str(&format_diagnostic(&diag)); + } + return ScriptResponse::error(&msg); } + // Helper: resolve exe path from the command line for post-exit diagnostics. + let resolved_exe = { + let bare = + std::path::Path::new(request.script_code.split_whitespace().next().unwrap_or("")); + crate::launch_diagnostics::resolve_exe_on_path(bare) + }; + let _ = writeln!(logger, "process created (PID: {})", pi.dwProcessId); let _ = writeln!(logger, "{EMOJI_SECTION} SECTION: Wait for exit"); @@ -654,11 +675,33 @@ impl ScriptRunner for BaseContainerRunner { // Stop the builtin test proxy if it was started. self.proxy_coordinator.stop(logger); + // Post-failure diagnostics: if the child failed, check for known + // environment issues and enrich the error message. + let mut error_message = String::new(); + let mut standard_err = String::new(); + if exit_code != 0 { + if let Some(diag) = diagnose_launch_failure( + &resolved_exe, + &request.policy.readonly_paths, + Some(exit_code), + ) { + let _ = writeln!( + logger, + "Error: Launch diagnostic [{}]: {}", + diag.kind, diag.message + ); + let _ = writeln!(logger, "Error: Remediation: {}", diag.remediation); + let diagnostic_text = format_diagnostic(&diag); + error_message = diagnostic_text.trim().to_string(); + standard_err = diagnostic_text; + } + } + ScriptResponse { exit_code: exit_code as i32, standard_out: String::new(), - standard_err: String::new(), - error_message: String::new(), + standard_err, + error_message, } } } diff --git a/src/wxc_common/src/launch_diagnostics.rs b/src/wxc_common/src/launch_diagnostics.rs new file mode 100644 index 000000000..af492ded4 --- /dev/null +++ b/src/wxc_common/src/launch_diagnostics.rs @@ -0,0 +1,223 @@ +// 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_launch_failure`] to check for well-known environment +//! conditions and produce an actionable remediation message for the user. +//! +//! 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. + pub message: String, + /// Actionable remediation guidance for the user. + pub remediation: String, +} + +/// Attempts to diagnose a known failure condition after a process launch has +/// failed. Returns `None` when no recognized condition matches -- the caller +/// should fall through to its existing generic error message in that case. +/// +/// # Arguments +/// +/// * `exe_path` -- Resolved path to the executable that was launched. +/// * `readonly_paths` -- The `readonlyPaths` from the sandbox policy. +/// * `_exit_code` -- The child's exit code (if available). Reserved for future +/// heuristics that key off specific exit codes. +pub fn diagnose_launch_failure( + 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.", + exe_path.display() + ), + remediation: "Uninstall the packaged version and install an unpackaged build, \ + e.g. `winget install Microsoft.PowerShell`." + .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." + ), + remediation: format!( + "Add \"{root}\" to `readonlyPaths` in your sandbox policy, \ + or upgrade to pwsh 7.7+ which does not require root drive access." + ), + }); + } + + None +} + +/// Format a `LaunchDiagnostic` into a string suitable for appending to an +/// error message displayed to the user. +pub fn format_diagnostic(diag: &LaunchDiagnostic) -> String { + format!( + "\n\n--- Diagnostic: {} ---\n{}\n\nRemediation: {}", + diag.kind, diag.message, diag.remediation + ) +} + +/// 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 already absolute, return as-is. + if exe.is_absolute() { + return exe.to_path_buf(); + } + + // Search PATH for the executable. + 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; + } + } + } + + // Fallback: return the original (detection will be best-effort). + exe.to_path_buf() +} + +// --- Internal detection heuristics --- + +/// MSIX/packaged apps are installed under `WindowsApps`. +fn is_packaged_app(exe_path: &Path) -> bool { + let normalized = exe_path.to_string_lossy().to_lowercase(); + normalized.contains("\\windowsapps\\") +} + +/// Returns `true` when the executable is `pwsh.exe` and the sandbox policy +/// does not grant read-only access to the drive root. +/// +/// Note: This does NOT apply to `powershell.exe` (inbox Windows PowerShell 5.x) +/// and does NOT apply to `pwsh.exe` >= 7.7-preview1. +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 == "\\") +} + +/// Extract the drive root (e.g. `C:\`) from an absolute path. Falls back to +/// `C:\` when the path is relative or otherwise cannot be parsed. +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() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + #[test] + fn packaged_app_detected() { + let path = + PathBuf::from(r"C:\Program Files\WindowsApps\Microsoft.PowerShell_7.4.0\pwsh.exe"); + let diag = diagnose_launch_failure(&path, &[], None); + assert!(diag.is_some()); + let d = diag.unwrap(); + assert_eq!(d.kind, "packaged_app"); + assert!(d.message.contains("packaged")); + } + + #[test] + fn unpackaged_pwsh_without_root_readonly() { + let path = PathBuf::from(r"C:\Program Files\PowerShell\7\pwsh.exe"); + let diag = diagnose_launch_failure(&path, &[], None); + assert!(diag.is_some()); + let d = diag.unwrap(); + assert_eq!(d.kind, "missing_filesystem_access"); + assert!(d.remediation.contains("readonlyPaths")); + } + + #[test] + fn unpackaged_pwsh_with_root_readonly() { + let path = PathBuf::from(r"C:\Program Files\PowerShell\7\pwsh.exe"); + let readonly = vec!["C:\\".to_string()]; + let diag = diagnose_launch_failure(&path, &readonly, None); + assert!(diag.is_none()); + } + + #[test] + fn powershell_5_not_flagged() { + let path = PathBuf::from(r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe"); + let diag = diagnose_launch_failure(&path, &[], None); + assert!(diag.is_none()); + } + + #[test] + fn non_powershell_not_flagged() { + let path = PathBuf::from(r"C:\Windows\System32\cmd.exe"); + let diag = diagnose_launch_failure(&path, &[], None); + assert!(diag.is_none()); + } + + #[test] + fn packaged_app_takes_priority_over_missing_access() { + // A packaged pwsh.exe should report "packaged_app", not "missing_filesystem_access" + let path = + PathBuf::from(r"C:\Program Files\WindowsApps\Microsoft.PowerShell_7.4.0\pwsh.exe"); + let diag = diagnose_launch_failure(&path, &[], None); + assert!(diag.is_some()); + assert_eq!(diag.unwrap().kind, "packaged_app"); + } + + #[test] + fn case_insensitive_root_path_match() { + let path = PathBuf::from(r"C:\Program Files\PowerShell\7\pwsh.exe"); + let readonly = vec!["c:\\".to_string()]; + let diag = diagnose_launch_failure(&path, &readonly, None); + assert!(diag.is_none()); + } + + #[test] + fn backslash_only_matches_as_root() { + let path = PathBuf::from(r"C:\Program Files\PowerShell\7\pwsh.exe"); + let readonly = vec!["\\".to_string()]; + let diag = diagnose_launch_failure(&path, &readonly, None); + assert!(diag.is_none()); + } +} diff --git a/src/wxc_common/src/lib.rs b/src/wxc_common/src/lib.rs index 0c4ee0115..106b8a90e 100644 --- a/src/wxc_common/src/lib.rs +++ b/src/wxc_common/src/lib.rs @@ -55,6 +55,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 From 83308e5c7e440be34d767dbf3cd34c421f679b7f Mon Sep 17 00:00:00 2001 From: Jeff Whiteside Date: Wed, 20 May 2026 16:16:36 -0700 Subject: [PATCH 02/21] surface errors to copilot --- src/wxc_common/src/appcontainer_runner.rs | 5 +- src/wxc_common/src/base_container_runner.rs | 10 ++-- src/wxc_common/src/launch_diagnostics.rs | 57 +++++++++++++++++++++ 3 files changed, 66 insertions(+), 6 deletions(-) diff --git a/src/wxc_common/src/appcontainer_runner.rs b/src/wxc_common/src/appcontainer_runner.rs index cc12d6945..ca616a50e 100644 --- a/src/wxc_common/src/appcontainer_runner.rs +++ b/src/wxc_common/src/appcontainer_runner.rs @@ -645,8 +645,9 @@ impl ScriptRunner for AppContainerScriptRunner { // environment issues and enrich the error message with actionable // guidance. if response.exit_code != 0 { - let bare_exe = - std::path::Path::new(request.script_code.split_whitespace().next().unwrap_or("")); + let bare_exe = std::path::Path::new( + crate::launch_diagnostics::extract_exe_from_command_line(&request.script_code), + ); let resolved_exe = crate::launch_diagnostics::resolve_exe_on_path(bare_exe); if let Some(diag) = diagnose_launch_failure( &resolved_exe, diff --git a/src/wxc_common/src/base_container_runner.rs b/src/wxc_common/src/base_container_runner.rs index 74b4649ac..51e8f0532 100644 --- a/src/wxc_common/src/base_container_runner.rs +++ b/src/wxc_common/src/base_container_runner.rs @@ -599,8 +599,9 @@ impl ScriptRunner for BaseContainerRunner { Use schema version '0.4.0-alpha' to fall back to the AppContainer backend.", ); } - let bare_exe = - std::path::Path::new(request.script_code.split_whitespace().next().unwrap_or("")); + let bare_exe = std::path::Path::new( + crate::launch_diagnostics::extract_exe_from_command_line(&request.script_code), + ); let resolved_exe = crate::launch_diagnostics::resolve_exe_on_path(bare_exe); let mut msg = format!("Experimental_CreateProcessInSandbox failed: {err:?}"); if let Some(diag) = @@ -619,8 +620,9 @@ impl ScriptRunner for BaseContainerRunner { // Helper: resolve exe path from the command line for post-exit diagnostics. let resolved_exe = { - let bare = - std::path::Path::new(request.script_code.split_whitespace().next().unwrap_or("")); + let bare = std::path::Path::new( + crate::launch_diagnostics::extract_exe_from_command_line(&request.script_code), + ); crate::launch_diagnostics::resolve_exe_on_path(bare) }; diff --git a/src/wxc_common/src/launch_diagnostics.rs b/src/wxc_common/src/launch_diagnostics.rs index af492ded4..3e287cf61 100644 --- a/src/wxc_common/src/launch_diagnostics.rs +++ b/src/wxc_common/src/launch_diagnostics.rs @@ -108,6 +108,24 @@ pub fn resolve_exe_on_path(exe: &Path) -> std::path::PathBuf { 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('"') { + // Quoted: find the closing quote. + match after_quote.find('"') { + Some(end) => &after_quote[..end], + None => trimmed.split_whitespace().next().unwrap_or(""), + } + } else { + // Unquoted: take up to first whitespace. + trimmed.split_whitespace().next().unwrap_or("") + } +} + // --- Internal detection heuristics --- /// MSIX/packaged apps are installed under `WindowsApps`. @@ -220,4 +238,43 @@ mod tests { let diag = diagnose_launch_failure(&path, &readonly, None); assert!(diag.is_none()); } + + #[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() { + let cmd = r"pwsh.exe -NoProfile -NoLogo"; + let exe = extract_exe_from_command_line(cmd); + assert_eq!(exe, "pwsh.exe"); + } + + #[test] + fn extract_exe_quoted_no_args() { + let cmd = r#""C:\Program Files\PowerShell\7\pwsh.exe""#; + let exe = extract_exe_from_command_line(cmd); + assert_eq!(exe, r"C:\Program Files\PowerShell\7\pwsh.exe"); + } + + #[test] + fn extract_exe_empty() { + assert_eq!(extract_exe_from_command_line(""), ""); + } + + #[test] + fn quoted_packaged_path_triggers_diagnostic() { + let cmd = r#""C:\Program Files\WindowsApps\Microsoft.PowerShell_7.6.1.0_x64__8wekyb3d8bbwe\pwsh.exe" -NoProfile"#; + let exe = extract_exe_from_command_line(cmd); + let path = PathBuf::from(exe); + let diag = diagnose_launch_failure(&path, &[], None); + assert!(diag.is_some()); + assert_eq!(diag.unwrap().kind, "packaged_app"); + } } From b64f28c29b6e6640d86cd53fbc1cafe2665493a2 Mon Sep 17 00:00:00 2001 From: Jeff Whiteside Date: Wed, 20 May 2026 21:30:33 -0700 Subject: [PATCH 03/21] the reagent --- src/wxc/src/main.rs | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/wxc/src/main.rs b/src/wxc/src/main.rs index 911c82a56..b0031ab15 100644 --- a/src/wxc/src/main.rs +++ b/src/wxc/src/main.rs @@ -583,14 +583,19 @@ fn main() { // Emit a structured JSON error envelope on stderr for SDK consumption // when the runner produced an error message (one-shot flows only). + // Suppress when stderr is a TTY (PTY mode) -- the human-readable + // diagnostic text is already visible; raw JSON would be noise. if response.exit_code != 0 && !response.error_message.is_empty() { - if let Ok(json) = serde_json::to_string(&serde_json::json!({ - "error": { - "code": "backend_error", - "message": response.error_message, + use std::io::IsTerminal; + if !std::io::stderr().is_terminal() { + if let Ok(json) = serde_json::to_string(&serde_json::json!({ + "error": { + "code": "backend_error", + "message": response.error_message, + } + })) { + eprintln!("{json}"); } - })) { - eprintln!("{json}"); } } From e8ba1aa201cb2c240335b5d0938d7279a059cfc9 Mon Sep 17 00:00:00 2001 From: Jeff Whiteside Date: Wed, 20 May 2026 21:52:25 -0700 Subject: [PATCH 04/21] i dunno anymore --- src/lxc_common/src/lxc_runner.rs | 1 + src/wslc_common/src/wsl_container_runner.rs | 1 + src/wxc/src/main.rs | 9 +++++-- src/wxc_common/src/appcontainer_runner.rs | 15 +++++++---- src/wxc_common/src/base_container_runner.rs | 27 +++++++++++++++----- src/wxc_common/src/models.rs | 6 +++++ src/wxc_common/src/windows_sandbox_runner.rs | 1 + 7 files changed, 46 insertions(+), 14 deletions(-) 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/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 b0031ab15..eba2cc40f 100644 --- a/src/wxc/src/main.rs +++ b/src/wxc/src/main.rs @@ -588,12 +588,17 @@ fn main() { if response.exit_code != 0 && !response.error_message.is_empty() { use std::io::IsTerminal; if !std::io::stderr().is_terminal() { - if let Ok(json) = serde_json::to_string(&serde_json::json!({ + 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}"); } } diff --git a/src/wxc_common/src/appcontainer_runner.rs b/src/wxc_common/src/appcontainer_runner.rs index ca616a50e..8652e60a0 100644 --- a/src/wxc_common/src/appcontainer_runner.rs +++ b/src/wxc_common/src/appcontainer_runner.rs @@ -539,6 +539,7 @@ impl AppContainerScriptRunner { standard_out: String::new(), standard_err: String::new(), error_message: String::new(), + ..Default::default() }) } @@ -580,7 +581,7 @@ 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_launch_failure, format_diagnostic}; + use crate::launch_diagnostics::diagnose_launch_failure; use crate::network_manager::NetworkManager; // Apply experimental features when flag is set @@ -654,18 +655,22 @@ impl ScriptRunner for AppContainerScriptRunner { &request.policy.readonly_paths, Some(response.exit_code as u32), ) { - let diagnostic_text = format_diagnostic(&diag); logger.log_line(&format!( "Error: Launch diagnostic [{}]: {}", diag.kind, diag.message )); logger.log_line(&format!("Error: Remediation: {}", diag.remediation)); + let user_msg = + format!("{}\n\nRemediation: {}", diag.message, diag.remediation); if response.error_message.is_empty() { - response.error_message = diagnostic_text.trim().to_string(); + response.error_message = user_msg.clone(); } else { - response.error_message.push_str(&diagnostic_text); + // Preserve original error as extended_error, replace + // error_message with the friendly diagnostic. + response.extended_error = response.error_message.clone(); + response.error_message = user_msg.clone(); } - response.standard_err.push_str(&diagnostic_text); + response.standard_err.push_str(&user_msg); } } diff --git a/src/wxc_common/src/base_container_runner.rs b/src/wxc_common/src/base_container_runner.rs index 51e8f0532..1724740d3 100644 --- a/src/wxc_common/src/base_container_runner.rs +++ b/src/wxc_common/src/base_container_runner.rs @@ -22,7 +22,7 @@ use windows::Win32::System::Threading::{ }; use windows_core::PCWSTR; -use crate::launch_diagnostics::{diagnose_launch_failure, format_diagnostic}; +use crate::launch_diagnostics::diagnose_launch_failure; use crate::log_symbols::{ EMOJI_ALLOWED, EMOJI_BLOCKED, EMOJI_NEUTRAL, EMOJI_SECTION, EMOJI_WARNING, }; @@ -603,7 +603,8 @@ impl ScriptRunner for BaseContainerRunner { crate::launch_diagnostics::extract_exe_from_command_line(&request.script_code), ); let resolved_exe = crate::launch_diagnostics::resolve_exe_on_path(bare_exe); - let mut msg = format!("Experimental_CreateProcessInSandbox failed: {err:?}"); + let raw_error = format!("Experimental_CreateProcessInSandbox failed: {err:?}"); + let mut user_message = String::new(); if let Some(diag) = diagnose_launch_failure(&resolved_exe, &request.policy.readonly_paths, None) { @@ -613,9 +614,19 @@ impl ScriptRunner for BaseContainerRunner { diag.kind, diag.message ); let _ = writeln!(logger, "Error: Remediation: {}", diag.remediation); - msg.push_str(&format_diagnostic(&diag)); + user_message = + format!("{}\n\nRemediation: {}", diag.message, diag.remediation); } - return ScriptResponse::error(&msg); + if user_message.is_empty() { + user_message = raw_error.clone(); + } + return ScriptResponse { + exit_code: -1, + standard_err: user_message.clone(), + error_message: user_message, + extended_error: raw_error, + ..Default::default() + }; } // Helper: resolve exe path from the command line for post-exit diagnostics. @@ -693,9 +704,10 @@ impl ScriptRunner for BaseContainerRunner { diag.kind, diag.message ); let _ = writeln!(logger, "Error: Remediation: {}", diag.remediation); - let diagnostic_text = format_diagnostic(&diag); - error_message = diagnostic_text.trim().to_string(); - standard_err = diagnostic_text; + let user_msg = + format!("{}\n\nRemediation: {}", diag.message, diag.remediation); + error_message = user_msg.clone(); + standard_err = user_msg; } } @@ -704,6 +716,7 @@ impl ScriptRunner for BaseContainerRunner { standard_out: String::new(), standard_err, error_message, + ..Default::default() } } } diff --git a/src/wxc_common/src/models.rs b/src/wxc_common/src/models.rs index 34d0fdef2..f9eb3747d 100644 --- a/src/wxc_common/src/models.rs +++ b/src/wxc_common/src/models.rs @@ -552,6 +552,11 @@ 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, } impl Default for ScriptResponse { @@ -561,6 +566,7 @@ impl Default for ScriptResponse { standard_out: String::new(), standard_err: String::new(), error_message: String::new(), + extended_error: String::new(), } } } 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), From 9eaf9e13314cecf6fe2d6db9194f8b1d54c5ab2c Mon Sep 17 00:00:00 2001 From: Jeff Whiteside Date: Wed, 20 May 2026 21:56:22 -0700 Subject: [PATCH 05/21] . --- src/wxc_common/src/appcontainer_runner.rs | 3 +-- src/wxc_common/src/base_container_runner.rs | 6 ++---- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/wxc_common/src/appcontainer_runner.rs b/src/wxc_common/src/appcontainer_runner.rs index 8652e60a0..9481eac38 100644 --- a/src/wxc_common/src/appcontainer_runner.rs +++ b/src/wxc_common/src/appcontainer_runner.rs @@ -660,8 +660,7 @@ impl ScriptRunner for AppContainerScriptRunner { diag.kind, diag.message )); logger.log_line(&format!("Error: Remediation: {}", diag.remediation)); - let user_msg = - format!("{}\n\nRemediation: {}", diag.message, diag.remediation); + let user_msg = format!("{}\n\nRemediation: {}", diag.message, diag.remediation); if response.error_message.is_empty() { response.error_message = user_msg.clone(); } else { diff --git a/src/wxc_common/src/base_container_runner.rs b/src/wxc_common/src/base_container_runner.rs index 1724740d3..c6f6fc8e0 100644 --- a/src/wxc_common/src/base_container_runner.rs +++ b/src/wxc_common/src/base_container_runner.rs @@ -614,8 +614,7 @@ impl ScriptRunner for BaseContainerRunner { diag.kind, diag.message ); let _ = writeln!(logger, "Error: Remediation: {}", diag.remediation); - user_message = - format!("{}\n\nRemediation: {}", diag.message, diag.remediation); + user_message = format!("{}\n\nRemediation: {}", diag.message, diag.remediation); } if user_message.is_empty() { user_message = raw_error.clone(); @@ -704,8 +703,7 @@ impl ScriptRunner for BaseContainerRunner { diag.kind, diag.message ); let _ = writeln!(logger, "Error: Remediation: {}", diag.remediation); - let user_msg = - format!("{}\n\nRemediation: {}", diag.message, diag.remediation); + let user_msg = format!("{}\n\nRemediation: {}", diag.message, diag.remediation); error_message = user_msg.clone(); standard_err = user_msg; } From aad99d4b24c0c5069b06345bfe8579396a025c29 Mon Sep 17 00:00:00 2001 From: Jeff Whiteside Date: Wed, 20 May 2026 22:01:07 -0700 Subject: [PATCH 06/21] . --- docs/launch-diagnostics.md | 2 +- src/wxc_common/src/launch_diagnostics.rs | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/launch-diagnostics.md b/docs/launch-diagnostics.md index f4bc6fee1..3f4472e2a 100644 --- a/docs/launch-diagnostics.md +++ b/docs/launch-diagnostics.md @@ -46,7 +46,7 @@ pub fn diagnose_launch_failure( 1. **`packaged_app`** -- If the resolved exe path is under `C:\Program Files\WindowsApps\` (MSIX install location), return: - message: "The target executable is a packaged (MSIX) app which cannot run inside a sandboxed container." - - remediation: "Uninstall the packaged version and install via `winget install Microsoft.PowerShell`." + - remediation: "Uninstall the packaged version and install an unpackaged build." 2. **`missing_filesystem_access`** -- If the exe is `pwsh.exe` (not `powershell.exe`) and the policy does NOT grant `readonlyPaths` including the drive root (`C:\`), return: - message: "pwsh.exe versions before 7.7 require read-only access to the root drive to start." diff --git a/src/wxc_common/src/launch_diagnostics.rs b/src/wxc_common/src/launch_diagnostics.rs index 3e287cf61..949f44be7 100644 --- a/src/wxc_common/src/launch_diagnostics.rs +++ b/src/wxc_common/src/launch_diagnostics.rs @@ -51,8 +51,7 @@ pub fn diagnose_launch_failure( Packaged apps cannot be launched inside a sandboxed container.", exe_path.display() ), - remediation: "Uninstall the packaged version and install an unpackaged build, \ - e.g. `winget install Microsoft.PowerShell`." + remediation: "Uninstall the packaged version and install an unpackaged build." .to_string(), }); } From aeef5143e31efd873746bcb0aca7f33e295de602 Mon Sep 17 00:00:00 2001 From: Jeff Whiteside Date: Wed, 20 May 2026 22:17:55 -0700 Subject: [PATCH 07/21] STATUS_DLL_INIT_FAILED --- src/wxc_common/src/launch_diagnostics.rs | 64 +++++++++++++++++++++++- 1 file changed, 63 insertions(+), 1 deletion(-) diff --git a/src/wxc_common/src/launch_diagnostics.rs b/src/wxc_common/src/launch_diagnostics.rs index 949f44be7..ae3dbb821 100644 --- a/src/wxc_common/src/launch_diagnostics.rs +++ b/src/wxc_common/src/launch_diagnostics.rs @@ -41,7 +41,7 @@ pub struct LaunchDiagnostic { pub fn diagnose_launch_failure( exe_path: &Path, readonly_paths: &[String], - _exit_code: Option, + exit_code: Option, ) -> Option { if is_packaged_app(exe_path) { return Some(LaunchDiagnostic { @@ -56,6 +56,23 @@ pub fn diagnose_launch_failure( }); } + // STATUS_DLL_INIT_FAILED (0xC0000142) from pwsh/powershell typically means + // a DLL (e.g. user32.dll) failed to initialize because the sandbox blocked + // UI subsystem access (Win32k system calls). + const STATUS_DLL_INIT_FAILED: u32 = 0xC000_0142; + 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." + .to_string(), + remediation: "Enable UI access in your sandbox policy \ + (set `ui.allowWindows: true` or equivalent)." + .to_string(), + }); + } + if missing_root_readonly(exe_path, readonly_paths) { let root = drive_root(exe_path); return Some(LaunchDiagnostic { @@ -133,6 +150,17 @@ fn is_packaged_app(exe_path: &Path) -> bool { normalized.contains("\\windowsapps\\") } +/// Returns `true` when the executable filename is a PowerShell variant +/// (pwsh.exe or powershell.exe). +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" +} + /// Returns `true` when the executable is `pwsh.exe` and the sandbox policy /// does not grant read-only access to the drive root. /// @@ -276,4 +304,38 @@ mod tests { assert!(diag.is_some()); assert_eq!(diag.unwrap().kind, "packaged_app"); } + + #[test] + fn dll_init_failed_pwsh_triggers_ui_diagnostic() { + let path = PathBuf::from(r"C:\Program Files\PowerShell\7\pwsh.exe"); + let diag = diagnose_launch_failure(&path, &["C:\\".to_string()], Some(0xC000_0142)); + 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.remediation.contains("UI access")); + } + + #[test] + fn dll_init_failed_powershell_exe_triggers_ui_diagnostic() { + let path = PathBuf::from(r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe"); + let diag = diagnose_launch_failure(&path, &["C:\\".to_string()], Some(0xC000_0142)); + 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 path = PathBuf::from(r"C:\tools\myapp.exe"); + let diag = diagnose_launch_failure(&path, &["C:\\".to_string()], Some(0xC000_0142)); + assert!(diag.is_none()); + } + + #[test] + fn different_exit_code_pwsh_does_not_trigger_ui_diagnostic() { + let path = PathBuf::from(r"C:\Program Files\PowerShell\7\pwsh.exe"); + // Exit code 1 should not trigger the UI diagnostic + let diag = diagnose_launch_failure(&path, &["C:\\".to_string()], Some(1)); + assert!(diag.is_none()); + } } From ecd5acb81ba835b951c44c11e8889ac50b2fe3e9 Mon Sep 17 00:00:00 2001 From: Jeff Whiteside Date: Wed, 20 May 2026 23:15:29 -0700 Subject: [PATCH 08/21] STATUS_DLL_INIT_FAILED --- src/wxc_common/src/base_container_runner.rs | 26 ++++-- src/wxc_common/src/launch_diagnostics.rs | 96 +++++++++++++++++++++ 2 files changed, 116 insertions(+), 6 deletions(-) diff --git a/src/wxc_common/src/base_container_runner.rs b/src/wxc_common/src/base_container_runner.rs index c6f6fc8e0..71159c42b 100644 --- a/src/wxc_common/src/base_container_runner.rs +++ b/src/wxc_common/src/base_container_runner.rs @@ -67,6 +67,10 @@ pub struct BaseContainerRunner { /// (e.g., disabled via feature-enablement mechanisms). 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; + /// SandboxSpec FlatBuffer schema version embedded in every spec payload. const SANDBOX_SPEC_VERSION: &str = "0.1.0"; @@ -591,13 +595,23 @@ impl ScriptRunner for BaseContainerRunner { ); } 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.", + if err.0 == ERROR_CALL_NOT_IMPLEMENTED || err.0 == E_NOTIMPL { + let diag = crate::launch_diagnostics::diagnose_api_not_implemented(); + let _ = writeln!( + logger, + "Error: Launch diagnostic [{}]: {}", + diag.kind, diag.message ); + let _ = writeln!(logger, "Error: Remediation: {}", diag.remediation); + let user_message = format!("{}\n\nRemediation: {}", diag.message, diag.remediation); + let raw_error = format!("Experimental_CreateProcessInSandbox failed: {err:?}"); + return ScriptResponse { + exit_code: -1, + standard_err: user_message.clone(), + error_message: user_message, + extended_error: raw_error, + ..Default::default() + }; } let bare_exe = std::path::Path::new( crate::launch_diagnostics::extract_exe_from_command_line(&request.script_code), diff --git a/src/wxc_common/src/launch_diagnostics.rs b/src/wxc_common/src/launch_diagnostics.rs index ae3dbb821..31d95eca6 100644 --- a/src/wxc_common/src/launch_diagnostics.rs +++ b/src/wxc_common/src/launch_diagnostics.rs @@ -92,6 +92,102 @@ pub fn diagnose_launch_failure( None } +/// Velocity key IDs required by the BaseContainer feature. +const REQUIRED_VELOCITY_KEYS: &[(u32, &str)] = &[ + (61389575, "BaseContainer core"), + (61155944, "BaseContainer sandbox spec"), +]; + +/// Produces a diagnostic when `Experimental_CreateProcessInSandbox` returns +/// `E_NOTIMPL` or `ERROR_CALL_NOT_IMPLEMENTED`, indicating the feature is +/// gated behind velocity keys that are not enabled. +pub fn diagnose_api_not_implemented() -> LaunchDiagnostic { + let key_status = check_velocity_keys(); + let (message, remediation) = if key_status.is_empty() { + // Could not determine key status (no registry access or different gating) + ( + "Experimental_CreateProcessInSandbox returned E_NOTIMPL. \ + The BaseContainer feature is not enabled on this OS build." + .to_string(), + "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() { + // All keys are enabled but we still got E_NOTIMPL - unexpected + ( + "Experimental_CreateProcessInSandbox returned E_NOTIMPL. \ + All known velocity keys appear enabled, but the feature is still gated." + .to_string(), + "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: {}.", + disabled_list.join(", ") + ), + format!( + "Enable velocity keys {} 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, + remediation, + } +} + +/// 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 { + // Velocity key overrides live under FeatureManagement\Overrides\\. + // Priority 4 = user/test override, 8 = flighting. + // The key has a DWORD "EnabledState" (1 = disabled, 2 = enabled). + 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; // 2 = enabled + } + } + false + }); + results.push((key_id, enabled)); + } + results + } + #[cfg(not(target_os = "windows"))] + { + Vec::new() + } +} + /// Format a `LaunchDiagnostic` into a string suitable for appending to an /// error message displayed to the user. pub fn format_diagnostic(diag: &LaunchDiagnostic) -> String { From ff774ea1b35aa4c8eb6d9cf50562c676ef08ff4f Mon Sep 17 00:00:00 2001 From: Jeff Whiteside Date: Wed, 20 May 2026 23:42:49 -0700 Subject: [PATCH 09/21] . --- docs/launch-diagnostics.md | 120 ------------------------------------- 1 file changed, 120 deletions(-) delete mode 100644 docs/launch-diagnostics.md diff --git a/docs/launch-diagnostics.md b/docs/launch-diagnostics.md deleted file mode 100644 index 3f4472e2a..000000000 --- a/docs/launch-diagnostics.md +++ /dev/null @@ -1,120 +0,0 @@ -# Launch Diagnostics: Surfacing Errors to Callers (#348) - -## Problem - -Two known errors are silently swallowed today: - -1. **Packaged pwsh.exe** -- AppContainer/BaseContainer cannot launch packaged (MSIX) apps; the process just fails with a generic error. -2. **pwsh < 7.7-preview1 needs r/o access to `C:\`** -- unless the sandbox policy grants it, pwsh crashes on startup. - -Neither is detected or reported as an actionable message to the user (copilot.exe). - -## Design Goals - -- Surface errors as **structured, actionable messages** with remediation guidance. -- Work for **both** one-shot (`spawnSandbox`) and state-aware flows. -- Allow the SDK caller (copilot.exe) to programmatically distinguish these errors from generic failures. -- **Zero overhead on the happy path** -- diagnostics run only after a launch failure. - ---- - -## Implementation - -### Phase 1: Rust -- Post-failure diagnostics (shared module) - -**New file:** `src/wxc_common/src/launch_diagnostics.rs` - -A shared diagnostics module that both runners call **after** a process-creation failure to enrich the error with actionable guidance: - -```rust -pub struct LaunchDiagnostic { - pub kind: &'static str, // e.g. "packaged_app", "missing_filesystem_access" - pub message: String, // human-readable explanation - pub remediation: String, // actionable fix for the user -} - -/// Attempts to diagnose *why* a process launch failed. -/// Returns `None` if no known condition is detected (the original error passes through unchanged). -pub fn diagnose_launch_failure( - exe_path: &Path, - readonly_paths: &[String], - exit_code: Option, -) -> Option { ... } -``` - -**Checks (in order):** - -1. **`packaged_app`** -- If the resolved exe path is under `C:\Program Files\WindowsApps\` (MSIX install location), return: - - message: "The target executable is a packaged (MSIX) app which cannot run inside a sandboxed container." - - remediation: "Uninstall the packaged version and install an unpackaged build." - -2. **`missing_filesystem_access`** -- If the exe is `pwsh.exe` (not `powershell.exe`) and the policy does NOT grant `readonlyPaths` including the drive root (`C:\`), return: - - message: "pwsh.exe versions before 7.7 require read-only access to the root drive to start." - - remediation: "Add `C:\\` to `readonlyPaths` in your sandbox policy, or upgrade to pwsh 7.7+." - -**Callers:** - -- `appcontainer_runner.rs` -- after `CreateProcessW` fails or child exits non-zero. -- `base_container_runner.rs` -- after `Experimental_CreateProcessInSandbox` returns failure or child exits non-zero. - -Both runners already return `ScriptResponse::error(...)` for failures, so this enriches existing error paths without changing control flow. - -### Phase 2: Rust -- Structured error envelope for one-shot flows - -**File:** `src/wxc/src/main.rs` - -When a `ScriptResponse` contains a diagnostic, emit a JSON error envelope on **stderr**: - -```json -{"error": {"code": "backend_error", "message": "...", "details": {"kind": "packaged_app", "remediation": "..."}}} -``` - -This reuses `MxcErrorCode::BackendError` with a `details.kind` discriminator. The existing human-readable stderr text continues to be emitted for direct CLI users. - -### Phase 3: SDK -- Parse structured errors from one-shot flows - -**File:** `sdk/src/sandbox.ts` - -- Non-PTY path: after child exits non-zero, scan stderr for a JSON `{"error": {...}}` line. If found, throw `MxcError`. -- PTY path (merged stdout/stderr): scan combined output for the error envelope. If found, throw `MxcError`. -- Fallback: existing behavior (return exit code + raw output). - -### Phase 4: Tests and documentation - -1. Unit tests in `src/wxc_common/` for the detection heuristics. -2. Test config under `test_configs/` that triggers the packaged-app detection (manual validation). -3. This document serves as the feature's design reference. - ---- - -## Detection Heuristics - -### Packaged app detection - -```rust -fn is_packaged_app(exe_path: &Path) -> bool { - let normalized = exe_path.to_string_lossy().to_lowercase(); - normalized.contains("\\windowsapps\\") -} -``` - -### Missing filesystem access (pwsh root drive) - -```rust -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 = format!("{}\\", &exe_path.to_string_lossy()[..2]); - !readonly_paths.iter().any(|p| p.eq_ignore_ascii_case(&root) || p == "\\") -} -``` - -Version detection is deferred -- the simpler heuristic is path + filename based. Note from @asklar: this does not apply to `powershell.exe` (inbox Windows PowerShell 5.x), only to `pwsh.exe` < 7.7. - ---- - -## Open Questions - -1. **New error code vs `backend_error` + details?** Adding a first-class code (e.g. `launch_diagnostic`) requires both Rust and SDK changes to the closed union. Using `backend_error` + `details.kind` is additive-only. -2. **PTY path reliability** -- In PTY mode stdout/stderr are merged. Should the envelope use a unique prefix (e.g., `MXC_ERROR:`) for reliable parsing? -3. **pwsh version detection** -- Is it worth spawning `pwsh --version` post-failure, or is path-based detection sufficient? From 01b6e13347e4ed3647746eb33d1552b6267a4f40 Mon Sep 17 00:00:00 2001 From: Jeff Whiteside Date: Fri, 22 May 2026 11:39:01 -0700 Subject: [PATCH 10/21] refact --- src/wxc_common/src/appcontainer_runner.rs | 29 +- src/wxc_common/src/base_container_runner.rs | 98 +-- src/wxc_common/src/launch_diagnostics.rs | 900 ++++++++++---------- src/wxc_common/src/models.rs | 17 + 4 files changed, 517 insertions(+), 527 deletions(-) diff --git a/src/wxc_common/src/appcontainer_runner.rs b/src/wxc_common/src/appcontainer_runner.rs index 9481eac38..b1995d251 100644 --- a/src/wxc_common/src/appcontainer_runner.rs +++ b/src/wxc_common/src/appcontainer_runner.rs @@ -581,7 +581,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_launch_failure; + use crate::launch_diagnostics::diagnose_process_exit; + use crate::models::FailurePhase; use crate::network_manager::NetworkManager; // Apply experimental features when flag is set @@ -643,33 +644,23 @@ impl ScriptRunner for AppContainerScriptRunner { }; // Post-failure diagnostics: if the child failed, check for known - // environment issues and enrich the error message with actionable - // guidance. + // environment issues and enrich the error message. if response.exit_code != 0 { - let bare_exe = std::path::Path::new( - crate::launch_diagnostics::extract_exe_from_command_line(&request.script_code), - ); - let resolved_exe = crate::launch_diagnostics::resolve_exe_on_path(bare_exe); - if let Some(diag) = diagnose_launch_failure( - &resolved_exe, + response.failure_phase = FailurePhase::ProcessExited; + if let Some(diag) = diagnose_process_exit( + &request.script_code, &request.policy.readonly_paths, - Some(response.exit_code as u32), + response.exit_code as u32, ) { logger.log_line(&format!( "Error: Launch diagnostic [{}]: {}", diag.kind, diag.message )); - logger.log_line(&format!("Error: Remediation: {}", diag.remediation)); - let user_msg = format!("{}\n\nRemediation: {}", diag.message, diag.remediation); - if response.error_message.is_empty() { - response.error_message = user_msg.clone(); - } else { - // Preserve original error as extended_error, replace - // error_message with the friendly diagnostic. + if !response.error_message.is_empty() { response.extended_error = response.error_message.clone(); - response.error_message = user_msg.clone(); } - response.standard_err.push_str(&user_msg); + response.error_message = diag.message.clone(); + response.standard_err.push_str(&diag.message); } } diff --git a/src/wxc_common/src/base_container_runner.rs b/src/wxc_common/src/base_container_runner.rs index 71159c42b..2f8f4315b 100644 --- a/src/wxc_common/src/base_container_runner.rs +++ b/src/wxc_common/src/base_container_runner.rs @@ -22,13 +22,13 @@ use windows::Win32::System::Threading::{ }; use windows_core::PCWSTR; -use crate::launch_diagnostics::diagnose_launch_failure; +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}; @@ -63,14 +63,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; - -/// HRESULT E_NOTIMPL -- the OS may set this via GetLastError when the -/// feature is gated behind velocity keys. -const E_NOTIMPL: u32 = 0x8000_4001; - /// SandboxSpec FlatBuffer schema version embedded in every spec payload. const SANDBOX_SPEC_VERSION: &str = "0.1.0"; @@ -594,62 +586,26 @@ impl ScriptRunner for BaseContainerRunner { logger, ); } + let err = unsafe { GetLastError() }; - if err.0 == ERROR_CALL_NOT_IMPLEMENTED || err.0 == E_NOTIMPL { - let diag = crate::launch_diagnostics::diagnose_api_not_implemented(); - let _ = writeln!( - logger, - "Error: Launch diagnostic [{}]: {}", - diag.kind, diag.message - ); - let _ = writeln!(logger, "Error: Remediation: {}", diag.remediation); - let user_message = format!("{}\n\nRemediation: {}", diag.message, diag.remediation); - let raw_error = format!("Experimental_CreateProcessInSandbox failed: {err:?}"); - return ScriptResponse { - exit_code: -1, - standard_err: user_message.clone(), - error_message: user_message, - extended_error: raw_error, - ..Default::default() - }; - } - let bare_exe = std::path::Path::new( - crate::launch_diagnostics::extract_exe_from_command_line(&request.script_code), + let diag = diagnose_create_process_failure( + err.0, + &request.script_code, + &request.policy.readonly_paths, ); - let resolved_exe = crate::launch_diagnostics::resolve_exe_on_path(bare_exe); - let raw_error = format!("Experimental_CreateProcessInSandbox failed: {err:?}"); - let mut user_message = String::new(); - if let Some(diag) = - diagnose_launch_failure(&resolved_exe, &request.policy.readonly_paths, None) - { - let _ = writeln!( - logger, - "Error: Launch diagnostic [{}]: {}", - diag.kind, diag.message - ); - let _ = writeln!(logger, "Error: Remediation: {}", diag.remediation); - user_message = format!("{}\n\nRemediation: {}", diag.message, diag.remediation); - } - if user_message.is_empty() { - user_message = raw_error.clone(); - } + + let _ = writeln!(logger, "Error: Launch diagnostic [{}]: {}", diag.kind, diag.message); + return ScriptResponse { exit_code: -1, - standard_err: user_message.clone(), - error_message: user_message, - extended_error: raw_error, + error_message: diag.message.clone(), + standard_err: diag.message, + extended_error: format!("Experimental_CreateProcessInSandbox failed: {err:?}"), + failure_phase: FailurePhase::LaunchFailed, ..Default::default() }; } - // Helper: resolve exe path from the command line for post-exit diagnostics. - let resolved_exe = { - let bare = std::path::Path::new( - crate::launch_diagnostics::extract_exe_from_command_line(&request.script_code), - ); - crate::launch_diagnostics::resolve_exe_on_path(bare) - }; - let _ = writeln!(logger, "process created (PID: {})", pi.dwProcessId); let _ = writeln!(logger, "{EMOJI_SECTION} SECTION: Wait for exit"); @@ -701,33 +657,33 @@ impl ScriptRunner for BaseContainerRunner { // Stop the builtin test proxy if it was started. self.proxy_coordinator.stop(logger); - // Post-failure diagnostics: if the child failed, check for known + // Post-exit diagnostics: if the child failed, check for known // environment issues and enrich the error message. - let mut error_message = String::new(); - let mut standard_err = String::new(); - if exit_code != 0 { - if let Some(diag) = diagnose_launch_failure( - &resolved_exe, + let (error_message, failure_phase) = if exit_code != 0 { + if let Some(diag) = diagnose_process_exit( + &request.script_code, &request.policy.readonly_paths, - Some(exit_code), + exit_code, ) { let _ = writeln!( logger, "Error: Launch diagnostic [{}]: {}", diag.kind, diag.message ); - let _ = writeln!(logger, "Error: Remediation: {}", diag.remediation); - let user_msg = format!("{}\n\nRemediation: {}", diag.message, diag.remediation); - error_message = user_msg.clone(); - standard_err = user_msg; + (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, + standard_err: error_message.clone(), error_message, + failure_phase, ..Default::default() } } diff --git a/src/wxc_common/src/launch_diagnostics.rs b/src/wxc_common/src/launch_diagnostics.rs index 31d95eca6..725cf470a 100644 --- a/src/wxc_common/src/launch_diagnostics.rs +++ b/src/wxc_common/src/launch_diagnostics.rs @@ -1,437 +1,463 @@ -// 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_launch_failure`] to check for well-known environment -//! conditions and produce an actionable remediation message for the user. -//! -//! 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. - pub message: String, - /// Actionable remediation guidance for the user. - pub remediation: String, -} - -/// Attempts to diagnose a known failure condition after a process launch has -/// failed. Returns `None` when no recognized condition matches -- the caller -/// should fall through to its existing generic error message in that case. -/// -/// # Arguments -/// -/// * `exe_path` -- Resolved path to the executable that was launched. -/// * `readonly_paths` -- The `readonlyPaths` from the sandbox policy. -/// * `_exit_code` -- The child's exit code (if available). Reserved for future -/// heuristics that key off specific exit codes. -pub fn diagnose_launch_failure( - 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.", - exe_path.display() - ), - remediation: "Uninstall the packaged version and install an unpackaged build." - .to_string(), - }); - } - - // STATUS_DLL_INIT_FAILED (0xC0000142) from pwsh/powershell typically means - // a DLL (e.g. user32.dll) failed to initialize because the sandbox blocked - // UI subsystem access (Win32k system calls). - const STATUS_DLL_INIT_FAILED: u32 = 0xC000_0142; - 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." - .to_string(), - remediation: "Enable UI access in your sandbox policy \ - (set `ui.allowWindows: true` or equivalent)." - .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." - ), - remediation: format!( - "Add \"{root}\" to `readonlyPaths` in your sandbox policy, \ - or upgrade to pwsh 7.7+ which does not require root drive access." - ), - }); - } - - None -} - -/// Velocity key IDs required by the BaseContainer feature. -const REQUIRED_VELOCITY_KEYS: &[(u32, &str)] = &[ - (61389575, "BaseContainer core"), - (61155944, "BaseContainer sandbox spec"), -]; - -/// Produces a diagnostic when `Experimental_CreateProcessInSandbox` returns -/// `E_NOTIMPL` or `ERROR_CALL_NOT_IMPLEMENTED`, indicating the feature is -/// gated behind velocity keys that are not enabled. -pub fn diagnose_api_not_implemented() -> LaunchDiagnostic { - let key_status = check_velocity_keys(); - let (message, remediation) = if key_status.is_empty() { - // Could not determine key status (no registry access or different gating) - ( - "Experimental_CreateProcessInSandbox returned E_NOTIMPL. \ - The BaseContainer feature is not enabled on this OS build." - .to_string(), - "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() { - // All keys are enabled but we still got E_NOTIMPL - unexpected - ( - "Experimental_CreateProcessInSandbox returned E_NOTIMPL. \ - All known velocity keys appear enabled, but the feature is still gated." - .to_string(), - "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: {}.", - disabled_list.join(", ") - ), - format!( - "Enable velocity keys {} 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, - remediation, - } -} - -/// 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 { - // Velocity key overrides live under FeatureManagement\Overrides\\. - // Priority 4 = user/test override, 8 = flighting. - // The key has a DWORD "EnabledState" (1 = disabled, 2 = enabled). - 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; // 2 = enabled - } - } - false - }); - results.push((key_id, enabled)); - } - results - } - #[cfg(not(target_os = "windows"))] - { - Vec::new() - } -} - -/// Format a `LaunchDiagnostic` into a string suitable for appending to an -/// error message displayed to the user. -pub fn format_diagnostic(diag: &LaunchDiagnostic) -> String { - format!( - "\n\n--- Diagnostic: {} ---\n{}\n\nRemediation: {}", - diag.kind, diag.message, diag.remediation - ) -} - -/// 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 already absolute, return as-is. - if exe.is_absolute() { - return exe.to_path_buf(); - } - - // Search PATH for the executable. - 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; - } - } - } - - // Fallback: return the original (detection will be best-effort). - 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('"') { - // Quoted: find the closing quote. - match after_quote.find('"') { - Some(end) => &after_quote[..end], - None => trimmed.split_whitespace().next().unwrap_or(""), - } - } else { - // Unquoted: take up to first whitespace. - trimmed.split_whitespace().next().unwrap_or("") - } -} - -// --- Internal detection heuristics --- - -/// MSIX/packaged apps are installed under `WindowsApps`. -fn is_packaged_app(exe_path: &Path) -> bool { - let normalized = exe_path.to_string_lossy().to_lowercase(); - normalized.contains("\\windowsapps\\") -} - -/// Returns `true` when the executable filename is a PowerShell variant -/// (pwsh.exe or powershell.exe). -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" -} - -/// Returns `true` when the executable is `pwsh.exe` and the sandbox policy -/// does not grant read-only access to the drive root. -/// -/// Note: This does NOT apply to `powershell.exe` (inbox Windows PowerShell 5.x) -/// and does NOT apply to `pwsh.exe` >= 7.7-preview1. -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 == "\\") -} - -/// Extract the drive root (e.g. `C:\`) from an absolute path. Falls back to -/// `C:\` when the path is relative or otherwise cannot be parsed. -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() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::path::PathBuf; - - #[test] - fn packaged_app_detected() { - let path = - PathBuf::from(r"C:\Program Files\WindowsApps\Microsoft.PowerShell_7.4.0\pwsh.exe"); - let diag = diagnose_launch_failure(&path, &[], None); - assert!(diag.is_some()); - let d = diag.unwrap(); - assert_eq!(d.kind, "packaged_app"); - assert!(d.message.contains("packaged")); - } - - #[test] - fn unpackaged_pwsh_without_root_readonly() { - let path = PathBuf::from(r"C:\Program Files\PowerShell\7\pwsh.exe"); - let diag = diagnose_launch_failure(&path, &[], None); - assert!(diag.is_some()); - let d = diag.unwrap(); - assert_eq!(d.kind, "missing_filesystem_access"); - assert!(d.remediation.contains("readonlyPaths")); - } - - #[test] - fn unpackaged_pwsh_with_root_readonly() { - let path = PathBuf::from(r"C:\Program Files\PowerShell\7\pwsh.exe"); - let readonly = vec!["C:\\".to_string()]; - let diag = diagnose_launch_failure(&path, &readonly, None); - assert!(diag.is_none()); - } - - #[test] - fn powershell_5_not_flagged() { - let path = PathBuf::from(r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe"); - let diag = diagnose_launch_failure(&path, &[], None); - assert!(diag.is_none()); - } - - #[test] - fn non_powershell_not_flagged() { - let path = PathBuf::from(r"C:\Windows\System32\cmd.exe"); - let diag = diagnose_launch_failure(&path, &[], None); - assert!(diag.is_none()); - } - - #[test] - fn packaged_app_takes_priority_over_missing_access() { - // A packaged pwsh.exe should report "packaged_app", not "missing_filesystem_access" - let path = - PathBuf::from(r"C:\Program Files\WindowsApps\Microsoft.PowerShell_7.4.0\pwsh.exe"); - let diag = diagnose_launch_failure(&path, &[], None); - assert!(diag.is_some()); - assert_eq!(diag.unwrap().kind, "packaged_app"); - } - - #[test] - fn case_insensitive_root_path_match() { - let path = PathBuf::from(r"C:\Program Files\PowerShell\7\pwsh.exe"); - let readonly = vec!["c:\\".to_string()]; - let diag = diagnose_launch_failure(&path, &readonly, None); - assert!(diag.is_none()); - } - - #[test] - fn backslash_only_matches_as_root() { - let path = PathBuf::from(r"C:\Program Files\PowerShell\7\pwsh.exe"); - let readonly = vec!["\\".to_string()]; - let diag = diagnose_launch_failure(&path, &readonly, None); - assert!(diag.is_none()); - } - - #[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() { - let cmd = r"pwsh.exe -NoProfile -NoLogo"; - let exe = extract_exe_from_command_line(cmd); - assert_eq!(exe, "pwsh.exe"); - } - - #[test] - fn extract_exe_quoted_no_args() { - let cmd = r#""C:\Program Files\PowerShell\7\pwsh.exe""#; - let exe = extract_exe_from_command_line(cmd); - assert_eq!(exe, r"C:\Program Files\PowerShell\7\pwsh.exe"); - } - - #[test] - fn extract_exe_empty() { - assert_eq!(extract_exe_from_command_line(""), ""); - } - - #[test] - fn quoted_packaged_path_triggers_diagnostic() { - let cmd = r#""C:\Program Files\WindowsApps\Microsoft.PowerShell_7.6.1.0_x64__8wekyb3d8bbwe\pwsh.exe" -NoProfile"#; - let exe = extract_exe_from_command_line(cmd); - let path = PathBuf::from(exe); - let diag = diagnose_launch_failure(&path, &[], None); - assert!(diag.is_some()); - assert_eq!(diag.unwrap().kind, "packaged_app"); - } - - #[test] - fn dll_init_failed_pwsh_triggers_ui_diagnostic() { - let path = PathBuf::from(r"C:\Program Files\PowerShell\7\pwsh.exe"); - let diag = diagnose_launch_failure(&path, &["C:\\".to_string()], Some(0xC000_0142)); - 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.remediation.contains("UI access")); - } - - #[test] - fn dll_init_failed_powershell_exe_triggers_ui_diagnostic() { - let path = PathBuf::from(r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe"); - let diag = diagnose_launch_failure(&path, &["C:\\".to_string()], Some(0xC000_0142)); - 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 path = PathBuf::from(r"C:\tools\myapp.exe"); - let diag = diagnose_launch_failure(&path, &["C:\\".to_string()], Some(0xC000_0142)); - assert!(diag.is_none()); - } - - #[test] - fn different_exit_code_pwsh_does_not_trigger_ui_diagnostic() { - let path = PathBuf::from(r"C:\Program Files\PowerShell\7\pwsh.exe"); - // Exit code 1 should not trigger the UI diagnostic - let diag = diagnose_launch_failure(&path, &["C:\\".to_string()], Some(1)); - assert!(diag.is_none()); - } -} +// 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], + 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); + check_exe_heuristics(&resolved_exe, readonly_paths, Some(exit_code)) +} + +// -- 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() + } +} + +// -- Utilities (pub for use by runners) -------------------------------------- + +/// 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\\") +} + +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()); + } +} diff --git a/src/wxc_common/src/models.rs b/src/wxc_common/src/models.rs index f9eb3747d..8d2887f63 100644 --- a/src/wxc_common/src/models.rs +++ b/src/wxc_common/src/models.rs @@ -545,6 +545,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 { @@ -557,6 +570,9 @@ pub struct ScriptResponse { /// 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 +583,7 @@ impl Default for ScriptResponse { standard_err: String::new(), error_message: String::new(), extended_error: String::new(), + failure_phase: FailurePhase::None, } } } From 7922f6fb0b98d92a0cefb1c21a09ceb4e28a26df Mon Sep 17 00:00:00 2001 From: Jeff Whiteside Date: Fri, 22 May 2026 11:41:13 -0700 Subject: [PATCH 11/21] refact\ --- src/wxc_common/src/base_container_runner.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/wxc_common/src/base_container_runner.rs b/src/wxc_common/src/base_container_runner.rs index 2f8f4315b..b2e55b532 100644 --- a/src/wxc_common/src/base_container_runner.rs +++ b/src/wxc_common/src/base_container_runner.rs @@ -587,6 +587,9 @@ impl ScriptRunner for BaseContainerRunner { ); } + // + // Diagnose the launch failure (FailurePhase::LaunchFailed). + // let err = unsafe { GetLastError() }; let diag = diagnose_create_process_failure( err.0, @@ -657,8 +660,9 @@ impl ScriptRunner for BaseContainerRunner { // Stop the builtin test proxy if it was started. self.proxy_coordinator.stop(logger); - // Post-exit diagnostics: if the child failed, check for known - // environment issues and enrich the error message. + // + // 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, From 4f1ec1014789cac1f63e41e68a0c0a8dec0235d8 Mon Sep 17 00:00:00 2001 From: Jeff Whiteside Date: Fri, 22 May 2026 11:43:42 -0700 Subject: [PATCH 12/21] fmt --- src/wxc_common/src/base_container_runner.rs | 6 +- src/wxc_common/src/launch_diagnostics.rs | 916 ++++++++++---------- 2 files changed, 458 insertions(+), 464 deletions(-) diff --git a/src/wxc_common/src/base_container_runner.rs b/src/wxc_common/src/base_container_runner.rs index b2e55b532..e2768fdd9 100644 --- a/src/wxc_common/src/base_container_runner.rs +++ b/src/wxc_common/src/base_container_runner.rs @@ -597,7 +597,11 @@ impl ScriptRunner for BaseContainerRunner { &request.policy.readonly_paths, ); - let _ = writeln!(logger, "Error: Launch diagnostic [{}]: {}", diag.kind, diag.message); + let _ = writeln!( + logger, + "Error: Launch diagnostic [{}]: {}", + diag.kind, diag.message + ); return ScriptResponse { exit_code: -1, diff --git a/src/wxc_common/src/launch_diagnostics.rs b/src/wxc_common/src/launch_diagnostics.rs index 725cf470a..e716218bc 100644 --- a/src/wxc_common/src/launch_diagnostics.rs +++ b/src/wxc_common/src/launch_diagnostics.rs @@ -1,463 +1,453 @@ -// 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], - 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); - check_exe_heuristics(&resolved_exe, readonly_paths, Some(exit_code)) -} - -// -- 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() - } -} - -// -- Utilities (pub for use by runners) -------------------------------------- - -/// 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\\") -} - -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()); - } -} +// 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], + 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); + check_exe_heuristics(&resolved_exe, readonly_paths, Some(exit_code)) +} + +// -- 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() + } +} + +// -- Utilities (pub for use by runners) -------------------------------------- + +/// 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\\") +} + +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()); + } +} From 194dd95cd345b3929a02b7860f8694d9b20f2525 Mon Sep 17 00:00:00 2001 From: Jeff Whiteside Date: Fri, 22 May 2026 11:57:19 -0700 Subject: [PATCH 13/21] . --- src/wxc_common/src/base_container_runner.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/wxc_common/src/base_container_runner.rs b/src/wxc_common/src/base_container_runner.rs index e2768fdd9..f00b1f1a3 100644 --- a/src/wxc_common/src/base_container_runner.rs +++ b/src/wxc_common/src/base_container_runner.rs @@ -603,11 +603,15 @@ impl ScriptRunner for BaseContainerRunner { diag.kind, diag.message ); + let extended_error = + format!("Experimental_CreateProcessInSandbox failed: {err:?}"); + let _ = writeln!(logger, "Error: {extended_error}"); + return ScriptResponse { exit_code: -1, error_message: diag.message.clone(), standard_err: diag.message, - extended_error: format!("Experimental_CreateProcessInSandbox failed: {err:?}"), + extended_error, failure_phase: FailurePhase::LaunchFailed, ..Default::default() }; From f64a9e49d6aceeaaae376e11c6c303782e96d378 Mon Sep 17 00:00:00 2001 From: Jeff Whiteside Date: Fri, 22 May 2026 12:00:29 -0700 Subject: [PATCH 14/21] . --- src/wxc_common/src/base_container_runner.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/wxc_common/src/base_container_runner.rs b/src/wxc_common/src/base_container_runner.rs index f00b1f1a3..657b0c421 100644 --- a/src/wxc_common/src/base_container_runner.rs +++ b/src/wxc_common/src/base_container_runner.rs @@ -597,16 +597,16 @@ impl ScriptRunner for BaseContainerRunner { &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 ); - let extended_error = - format!("Experimental_CreateProcessInSandbox failed: {err:?}"); - let _ = writeln!(logger, "Error: {extended_error}"); - return ScriptResponse { exit_code: -1, error_message: diag.message.clone(), From 1cf59e058368b82585750c9cc550e5c090fefa84 Mon Sep 17 00:00:00 2001 From: Jeff Whiteside Date: Fri, 22 May 2026 12:46:59 -0700 Subject: [PATCH 15/21] .sdfdsf --- src/wxc/src/main.rs | 31 ++++++++++----------- src/wxc_common/src/base_container_runner.rs | 3 +- 2 files changed, 15 insertions(+), 19 deletions(-) diff --git a/src/wxc/src/main.rs b/src/wxc/src/main.rs index eba2cc40f..23ab14409 100644 --- a/src/wxc/src/main.rs +++ b/src/wxc/src/main.rs @@ -581,26 +581,23 @@ fn main() { eprint!("{}", response.standard_err); } - // Emit a structured JSON error envelope on stderr for SDK consumption + // Emit a structured JSON error envelope on stderr for SDK/caller consumption // when the runner produced an error message (one-shot flows only). - // Suppress when stderr is a TTY (PTY mode) -- the human-readable - // diagnostic text is already visible; raw JSON would be noise. + // 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() { - use std::io::IsTerminal; - if !std::io::stderr().is_terminal() { - 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}"); + 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}"); } } diff --git a/src/wxc_common/src/base_container_runner.rs b/src/wxc_common/src/base_container_runner.rs index 657b0c421..06f76b4be 100644 --- a/src/wxc_common/src/base_container_runner.rs +++ b/src/wxc_common/src/base_container_runner.rs @@ -597,8 +597,7 @@ impl ScriptRunner for BaseContainerRunner { &request.policy.readonly_paths, ); - let extended_error = - format!("Experimental_CreateProcessInSandbox failed: {err:?}"); + let extended_error = format!("Experimental_CreateProcessInSandbox failed: {err:?}"); let _ = writeln!(logger, "Error: {extended_error}"); let _ = writeln!( From 1e618a755819b6b62a316e5cda47e112ac9a751e Mon Sep 17 00:00:00 2001 From: Jeff Whiteside Date: Fri, 22 May 2026 13:01:46 -0700 Subject: [PATCH 16/21] .sdfdsf --- src/wxc_common/src/launch_diagnostics.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wxc_common/src/launch_diagnostics.rs b/src/wxc_common/src/launch_diagnostics.rs index e716218bc..332fc0836 100644 --- a/src/wxc_common/src/launch_diagnostics.rs +++ b/src/wxc_common/src/launch_diagnostics.rs @@ -250,7 +250,7 @@ pub fn extract_exe_from_command_line(command_line: &str) -> &str { fn is_packaged_app(exe_path: &Path) -> bool { let normalized = exe_path.to_string_lossy().to_lowercase(); - normalized.contains("\\windowsapps\\") + normalized.contains("\\windowsapps\\") || normalized.contains("/windowsapps/") } fn is_powershell(exe_path: &Path) -> bool { From 4b93b637e022054d78eaf07aaf4b399aa839e178 Mon Sep 17 00:00:00 2001 From: Jeff Whiteside Date: Fri, 22 May 2026 13:25:09 -0700 Subject: [PATCH 17/21] add support for refs checks --- src/wxc_common/src/appcontainer_runner.rs | 1 + src/wxc_common/src/base_container_runner.rs | 2 + src/wxc_common/src/launch_diagnostics.rs | 155 +++++++++++++++++++- 3 files changed, 150 insertions(+), 8 deletions(-) diff --git a/src/wxc_common/src/appcontainer_runner.rs b/src/wxc_common/src/appcontainer_runner.rs index b1995d251..f54a347ed 100644 --- a/src/wxc_common/src/appcontainer_runner.rs +++ b/src/wxc_common/src/appcontainer_runner.rs @@ -650,6 +650,7 @@ impl ScriptRunner for AppContainerScriptRunner { 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!( diff --git a/src/wxc_common/src/base_container_runner.rs b/src/wxc_common/src/base_container_runner.rs index 06f76b4be..7e7d89de7 100644 --- a/src/wxc_common/src/base_container_runner.rs +++ b/src/wxc_common/src/base_container_runner.rs @@ -595,6 +595,7 @@ impl ScriptRunner for BaseContainerRunner { err.0, &request.script_code, &request.policy.readonly_paths, + &request.policy.readwrite_paths, ); let extended_error = format!("Experimental_CreateProcessInSandbox failed: {err:?}"); @@ -674,6 +675,7 @@ impl ScriptRunner for BaseContainerRunner { if let Some(diag) = diagnose_process_exit( &request.script_code, &request.policy.readonly_paths, + &request.policy.readwrite_paths, exit_code, ) { let _ = writeln!( diff --git a/src/wxc_common/src/launch_diagnostics.rs b/src/wxc_common/src/launch_diagnostics.rs index 332fc0836..af48fb09f 100644 --- a/src/wxc_common/src/launch_diagnostics.rs +++ b/src/wxc_common/src/launch_diagnostics.rs @@ -38,6 +38,7 @@ pub fn diagnose_create_process_failure( win32_error: u32, command_line: &str, readonly_paths: &[String], + readwrite_paths: &[String], ) -> LaunchDiagnostic { // Check for feature-not-enabled (velocity keys). if win32_error == ERROR_CALL_NOT_IMPLEMENTED || win32_error == E_NOTIMPL { @@ -52,6 +53,10 @@ pub fn diagnose_create_process_failure( return diag; } + if let Some(diag) = check_refs_volumes(readonly_paths, readwrite_paths) { + return diag; + } + // Generic fallback. LaunchDiagnostic { kind: "create_process_failed", @@ -66,11 +71,15 @@ pub fn diagnose_create_process_failure( 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); - check_exe_heuristics(&resolved_exe, readonly_paths, Some(exit_code)) + 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 --------------------------------------------------------------- @@ -210,7 +219,110 @@ fn check_velocity_keys() -> Vec<(u32, bool)> { } } -// -- Utilities (pub for use by runners) -------------------------------------- +/// Check if any sandbox paths reference volumes that use ReFS (Dev Drive). +/// BFS (Bind Filter) does not work correctly on ReFS, so filesystem policy +/// will not be enforced. Returns a diagnostic if problematic volumes are found. +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 @@ -296,14 +408,15 @@ mod tests { #[test] fn api_not_implemented_triggers_feature_diagnostic() { - let diag = diagnose_create_process_failure(ERROR_CALL_NOT_IMPLEMENTED, "pwsh.exe", &[]); + 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", &[]); + let diag = diagnose_create_process_failure(E_NOTIMPL, "pwsh.exe", &[], &[]); assert_eq!(diag.kind, "feature_not_enabled"); } @@ -311,14 +424,14 @@ mod tests { 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, &[]); + 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()]); + let diag = diagnose_create_process_failure(5, "cmd.exe", &["C:\\".to_string()], &[]); assert_eq!(diag.kind, "create_process_failed"); assert!(diag.message.contains("5")); } @@ -330,6 +443,7 @@ mod tests { 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()); @@ -344,6 +458,7 @@ mod tests { 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()); @@ -355,6 +470,7 @@ mod tests { let diag = diagnose_process_exit( r"C:\tools\myapp.exe", &["C:\\".to_string()], + &[], STATUS_DLL_INIT_FAILED, ); assert!(diag.is_none()); @@ -365,6 +481,7 @@ mod tests { let diag = diagnose_process_exit( r#""C:\Program Files\PowerShell\7\pwsh.exe""#, &["C:\\".to_string()], + &[], 1, ); assert!(diag.is_none()); @@ -372,7 +489,8 @@ mod tests { #[test] fn missing_root_readonly_from_exit() { - let diag = diagnose_process_exit(r#""C:\Program Files\PowerShell\7\pwsh.exe""#, &[], 1); + 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"); } @@ -382,6 +500,7 @@ mod tests { let diag = diagnose_process_exit( r#""C:\Program Files\PowerShell\7\pwsh.exe""#, &["C:\\".to_string()], + &[], 1, ); assert!(diag.is_none()); @@ -390,7 +509,7 @@ mod tests { #[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); + let diag = diagnose_process_exit(cmd, &[], &[], 1); assert!(diag.is_some()); assert_eq!(diag.unwrap().kind, "packaged_app"); } @@ -436,6 +555,7 @@ mod tests { let diag = diagnose_process_exit( r#""C:\Program Files\PowerShell\7\pwsh.exe""#, &["c:\\".to_string()], + &[], 1, ); assert!(diag.is_none()); @@ -446,8 +566,27 @@ mod tests { 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); + } } From fe48bd36a6e25ec5c47880e4902cee4d06029fc6 Mon Sep 17 00:00:00 2001 From: Jeff Whiteside Date: Fri, 22 May 2026 13:55:25 -0700 Subject: [PATCH 18/21] add support for refs checks --- src/wxc_common/src/appcontainer_runner.rs | 13 +++++++++++ src/wxc_common/src/base_container_runner.rs | 17 ++++++++++++++- src/wxc_common/src/launch_diagnostics.rs | 24 ++++++++++----------- 3 files changed, 40 insertions(+), 14 deletions(-) diff --git a/src/wxc_common/src/appcontainer_runner.rs b/src/wxc_common/src/appcontainer_runner.rs index f54a347ed..a4c9f0a29 100644 --- a/src/wxc_common/src/appcontainer_runner.rs +++ b/src/wxc_common/src/appcontainer_runner.rs @@ -440,6 +440,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(), diff --git a/src/wxc_common/src/base_container_runner.rs b/src/wxc_common/src/base_container_runner.rs index 7e7d89de7..1b596aef0 100644 --- a/src/wxc_common/src/base_container_runner.rs +++ b/src/wxc_common/src/base_container_runner.rs @@ -547,6 +547,22 @@ 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( @@ -595,7 +611,6 @@ impl ScriptRunner for BaseContainerRunner { err.0, &request.script_code, &request.policy.readonly_paths, - &request.policy.readwrite_paths, ); let extended_error = format!("Experimental_CreateProcessInSandbox failed: {err:?}"); diff --git a/src/wxc_common/src/launch_diagnostics.rs b/src/wxc_common/src/launch_diagnostics.rs index af48fb09f..51b569769 100644 --- a/src/wxc_common/src/launch_diagnostics.rs +++ b/src/wxc_common/src/launch_diagnostics.rs @@ -38,7 +38,6 @@ pub fn diagnose_create_process_failure( win32_error: u32, command_line: &str, readonly_paths: &[String], - readwrite_paths: &[String], ) -> LaunchDiagnostic { // Check for feature-not-enabled (velocity keys). if win32_error == ERROR_CALL_NOT_IMPLEMENTED || win32_error == E_NOTIMPL { @@ -53,10 +52,6 @@ pub fn diagnose_create_process_failure( return diag; } - if let Some(diag) = check_refs_volumes(readonly_paths, readwrite_paths) { - return diag; - } - // Generic fallback. LaunchDiagnostic { kind: "create_process_failed", @@ -219,10 +214,13 @@ fn check_velocity_keys() -> Vec<(u32, bool)> { } } -/// Check if any sandbox paths reference volumes that use ReFS (Dev Drive). -/// BFS (Bind Filter) does not work correctly on ReFS, so filesystem policy -/// will not be enforced. Returns a diagnostic if problematic volumes are found. -fn check_refs_volumes( +/// 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 { @@ -409,14 +407,14 @@ mod tests { #[test] fn api_not_implemented_triggers_feature_diagnostic() { let diag = - diagnose_create_process_failure(ERROR_CALL_NOT_IMPLEMENTED, "pwsh.exe", &[], &[]); + 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", &[], &[]); + let diag = diagnose_create_process_failure(E_NOTIMPL, "pwsh.exe", &[]); assert_eq!(diag.kind, "feature_not_enabled"); } @@ -424,14 +422,14 @@ mod tests { 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, &[], &[]); + 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()], &[]); + let diag = diagnose_create_process_failure(5, "cmd.exe", &["C:\\".to_string()]); assert_eq!(diag.kind, "create_process_failed"); assert!(diag.message.contains("5")); } From 2aedab8b8957a5c30483f095f36492a6bb3e5eac Mon Sep 17 00:00:00 2001 From: Jeff Whiteside Date: Fri, 22 May 2026 13:58:58 -0700 Subject: [PATCH 19/21] add support for refs checks --- sdk/tests/integration/package-lock.json | 2 +- src/wxc_common/src/base_container_runner.rs | 6 +++++- src/wxc_common/src/launch_diagnostics.rs | 3 +-- 3 files changed, 7 insertions(+), 4 deletions(-) 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/wxc_common/src/base_container_runner.rs b/src/wxc_common/src/base_container_runner.rs index fb9036ab1..3ee2dbb92 100644 --- a/src/wxc_common/src/base_container_runner.rs +++ b/src/wxc_common/src/base_container_runner.rs @@ -598,7 +598,11 @@ impl ScriptRunner for BaseContainerRunner { &request.policy.readonly_paths, &request.policy.readwrite_paths, ) { - let _ = writeln!(logger, "Error: Pre-launch diagnostic [{}]: {}", diag.kind, diag.message); + let _ = writeln!( + logger, + "Error: Pre-launch diagnostic [{}]: {}", + diag.kind, diag.message + ); return ScriptResponse { exit_code: -1, error_message: diag.message.clone(), diff --git a/src/wxc_common/src/launch_diagnostics.rs b/src/wxc_common/src/launch_diagnostics.rs index 51b569769..311dcd4ed 100644 --- a/src/wxc_common/src/launch_diagnostics.rs +++ b/src/wxc_common/src/launch_diagnostics.rs @@ -406,8 +406,7 @@ mod tests { #[test] fn api_not_implemented_triggers_feature_diagnostic() { - let diag = - diagnose_create_process_failure(ERROR_CALL_NOT_IMPLEMENTED, "pwsh.exe", &[]); + let diag = diagnose_create_process_failure(ERROR_CALL_NOT_IMPLEMENTED, "pwsh.exe", &[]); assert_eq!(diag.kind, "feature_not_enabled"); assert!(diag.message.contains("velocity")); } From 11ee69b0906e100b0e7130866e63aa7d8c2277c2 Mon Sep 17 00:00:00 2001 From: Jeff Whiteside Date: Fri, 22 May 2026 14:08:08 -0700 Subject: [PATCH 20/21] fix pipe --- src/wxc_common/src/isolation_session/one_shot.rs | 1 + 1 file changed, 1 insertion(+) 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() } } } From b46faef9b5157494681638546503fb1b600854b4 Mon Sep 17 00:00:00 2001 From: Jeff Whiteside Date: Fri, 22 May 2026 14:49:21 -0700 Subject: [PATCH 21/21] fix pipe --- src/bwrap_common/src/bwrap_runner.rs | 2 ++ src/seatbelt_common/src/seatbelt_runner.rs | 11 ++++------- 2 files changed, 6 insertions(+), 7 deletions(-) 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/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() } }