From 4fd5745e8486d655a0c7e7d762741465b986130b Mon Sep 17 00:00:00 2001 From: iceweasel-oai Date: Mon, 28 Sep 2026 15:43:18 +0000 Subject: [PATCH] Use a compatible PowerShell fallback for the Windows MXC sandbox (#49019) ## Why Microsoft Store PowerShell cannot run under MXC. The existing fallback to an unpackaged PowerShell executable only applied to the elevated Windows sandbox. ## What changed Extend local PowerShell fallback discovery to MXC in `unified_exec`, using the executor's Windows sandbox selection. Keep discovery local and automatic `-NoProfile` insertion specific to the elevated sandbox. Skip this preparation for non-Windows target paths. ## Testing Extend command-preparation tests to cover MXC and add a Windows integration test that replaces a Store PowerShell path before launch, writes a file successfully, and verifies `windows_mxc` sandbox metadata. GitOrigin-RevId: 6c581396525a8fec7e1fb538b3e63b0de2ddb926 --- codex-rs/core/src/tools/runtimes/mod.rs | 115 +++++++++-------- .../core/src/tools/runtimes/unified_exec.rs | 13 +- codex-rs/core/tests/suite/mod.rs | 6 + .../unified_exec_mxc_powershell_tests.rs | 120 ++++++++++++++++++ codex-rs/shell-command/src/shell_detect.rs | 28 ++-- 5 files changed, 211 insertions(+), 71 deletions(-) create mode 100644 codex-rs/core/tests/suite/unified_exec_mxc_powershell_tests.rs diff --git a/codex-rs/core/src/tools/runtimes/mod.rs b/codex-rs/core/src/tools/runtimes/mod.rs index 727ff73917..7145665cc5 100644 --- a/codex-rs/core/src/tools/runtimes/mod.rs +++ b/codex-rs/core/src/tools/runtimes/mod.rs @@ -13,6 +13,7 @@ use crate::shell::Shell; use crate::shell::ShellType; use codex_apply_patch::CODEX_APPLY_PATCH_PRESERVE_LINE_ENDINGS_ENV_VAR; use codex_core_plugins::PLUGIN_METRICS_OUTPUT_ENV_VAR; +use codex_file_system::WindowsSandboxSelection; #[cfg(unix)] use codex_install_context::InstallContext; #[cfg(target_os = "macos")] @@ -25,7 +26,6 @@ use codex_network_proxy::PROXY_ENV_KEYS; use codex_network_proxy::PROXY_GIT_SSH_COMMAND_ENV_KEY; pub(crate) use codex_network_proxy::is_managed_proxy_env_var; pub(crate) use codex_network_proxy::strip_managed_proxy_env; -use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::shell_environment::is_non_inheritable_env_var; use codex_shell_command::shell_snapshot::posix_env_path_expansion_function; use codex_utils_absolute_path::AbsolutePathBuf; @@ -152,38 +152,37 @@ pub(crate) fn apply_zsh_fork_path_prepend( runtime_path_prepends.prepend(env, zsh_bin_dir); } -pub(crate) fn prepare_powershell_command_for_elevated_windows_sandbox( +pub(crate) fn prepare_powershell_command_for_windows_sandbox( command: &[String], shell_type: Option<&ShellType>, sandbox_requested: bool, - windows_sandbox_level: WindowsSandboxLevel, + windows_sandbox: WindowsSandboxSelection, environment_is_remote: bool, ) -> Vec { - prepare_powershell_command_for_elevated_windows_sandbox_with_fallback( + prepare_powershell_command_for_windows_sandbox_with_fallback( command, shell_type, sandbox_requested, - windows_sandbox_level, + windows_sandbox, environment_is_remote, - |path| { - codex_shell_command::shell_detect::fallback_powershell_shell_for_elevated_windows_sandbox( - path, - ) - }, + codex_shell_command::shell_detect::fallback_powershell_shell_for_windows_sandbox, ) } -fn prepare_powershell_command_for_elevated_windows_sandbox_with_fallback( +fn prepare_powershell_command_for_windows_sandbox_with_fallback( command: &[String], shell_type: Option<&ShellType>, sandbox_requested: bool, - windows_sandbox_level: WindowsSandboxLevel, + windows_sandbox: WindowsSandboxSelection, environment_is_remote: bool, find_fallback: impl FnOnce(&Path) -> Option, ) -> Vec { if shell_type != Some(&ShellType::PowerShell) || !sandbox_requested - || windows_sandbox_level != WindowsSandboxLevel::Elevated + || !matches!( + windows_sandbox, + WindowsSandboxSelection::Elevated | WindowsSandboxSelection::Mxc + ) || command.is_empty() { return command.to_vec(); @@ -194,9 +193,10 @@ fn prepare_powershell_command_for_elevated_windows_sandbox_with_fallback( command[0] = fallback.shell_path.to_string_lossy().to_string(); } - if command[1..] - .iter() - .any(|arg| arg.eq_ignore_ascii_case("-NoProfile")) + if windows_sandbox != WindowsSandboxSelection::Elevated + || command[1..] + .iter() + .any(|arg| arg.eq_ignore_ascii_case("-NoProfile")) { return command; } @@ -716,11 +716,11 @@ mod prepare_powershell_command_tests { "Write-Output ok".to_string(), ]; - let rewritten = prepare_powershell_command_for_elevated_windows_sandbox( + let rewritten = prepare_powershell_command_for_windows_sandbox( &command, Some(&ShellType::PowerShell), /*sandbox_requested*/ true, - WindowsSandboxLevel::Elevated, + WindowsSandboxSelection::Elevated, /*environment_is_remote*/ false, ); @@ -743,11 +743,11 @@ mod prepare_powershell_command_tests { "VwByAGkAdABlAC0ATwB1AHQAcAB1AHQAIABvAGsA".to_string(), ]; - let rewritten = prepare_powershell_command_for_elevated_windows_sandbox( + let rewritten = prepare_powershell_command_for_windows_sandbox( &command, Some(&ShellType::PowerShell), /*sandbox_requested*/ true, - WindowsSandboxLevel::Elevated, + WindowsSandboxSelection::Elevated, /*environment_is_remote*/ false, ); @@ -762,8 +762,9 @@ mod prepare_powershell_command_tests { ); } - #[test] - fn preserves_existing_no_profile() { + #[test_case::test_case(WindowsSandboxSelection::Elevated; "elevated")] + #[test_case::test_case(WindowsSandboxSelection::Mxc; "mxc")] + fn preserves_existing_no_profile(windows_sandbox: WindowsSandboxSelection) { let command = vec![ "pwsh.exe".to_string(), "-NoProfile".to_string(), @@ -771,11 +772,11 @@ mod prepare_powershell_command_tests { "Write-Output ok".to_string(), ]; - let rewritten = prepare_powershell_command_for_elevated_windows_sandbox( + let rewritten = prepare_powershell_command_for_windows_sandbox( &command, Some(&ShellType::PowerShell), /*sandbox_requested*/ true, - WindowsSandboxLevel::Elevated, + windows_sandbox, /*environment_is_remote*/ false, ); @@ -791,19 +792,20 @@ mod prepare_powershell_command_tests { "Write-Output ok".to_string(), ]; - let rewritten = prepare_powershell_command_for_elevated_windows_sandbox( + let rewritten = prepare_powershell_command_for_windows_sandbox( &command, Some(&ShellType::PowerShell), /*sandbox_requested*/ true, - WindowsSandboxLevel::RestrictedToken, + WindowsSandboxSelection::RestrictedToken, /*environment_is_remote*/ false, ); assert_eq!(rewritten, command); } - #[test] - fn leaves_unsandboxed_attempts_alone() { + #[test_case::test_case(WindowsSandboxSelection::Elevated; "elevated")] + #[test_case::test_case(WindowsSandboxSelection::Mxc; "mxc")] + fn leaves_unsandboxed_attempts_alone(windows_sandbox: WindowsSandboxSelection) { let command = vec![ r"C:\Program Files\WindowsApps\Microsoft.PowerShell_7.6.4.0_x64__8wekyb3d8bbwe\pwsh.exe" .to_string(), @@ -811,37 +813,42 @@ mod prepare_powershell_command_tests { "Write-Output ok".to_string(), ]; - let rewritten = prepare_powershell_command_for_elevated_windows_sandbox( + let rewritten = prepare_powershell_command_for_windows_sandbox( &command, Some(&ShellType::PowerShell), /*sandbox_requested*/ false, - WindowsSandboxLevel::Elevated, + windows_sandbox, /*environment_is_remote*/ false, ); assert_eq!(rewritten, command); } - #[test] - fn leaves_non_powershell_alone() { + #[test_case::test_case(WindowsSandboxSelection::Elevated; "elevated")] + #[test_case::test_case(WindowsSandboxSelection::Mxc; "mxc")] + fn leaves_non_powershell_alone(windows_sandbox: WindowsSandboxSelection) { let command = vec![ "/bin/bash".to_string(), "-lc".to_string(), "echo ok".to_string(), ]; - let rewritten = prepare_powershell_command_for_elevated_windows_sandbox( + let rewritten = prepare_powershell_command_for_windows_sandbox( &command, Some(&ShellType::Bash), /*sandbox_requested*/ true, - WindowsSandboxLevel::Elevated, + windows_sandbox, /*environment_is_remote*/ false, ); assert_eq!(rewritten, command); } - #[test] - fn local_elevated_powershell_uses_discovered_fallback() { + #[test_case::test_case(WindowsSandboxSelection::Elevated, &["-NoProfile", "-Command", "Write-Output ok"]; "elevated")] + #[test_case::test_case(WindowsSandboxSelection::Mxc, &["-Command", "Write-Output ok"]; "mxc")] + fn local_sandboxed_powershell_uses_discovered_fallback( + windows_sandbox: WindowsSandboxSelection, + expected_args: &[&str], + ) { let command = vec![ r"C:\Program Files\WindowsApps\Microsoft.PowerShell_7.6.4.0_x64__8wekyb3d8bbwe\pwsh.exe" .to_string(), @@ -850,11 +857,11 @@ mod prepare_powershell_command_tests { ]; let fallback_path = std::path::PathBuf::from(r"C:\Program Files\PowerShell\7\pwsh.exe"); - let rewritten = prepare_powershell_command_for_elevated_windows_sandbox_with_fallback( + let rewritten = prepare_powershell_command_for_windows_sandbox_with_fallback( &command, Some(&ShellType::PowerShell), /*sandbox_requested*/ true, - WindowsSandboxLevel::Elevated, + windows_sandbox, /*environment_is_remote*/ false, |_| { Some(codex_shell_command::shell_detect::DetectedShell { @@ -866,17 +873,19 @@ mod prepare_powershell_command_tests { assert_eq!( rewritten, - vec![ - fallback_path.to_string_lossy().to_string(), - "-NoProfile".to_string(), - "-Command".to_string(), - "Write-Output ok".to_string(), - ] + vec![fallback_path.to_string_lossy().to_string()] + .into_iter() + .chain(expected_args.iter().map(std::string::ToString::to_string)) + .collect::>() ); } - #[test] - fn remote_elevated_powershell_keeps_remote_store_path_and_no_profile() { + #[test_case::test_case(WindowsSandboxSelection::Elevated, &["-NoProfile", "-Command", "Write-Output ok"]; "elevated")] + #[test_case::test_case(WindowsSandboxSelection::Mxc, &["-Command", "Write-Output ok"]; "mxc")] + fn remote_sandboxed_powershell_keeps_remote_store_path( + windows_sandbox: WindowsSandboxSelection, + expected_args: &[&str], + ) { let command = vec![ r"C:\Program Files\WindowsApps\Microsoft.PowerShell_7.6.4.0_x64__8wekyb3d8bbwe\pwsh.exe" .to_string(), @@ -885,11 +894,11 @@ mod prepare_powershell_command_tests { ]; let mut discovery_called = false; - let rewritten = prepare_powershell_command_for_elevated_windows_sandbox_with_fallback( + let rewritten = prepare_powershell_command_for_windows_sandbox_with_fallback( &command, Some(&ShellType::PowerShell), /*sandbox_requested*/ true, - WindowsSandboxLevel::Elevated, + windows_sandbox, /*environment_is_remote*/ true, |_| { discovery_called = true; @@ -903,12 +912,10 @@ mod prepare_powershell_command_tests { assert!(!discovery_called); assert_eq!( rewritten, - vec![ - command[0].clone(), - "-NoProfile".to_string(), - "-Command".to_string(), - "Write-Output ok".to_string(), - ] + vec![command[0].clone()] + .into_iter() + .chain(expected_args.iter().map(std::string::ToString::to_string)) + .collect::>() ); } } diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index 722b8dffc1..8e7c256b56 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -24,7 +24,7 @@ use crate::tools::runtimes::RuntimePathPrepends; use crate::tools::runtimes::apply_zsh_fork_path_prepend; use crate::tools::runtimes::exec_env_for_sandbox_permissions; use crate::tools::runtimes::maybe_wrap_shell_lc_with_snapshot; -use crate::tools::runtimes::prepare_powershell_command_for_elevated_windows_sandbox; +use crate::tools::runtimes::prepare_powershell_command_for_windows_sandbox; use crate::tools::runtimes::zsh_fork; use crate::tools::sandboxing::Approvable; use crate::tools::sandboxing::ApprovalAction; @@ -34,6 +34,7 @@ use crate::tools::sandboxing::Sandboxable; use crate::tools::sandboxing::ToolCtx; use crate::tools::sandboxing::ToolError; use crate::tools::sandboxing::ToolRuntime; +use crate::tools::sandboxing::executor_windows_sandbox_selection; use crate::tools::sandboxing::managed_network_for_sandbox_permissions; use crate::tools::sandboxing::sandbox_permissions_preserving_denied_reads; use crate::unified_exec::NoopSpawnLifecycle; @@ -571,11 +572,17 @@ impl<'a> ToolRuntime for UnifiedExecRunt { network.restore_and_disable_brokered_credentials(&mut env, &mut command); } - let command = prepare_powershell_command_for_elevated_windows_sandbox( + // The executor selection is Disabled for non-Windows target paths, so this + // preparation is a no-op for PowerShell on other platforms. + let command = prepare_powershell_command_for_windows_sandbox( &command, Some(&req.shell_type), attempt.sandbox_requested, - attempt.windows_sandbox_level, + executor_windows_sandbox_selection( + attempt.windows_sandbox_type, + attempt.windows_sandbox_level, + attempt.sandbox_cwd, + ), environment_is_remote, ); let command = if matches!(req.shell_type, ShellType::PowerShell) { diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 54e98b75cc..ada5d56124 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -26,6 +26,9 @@ pub static CODEX_ALIASES_TEMP_DIR: Option = { if argv1 == Some(CODEX_FS_HELPER_ARG1) { return TestBinaryDispatchMode::DispatchArg0Only; } + if argv1 == Some(codex_sandboxing::CODEX_WINDOWS_MXC_ARG1) { + return TestBinaryDispatchMode::DispatchArg0Only; + } if exe_name == CODEX_LINUX_SANDBOX_ARG0 { return TestBinaryDispatchMode::DispatchArg0Only; } @@ -218,6 +221,9 @@ mod turn_state; mod unified_exec; #[path = "unified_exec_launch_failure_tests.rs"] mod unified_exec_launch_failure; +#[cfg(windows)] +#[path = "unified_exec_mxc_powershell_tests.rs"] +mod unified_exec_mxc_powershell; mod unified_exec_process_events; mod unified_exec_stdin_approval; mod unified_exec_stdin_review_size; diff --git a/codex-rs/core/tests/suite/unified_exec_mxc_powershell_tests.rs b/codex-rs/core/tests/suite/unified_exec_mxc_powershell_tests.rs new file mode 100644 index 0000000000..42939e5ff3 --- /dev/null +++ b/codex-rs/core/tests/suite/unified_exec_mxc_powershell_tests.rs @@ -0,0 +1,120 @@ +//! Exercises local PowerShell discovery before launching commands in native MXC. + +use anyhow::Context; +use anyhow::Result; +use codex_protocol::models::PermissionProfile; +use codex_shell_command::shell_detect::DetectedShell; +use codex_shell_command::shell_detect::ShellType; +use core_test_support::responses::mount_function_call_agent_response; +use core_test_support::test_codex::TestCodexHarness; +use core_test_support::test_codex::test_codex; +use pretty_assertions::assert_eq; +use serde_json::json; +use std::path::PathBuf; +use std::time::Duration; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mxc_replaces_store_powershell_before_launch() -> Result<()> { + if !codex_sandboxing::windows_mxc_available() { + eprintln!("skipping test: native MXC is unavailable on this host"); + return Ok(()); + } + + // The Store executable need not exist: the fallback must replace it before launch. + let shell = DetectedShell { + shell_type: ShellType::PowerShell, + shell_path: PathBuf::from( + r"C:\Program Files\WindowsApps\Microsoft.PowerShell_test\pwsh.exe", + ), + }; + let builder = test_codex() + .with_user_shell(shell.into()) + .with_pre_build_hook(|home| { + std::fs::write(home.join("config.toml"), "[windows]\nsandbox = \"mxc\"\n") + .expect("write MXC config"); + }) + .with_config(|config| { + config.codex_self_exe = Some(std::env::current_exe().expect("test executable")); + }); + // Discovery is intentionally host-local; remote execution must not use the host's shell. + let harness = TestCodexHarness::with_builder(builder).await?; + let mock = mount_function_call_agent_response( + harness.server(), + "mxc-powershell", + &json!({ + "cmd": "Set-Content -LiteralPath fallback.txt -Value mxc-powershell -NoNewline", + "login": false, + "yield_time_ms": 10_000, + }) + .to_string(), + "exec_command", + ) + .await; + harness + .submit_with_permission_profile( + "Write a file using PowerShell in the sandbox.", + PermissionProfile::workspace_write(), + ) + .await?; + + let request = mock.completion.single_request(); + let tool_output = request.function_call_output("mxc-powershell"); + let mut output = tool_output["output"] + .as_str() + .expect("command output") + .to_owned(); + // A slow MXC launch may outlive exec_command's yield window. + tokio::time::timeout(Duration::from_secs(120), async { + for poll in 0.. { + let Some(session_id) = output + .lines() + .find_map(|line| line.strip_prefix("Process running with session ID ")) + else { + break; + }; + let session_id = session_id.parse::()?; + let call_id = format!("mxc-powershell-poll-{poll}"); + let poll_mock = mount_function_call_agent_response( + harness.server(), + &call_id, + &json!({ + "session_id": session_id, + "chars": "", + "yield_time_ms": 1_000, + }) + .to_string(), + "write_stdin", + ) + .await; + harness + .submit_with_permission_profile( + "Wait for the PowerShell command to finish.", + PermissionProfile::workspace_write(), + ) + .await?; + let request = poll_mock.completion.single_request(); + let tool_output = request.function_call_output(&call_id); + output = tool_output["output"] + .as_str() + .expect("poll output") + .to_owned(); + } + Ok::<_, anyhow::Error>(()) + }) + .await + .context("timed out waiting for MXC PowerShell to exit")??; + assert!(output.contains("Process exited with code 0"), "{output}"); + assert_eq!( + std::fs::read_to_string(harness.path("fallback.txt"))?, + "mxc-powershell" + ); + let metadata: serde_json::Value = serde_json::from_str( + &mock + .function_call + .single_request() + .header("x-codex-turn-metadata") + .expect("turn metadata"), + )?; + assert_eq!(metadata["sandbox"], "windows_mxc"); + Ok(()) +} diff --git a/codex-rs/shell-command/src/shell_detect.rs b/codex-rs/shell-command/src/shell_detect.rs index c74532cee3..c82d2634f6 100644 --- a/codex-rs/shell-command/src/shell_detect.rs +++ b/codex-rs/shell-command/src/shell_detect.rs @@ -132,7 +132,7 @@ fn file_exists(path: &std::path::Path) -> Option { } } -// Store PowerShell can be inaccessible to the elevated sandbox account; +// Store PowerShell cannot run under MXC and can be inaccessible to the elevated sandbox account; // WindowsApps also contains valid Codex frameworks. fn is_inaccessible_windows_apps_powershell_path(path: &std::path::Path) -> bool { path.as_os_str() @@ -156,7 +156,7 @@ fn targets_inaccessible_windows_apps_powershell(path: &std::path::Path) -> bool .is_some_and(|resolved| is_inaccessible_windows_apps_powershell_path(&resolved)) } -fn is_elevated_sandbox_compatible_powershell_path(path: &std::path::Path) -> bool { +fn is_windows_sandbox_compatible_powershell_path(path: &std::path::Path) -> bool { if !path .extension() .is_some_and(|extension| extension.eq_ignore_ascii_case("exe")) @@ -167,19 +167,19 @@ fn is_elevated_sandbox_compatible_powershell_path(path: &std::path::Path) -> boo !targets_inaccessible_windows_apps_powershell(path) } -fn get_elevated_sandbox_compatible_powershell_path( +fn get_windows_sandbox_compatible_powershell_path( binary_name: &str, fallback_paths: &[&str], ) -> Option { if let Ok(mut paths) = which::which_all(binary_name) - && let Some(path) = paths.find(|path| is_elevated_sandbox_compatible_powershell_path(path)) + && let Some(path) = paths.find(|path| is_windows_sandbox_compatible_powershell_path(path)) { return Some(path); } for path in fallback_paths { let path = std::path::Path::new(path); - if is_elevated_sandbox_compatible_powershell_path(path) + if is_windows_sandbox_compatible_powershell_path(path) && let Some(path) = file_exists(path) { return Some(path); @@ -281,21 +281,21 @@ fn get_powershell_shell() -> Option { } /// Returns a replacement only when shell_path targets Store PowerShell and -/// an elevated sandbox-compatible PowerShell executable can be discovered. +/// an unpackaged PowerShell executable can be discovered. /// -/// The caller owns the elevated-sandbox policy decision. Normal shell discovery +/// The caller decides whether the sandbox backend requires a fallback. Normal shell discovery /// intentionally keeps the user's ordered PATH selection unchanged. -pub fn fallback_powershell_shell_for_elevated_windows_sandbox( +pub fn fallback_powershell_shell_for_windows_sandbox( shell_path: &std::path::Path, ) -> Option { if !cfg!(windows) || !targets_inaccessible_windows_apps_powershell(shell_path) { return None; } - let shell_path = get_elevated_sandbox_compatible_powershell_path("pwsh", PWSH_FALLBACK_PATHS) + let shell_path = get_windows_sandbox_compatible_powershell_path("pwsh", PWSH_FALLBACK_PATHS) .or_else(|| { - get_elevated_sandbox_compatible_powershell_path("powershell", POWERSHELL_FALLBACK_PATHS) - })?; + get_windows_sandbox_compatible_powershell_path("powershell", POWERSHELL_FALLBACK_PATHS) + })?; Some(DetectedShell { shell_type: ShellType::PowerShell, @@ -385,7 +385,7 @@ mod tests { r"C:\PROGRAM FILES\WINDOWSAPPS\MICROSOFT.POWERSHELL\PWSH.EXE", r"C:\portable\pwsh.cmd", ] { - assert!(!is_elevated_sandbox_compatible_powershell_path( + assert!(!is_windows_sandbox_compatible_powershell_path( std::path::Path::new(path) )); } @@ -400,7 +400,7 @@ mod tests { r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe", r"C:\portable\NotWindowsApps\pwsh.EXE", ] { - assert!(is_elevated_sandbox_compatible_powershell_path( + assert!(is_windows_sandbox_compatible_powershell_path( std::path::Path::new(path) )); } @@ -421,7 +421,7 @@ mod tests { PathBuf::from(r"C:\Program Files\PowerShell\7\pwsh.exe"), ] .into_iter() - .find(|path| is_elevated_sandbox_compatible_powershell_path(path)); + .find(|path| is_windows_sandbox_compatible_powershell_path(path)); assert_eq!(found, Some(portable)); }