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.