From 12a30d4e6dbd7e527898142b6f4f65fd8070a3b5 Mon Sep 17 00:00:00 2001 From: chess Date: Fri, 2 Oct 2026 21:28:12 +0000 Subject: [PATCH] Add a CLI command to uninstall the legacy Windows sandbox (#50437) ## What changed Add `codex sandbox uninstall` to remove machine-wide legacy Windows sandbox accounts and network rules with administrator privileges. Run cleanup without loading configuration, preserving Codex home, sandbox directories, and filesystem permissions. Reject the command on other platforms. Keep commands after `--` forwarded to the sandbox, including `codex sandbox -- uninstall --help`. ## Testing Add parsing coverage for the uninstall subcommand and command separator. Add an integration test verifying that help and invalid arguments work with malformed configuration and leave configuration and credentials unchanged. GitOrigin-RevId: d2b02ef7bc0a05742dd5eb3c47292ad517d46664 --- codex-rs/cli/src/main.rs | 65 +++++++++++++++++-- codex-rs/cli/src/sandbox_uninstall.rs | 21 ++++++ codex-rs/cli/tests/sandbox_uninstall.rs | 39 +++++++++++ codex-rs/windows-sandbox-rs/src/lib.rs | 2 + codex-rs/windows-sandbox-rs/src/setup.rs | 2 +- .../src/uninstall_windows.rs | 26 ++++++++ 6 files changed, 148 insertions(+), 7 deletions(-) create mode 100644 codex-rs/cli/src/sandbox_uninstall.rs create mode 100644 codex-rs/cli/tests/sandbox_uninstall.rs diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index ac06a665e0..c047b20927 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -76,6 +76,7 @@ mod queue_cmd; mod remote_control_cmd; #[cfg(target_os = "windows")] mod sandbox_setup; +mod sandbox_uninstall; mod state_db_recovery; #[cfg(not(windows))] mod wsl_paths; @@ -189,7 +190,7 @@ enum Subcommand { Doctor(DoctorCommand), /// Run commands within a Codex-provided sandbox. - Sandbox(HostSandboxArgs), + Sandbox(SandboxCommand), /// Debugging tools. Debug(DebugCommand), @@ -459,6 +460,20 @@ impl clap::FromArgMatches for SessionTuiCli { } } +#[derive(Debug, Parser)] +struct SandboxCommand { + #[command(flatten)] + host: HostSandboxArgs, + + #[command(subcommand)] + subcommand: Option, +} + +#[derive(Debug, clap::Subcommand)] +enum SandboxSubcommand { + Uninstall(sandbox_uninstall::SandboxUninstallCommand), +} + #[cfg(target_os = "macos")] type HostSandboxArgs = codex_cli::SeatbeltCommand; #[cfg(target_os = "linux")] @@ -1652,7 +1667,10 @@ async fn cli_main( codex_cloud_tasks::run_main(cloud_cli, arg0_paths.codex_linux_sandbox_exe.clone()) .await?; } - Some(Subcommand::Sandbox(mut sandbox_cli)) => { + Some(Subcommand::Sandbox(SandboxCommand { + host: mut sandbox_cli, + subcommand, + })) => { let config_profile = sandbox_cli .config_profile .as_ref() @@ -1680,6 +1698,9 @@ async fn cli_main( root_remote_auth_token_env.as_deref(), "sandbox", )?; + if let Some(SandboxSubcommand::Uninstall(command)) = subcommand { + return command.run(); + } let loader_overrides = loader_overrides_for_profile(config_profile)?; #[cfg(target_os = "macos")] codex_cli::run_command_under_seatbelt( @@ -3552,6 +3573,38 @@ mod tests { ); } + #[test] + fn sandbox_uninstall_respects_command_separator() { + let cli = MultitoolCli::try_parse_from(["codex", "sandbox", "uninstall"]).expect("parse"); + assert!(matches!( + cli.subcommand, + Some(Subcommand::Sandbox(SandboxCommand { + subcommand: Some(SandboxSubcommand::Uninstall(_)), + .. + })) + )); + + for args in [ + vec!["codex", "sandbox", "--", "uninstall", "--help"], + vec![ + "codex", + "sandbox", + "--profile", + "uninstall", + "--", + "uninstall", + "--help", + ], + ] { + let cli = MultitoolCli::try_parse_from(args).expect("parse forwarded command"); + let Some(Subcommand::Sandbox(command)) = cli.subcommand else { + panic!("expected sandbox command"); + }; + assert!(command.subcommand.is_none()); + assert_eq!(command.host.command, vec!["uninstall", "--help"]); + } + } + #[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] #[test] fn sandbox_parses_permission_profile() { @@ -3565,7 +3618,7 @@ mod tests { ]) .expect("parse"); - let Some(Subcommand::Sandbox(command)) = cli.subcommand else { + let Some(Subcommand::Sandbox(SandboxCommand { host: command, .. })) = cli.subcommand else { panic!("expected sandbox command"); }; @@ -3586,7 +3639,7 @@ mod tests { ]) .expect("parse"); - let Some(Subcommand::Sandbox(command)) = cli.subcommand else { + let Some(Subcommand::Sandbox(SandboxCommand { host: command, .. })) = cli.subcommand else { panic!("expected sandbox command"); }; @@ -3609,7 +3662,7 @@ mod tests { MultitoolCli::try_parse_from(["codex", "sandbox", "-P", ":workspace", "--", "echo"]) .expect("parse"); - let Some(Subcommand::Sandbox(command)) = cli.subcommand else { + let Some(Subcommand::Sandbox(SandboxCommand { host: command, .. })) = cli.subcommand else { panic!("expected sandbox command"); }; @@ -3624,7 +3677,7 @@ mod tests { MultitoolCli::try_parse_from(["codex", "sandbox", "--profile", "work", "--", "echo"]) .expect("parse"); - let Some(Subcommand::Sandbox(command)) = cli.subcommand else { + let Some(Subcommand::Sandbox(SandboxCommand { host: command, .. })) = cli.subcommand else { panic!("expected sandbox command"); }; diff --git a/codex-rs/cli/src/sandbox_uninstall.rs b/codex-rs/cli/src/sandbox_uninstall.rs new file mode 100644 index 0000000000..49bc946f74 --- /dev/null +++ b/codex-rs/cli/src/sandbox_uninstall.rs @@ -0,0 +1,21 @@ +//! Uninstalls the legacy Windows sandbox without loading config or removing user data. + +use clap::Args; + +#[derive(Debug, Args)] +#[command( + about = "Remove the legacy Windows sandbox's machine-wide accounts and network rules", + after_help = "Run as administrator after stopping apps using the legacy sandbox and its provisioning service, if installed.\nPreserves Codex home, including sandbox directories and filesystem permissions." +)] +pub(crate) struct SandboxUninstallCommand {} + +impl SandboxUninstallCommand { + pub(crate) fn run(self) -> anyhow::Result<()> { + #[cfg(target_os = "windows")] + { + codex_windows_sandbox::clean_up_legacy_windows_sandbox() + } + #[cfg(not(target_os = "windows"))] + anyhow::bail!("`codex sandbox uninstall` is only supported on Windows") + } +} diff --git a/codex-rs/cli/tests/sandbox_uninstall.rs b/codex-rs/cli/tests/sandbox_uninstall.rs new file mode 100644 index 0000000000..4fceef875b --- /dev/null +++ b/codex-rs/cli/tests/sandbox_uninstall.rs @@ -0,0 +1,39 @@ +//! Exercises uninstall argument handling without performing machine-wide cleanup. + +use anyhow::Result; +use predicates::str::contains; +use pretty_assertions::assert_eq; +use tempfile::TempDir; + +#[test] +fn uninstall_help_and_invalid_arguments_preserve_user_data() -> Result<()> { + let home = TempDir::new()?; + // Uninstall must work independently of configuration parsing and authentication. + std::fs::write(home.path().join("config.toml"), "invalid config [")?; + std::fs::write(home.path().join("auth.json"), "preserve credentials")?; + + let mut command = assert_cmd::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?); + command + .env("CODEX_HOME", home.path()) + .args(["sandbox", "uninstall", "--help"]) + .assert() + .success(); + + let mut command = assert_cmd::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?); + command + .env("CODEX_HOME", home.path()) + .args(["sandbox", "uninstall", "unexpected"]) + .assert() + .failure() + .stderr(contains("unexpected argument")); + + assert_eq!( + std::fs::read_to_string(home.path().join("config.toml"))?, + "invalid config [" + ); + assert_eq!( + std::fs::read_to_string(home.path().join("auth.json"))?, + "preserve credentials" + ); + Ok(()) +} diff --git a/codex-rs/windows-sandbox-rs/src/lib.rs b/codex-rs/windows-sandbox-rs/src/lib.rs index 7008cd188b..8c2baf9370 100644 --- a/codex-rs/windows-sandbox-rs/src/lib.rs +++ b/codex-rs/windows-sandbox-rs/src/lib.rs @@ -483,6 +483,8 @@ pub use unified_exec::spawn_windows_sandbox_session_legacy; #[cfg(target_os = "windows")] pub use uninstall_windows::PreparedWindowsSandboxCleanup; #[cfg(target_os = "windows")] +pub use uninstall_windows::clean_up_legacy_windows_sandbox; +#[cfg(target_os = "windows")] pub use uninstall_windows::clean_up_packaged_windows_sandbox; #[cfg(target_os = "windows")] pub use uninstall_windows::prepare_packaged_windows_sandbox_cleanup; diff --git a/codex-rs/windows-sandbox-rs/src/setup.rs b/codex-rs/windows-sandbox-rs/src/setup.rs index 6380ae42ee..7c9cd6362c 100644 --- a/codex-rs/windows-sandbox-rs/src/setup.rs +++ b/codex-rs/windows-sandbox-rs/src/setup.rs @@ -504,7 +504,7 @@ impl SandboxUsersFile { } } -fn is_elevated() -> Result { +pub(crate) fn is_elevated() -> Result { unsafe { let mut administrators_group: *mut c_void = std::ptr::null_mut(); let ok = AllocateAndInitializeSid( diff --git a/codex-rs/windows-sandbox-rs/src/uninstall_windows.rs b/codex-rs/windows-sandbox-rs/src/uninstall_windows.rs index e08b62358d..b8da906749 100644 --- a/codex-rs/windows-sandbox-rs/src/uninstall_windows.rs +++ b/codex-rs/windows-sandbox-rs/src/uninstall_windows.rs @@ -5,6 +5,10 @@ use std::path::Path; use anyhow::Result; use anyhow::anyhow; +use anyhow::ensure; +use windows::Win32::System::Com::COINIT_MULTITHREADED; +use windows::Win32::System::Com::CoInitializeEx; +use windows::Win32::System::Com::CoUninitialize; use crate::setup::OFFLINE_USERNAME; use crate::setup::ONLINE_USERNAME; @@ -14,6 +18,28 @@ mod principals; mod processes; mod retained_logons; +/// Removes machine-wide legacy sandbox resources without reading or changing Codex home. +pub fn clean_up_legacy_windows_sandbox() -> Result<()> { + ensure!( + crate::setup::is_elevated()?, + "sandbox cleanup requires administrator privileges" + ); + let _setup_lock = crate::setup_mutex::acquire_sandbox_setup_lock(/*timeout_ms*/ 5_000)?; + // Retire the previous owner so future setup can use a different user or home. + crate::runtime_ownership::remove_installation()?; + unsafe { + CoInitializeEx(/*pvreserved*/ None, COINIT_MULTITHREADED).ok()? + }; + // No home or desktop callback: retain all user files, including sandbox caches and ACLs. + let result = clean_up_packaged_windows_sandbox( + /*codex_home*/ None, + |message| eprintln!("{message}"), + || Ok(()), + ); + unsafe { CoUninitialize() }; + result +} + /// Removes sandbox resources created for one authenticated packaged installation. /// Keep a supplied home and its ancestors pinned until `clean_up_desktop` starts. /// That callback removes user-owned desktop files while the sandbox accounts remain disabled.