diff --git a/codex-rs/app-server/src/request_processors/command_exec_processor.rs b/codex-rs/app-server/src/request_processors/command_exec_processor.rs index c82f9a48e2..f83f45d269 100644 --- a/codex-rs/app-server/src/request_processors/command_exec_processor.rs +++ b/codex-rs/app-server/src/request_processors/command_exec_processor.rs @@ -265,10 +265,10 @@ impl CommandExecRequestProcessor { .start_proxy( &network_proxy_permission_profile, managed_proxy_routing_for_windows_sandbox( - self.config.permissions.windows_sandbox_type, + self.config.effective_local_windows_sandbox_type(), ), local_binding_policy_for_sandbox( - self.config.permissions.windows_sandbox_type, + self.config.effective_local_windows_sandbox_type(), Some(std::env::consts::OS), ), /*policy_decider*/ None, @@ -321,7 +321,7 @@ impl CommandExecRequestProcessor { windows_sandbox_workspace_roots.as_slice(), &codex_linux_sandbox_exe, &self.arg0_paths.codex_self_exe, - self.config.permissions.windows_sandbox_type, + self.config.effective_local_windows_sandbox_type(), use_legacy_landlock, ) .map_err(|err| internal_error(format!("exec failed: {err}")))?; diff --git a/codex-rs/app-server/src/request_processors/windows_sandbox_processor.rs b/codex-rs/app-server/src/request_processors/windows_sandbox_processor.rs index d8a09d14e0..3976a21a36 100644 --- a/codex-rs/app-server/src/request_processors/windows_sandbox_processor.rs +++ b/codex-rs/app-server/src/request_processors/windows_sandbox_processor.rs @@ -34,6 +34,7 @@ impl WindowsSandboxRequestProcessor { ) -> Result, JSONRPCErrorError> { #[cfg(target_os = "windows")] if codex_windows_sandbox::registered_core_requested() + && self.config.effective_local_windows_sandbox_type() != SandboxType::WindowsMxc && matches!( WindowsSandboxLevel::from_config(&self.config), WindowsSandboxLevel::Elevated @@ -306,7 +307,7 @@ fn determine_windows_sandbox_readiness(config: &Config) -> WindowsSandboxReadine }; } - if config.permissions.windows_sandbox_type == SandboxType::WindowsMxc { + if config.effective_local_windows_sandbox_type() == SandboxType::WindowsMxc { return WindowsSandboxReadinessResponse { status: WindowsSandboxReadiness::Ready, }; diff --git a/codex-rs/app-server/tests/suite/v2/windows_sandbox_setup.rs b/codex-rs/app-server/tests/suite/v2/windows_sandbox_setup.rs index e562c6b8e8..53200389ea 100644 --- a/codex-rs/app-server/tests/suite/v2/windows_sandbox_setup.rs +++ b/codex-rs/app-server/tests/suite/v2/windows_sandbox_setup.rs @@ -6,6 +6,8 @@ use app_test_support::to_response; use app_test_support::write_mock_responses_config_toml; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::WindowsSandboxReadiness; +use codex_app_server_protocol::WindowsSandboxReadinessResponse; use codex_app_server_protocol::WindowsSandboxSetupCompletedNotification; use codex_app_server_protocol::WindowsSandboxSetupMode; use codex_app_server_protocol::WindowsSandboxSetupStartParams; @@ -17,6 +19,49 @@ use tokio::time::timeout; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); +#[test_case::test_case(false, false)] +#[test_case::test_case(true, false)] +#[test_case::test_case(true, true)] +#[tokio::test] +async fn startup_mxc_preference_is_resolved_before_readiness( + prefer_mxc: bool, + deny_local_binding: bool, +) -> Result<()> { + let codex_home = TempDir::new()?; + let mut config_toml = "[features]\nprefer_mxc = false\n".to_string(); + if deny_local_binding { + config_toml + .push_str("[features.network_proxy]\nenabled = true\nallow_local_binding = false\n"); + } + let config_path = codex_home.path().join("config.toml"); + std::fs::write(&config_path, &config_toml)?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_args(&["-c", &format!("features.prefer_mxc={prefer_mxc}")]) + .build_initialized_with_timeout(DEFAULT_READ_TIMEOUT) + .await?; + let mxc_selected = + prefer_mxc && !deny_local_binding && codex_sandboxing::windows_mxc_available(); + let request_id = mcp + .send_raw_request("windowsSandbox/readiness", /*params*/ None) + .await?; + let readiness: WindowsSandboxReadinessResponse = + timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??; + assert_eq!( + readiness, + WindowsSandboxReadinessResponse { + status: if mxc_selected { + WindowsSandboxReadiness::Ready + } else { + WindowsSandboxReadiness::NotConfigured + } + } + ); + assert_eq!(std::fs::read_to_string(config_path)?, config_toml); + Ok(()) +} + #[tokio::test] async fn windows_sandbox_setup_start_emits_completion_notification() -> Result<()> { let responses = Vec::new(); diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs index 1eaee3508b..55d71d6dc0 100644 --- a/codex-rs/cli/src/debug_sandbox.rs +++ b/codex-rs/cli/src/debug_sandbox.rs @@ -319,7 +319,8 @@ async fn run_command_under_sandbox( if let SandboxType::Windows = sandbox_type { #[cfg(target_os = "windows")] { - if config.permissions.windows_sandbox_type != codex_sandboxing::SandboxType::WindowsMxc + if config.effective_local_windows_sandbox_type() + != codex_sandboxing::SandboxType::WindowsMxc { let workspace_roots = config .effective_workspace_roots() @@ -356,9 +357,11 @@ async fn run_command_under_sandbox( Some(spec) => Some( spec.start_proxy( &permission_profile, - managed_proxy_routing_for_windows_sandbox(config.permissions.windows_sandbox_type), + managed_proxy_routing_for_windows_sandbox( + config.effective_local_windows_sandbox_type(), + ), local_binding_policy_for_sandbox( - config.permissions.windows_sandbox_type, + config.effective_local_windows_sandbox_type(), Some(std::env::consts::OS), ), /*policy_decider*/ None, diff --git a/codex-rs/cli/src/doctor/sandbox.rs b/codex-rs/cli/src/doctor/sandbox.rs index bea8e8efdb..10a7d351f4 100644 --- a/codex-rs/cli/src/doctor/sandbox.rs +++ b/codex-rs/cli/src/doctor/sandbox.rs @@ -123,7 +123,7 @@ pub(super) fn sandbox_check(config: &Config, arg0_paths: &Arg0DispatchPaths) -> #[cfg(target_os = "windows")] { let configured_level = WindowsSandboxLevel::from_config(config); - let mxc = config.permissions.windows_sandbox_type + let mxc = config.effective_local_windows_sandbox_type() == codex_protocol::sandbox::SandboxType::WindowsMxc; let elevated = !mxc && configured_level != WindowsSandboxLevel::Disabled diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 746e0a2515..c5302f17d8 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -951,6 +951,9 @@ "powershell_shell_version": { "type": "boolean" }, + "prefer_mxc": { + "type": "boolean" + }, "prevent_idle_sleep": { "type": "boolean" }, @@ -6847,6 +6850,9 @@ "powershell_shell_version": { "type": "boolean" }, + "prefer_mxc": { + "type": "boolean" + }, "prevent_idle_sleep": { "type": "boolean" }, diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 9440ba9eac..5013f50b30 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -3524,14 +3524,22 @@ async fn default_permissions_profile_can_extend_builtin_read_only() -> std::io:: Ok(()) } +#[test_case::test_case(false; "legacy")] +#[test_case::test_case(true; "prefer_mxc")] #[tokio::test] -async fn empty_config_defaults_to_builtin_profile_for_trusted_project() -> std::io::Result<()> { +async fn empty_config_defaults_to_builtin_profile_for_trusted_project( + prefer_mxc: bool, +) -> std::io::Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; let project_key = cwd.path().to_string_lossy().to_string(); let config = Config::load_from_base_config_with_overrides( ConfigToml { + features: Some(FeaturesToml::from(BTreeMap::from([( + "prefer_mxc".to_string(), + prefer_mxc, + )]))), projects: Some(HashMap::from([( project_key, ProjectConfig { @@ -3548,6 +3556,7 @@ async fn empty_config_defaults_to_builtin_profile_for_trusted_project() -> std:: ) .await?; + let mxc_selected = prefer_mxc && codex_sandboxing::windows_mxc_available(); let policy = config.permissions.file_system_sandbox_policy(); assert_eq!( config @@ -3555,13 +3564,13 @@ async fn empty_config_defaults_to_builtin_profile_for_trusted_project() -> std:: .active_permission_profile() .as_ref() .map(|active| active.id.as_str()), - Some(if cfg!(target_os = "windows") { + Some(if cfg!(target_os = "windows") && !mxc_selected { BUILT_IN_PERMISSION_PROFILE_READ_ONLY } else { BUILT_IN_PERMISSION_PROFILE_WORKSPACE }) ); - if cfg!(target_os = "windows") { + if cfg!(target_os = "windows") && !mxc_selected { assert!( !policy.can_write_local_path_with_cwd(cwd.path(), cwd.path()), "expected trusted project fallback to stay read-only without Windows sandbox support, policy: {policy:?}" @@ -11030,6 +11039,73 @@ async fn explicit_sandbox_mode_falls_back_when_disallowed_by_requirements() -> s Ok(()) } +#[tokio::test] +async fn local_mxc_preference_preserves_configured_backend() -> anyhow::Result<()> { + use codex_sandboxing::SandboxType::WindowsMxc; + use codex_sandboxing::SandboxType::WindowsRestrictedToken; + + let codex_home = TempDir::new()?; + for (prefer, resolved_preference, binding, mode, expected) in [ + (true, true, true, "unelevated", WindowsMxc), + (true, false, true, "unelevated", WindowsRestrictedToken), + (true, false, false, "unelevated", WindowsRestrictedToken), + (false, false, true, "unelevated", WindowsRestrictedToken), + (false, false, false, "mxc", WindowsMxc), + ] { + let cfg: ConfigToml = toml::from_str(&format!( + "[windows]\nsandbox = {mode:?}\n[features]\nprefer_mxc = {prefer}\n\ + [features.network_proxy]\nenabled = true\nallow_local_binding = {binding}\n" + ))?; + assert_eq!( + network_config_allows_mxc( + &EffectivePermissionSelection { + profiles: None, + selected_profile_id: None, + persisted_profile_id_was_provided: false, + requirements_force_profile_selection: false, + }, + /*profiles_are_active*/ false, + /*permission_profile*/ None, + /*network_requirements*/ None, + cfg.features.as_ref(), + /*enable_network_proxy*/ true, + )?, + binding, + ); + let mut config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides { + cwd: Some(codex_home.path().to_path_buf()), + ..Default::default() + }, + codex_home.abs(), + ) + .await?; + assert_eq!( + config.prefer_mxc, + prefer && binding && codex_sandboxing::windows_mxc_available(), + ); + // Exercise both resolved decisions independently of the host's native support. + config.prefer_mxc = resolved_preference; + assert_eq!( + ( + config.windows_sandbox_type_from_config(), + config.effective_local_windows_sandbox_type() + ), + ( + if mode == "mxc" { + WindowsMxc + } else { + WindowsRestrictedToken + }, + expected + ), + "prefer={prefer}, resolved={resolved_preference}, binding={binding}, mode={mode}" + ); + } + Ok(()) +} + #[tokio::test] async fn windows_sandbox_mode_falls_back_when_disallowed_by_requirements() -> std::io::Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 60971edda8..566e9c4912 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -215,7 +215,9 @@ pub use permissions::resolve_permission_profile; pub(crate) use resolved_permission_profile::PermissionProfileState; pub use token_budget_startup::TokenBudgetStartupConfig; pub use windows_sandbox_config::PreparedWindowsSandboxConfig; +use windows_sandbox_config::network_config_allows_mxc; pub use windows_sandbox_config::prepare_windows_sandbox_config; +use windows_sandbox_config::resolve_windows_sandbox_type; const DEFAULT_IGNORE_LARGE_UNTRACKED_DIRS: i64 = 200; const DEFAULT_IGNORE_LARGE_UNTRACKED_FILES: i64 = 10 * 1024 * 1024; @@ -346,7 +348,7 @@ pub struct Permissions { /// Effective Windows sandbox mode derived from `[windows].sandbox` or /// legacy feature keys. pub windows_sandbox_mode: Option, - /// Selected Windows sandbox implementation, separate from the legacy setup level. + /// Configured Windows backend; use `Config::effective_local_windows_sandbox_type()` for local selection. pub windows_sandbox_type: SandboxType, } @@ -1089,6 +1091,9 @@ pub struct Config { /// Centralized feature flags; source of truth for feature gating. pub features: ManagedFeatures, + /// Local rollout preference after checking network restrictions and native availability. + pub prefer_mxc: bool, + /// When `true`, suppress warnings about unstable (under development) features. pub suppress_unstable_features_warning: bool, @@ -3382,10 +3387,6 @@ impl Config { &mut constrained_windows_sandbox_mode, &mut startup_warnings, )?; - let legacy_windows_sandbox_level = windows_sandbox_level_for_legacy_checks( - windows_sandbox_type, - windows_sandbox_level, - ); let resolved_cwd = AbsolutePathBuf::try_from(normalize_for_native_workdir({ use std::env; @@ -3464,6 +3465,22 @@ impl Config { default_permissions_override.as_deref(), permission_config_syntax, ); + let prefer_mxc = features.enabled(Feature::PreferMxc) + && network_config_allows_mxc( + &effective_permission_selection, + profiles_are_active, + permission_profile.as_ref(), + network_requirements.as_ref(), + cfg.features.as_ref(), + enable_network_proxy, + )? + && codex_sandboxing::windows_mxc_available(); + let local_windows_sandbox_type = + resolve_windows_sandbox_type(windows_sandbox_type, prefer_mxc); + let legacy_windows_sandbox_level = windows_sandbox_level_for_legacy_checks( + local_windows_sandbox_type, + windows_sandbox_level, + ); let explicit_permission_profile_mode = effective_permission_selection .persisted_profile_id_was_provided || default_permissions_override.is_some() @@ -4200,6 +4217,7 @@ impl Config { .map_err(std::io::Error::from)?; let otel = otel::resolve_config(cfg.otel.unwrap_or_default(), &mut startup_warnings); let config = Self { + prefer_mxc, model, service_tier, review_model, diff --git a/codex-rs/core/src/config/windows_sandbox_config.rs b/codex-rs/core/src/config/windows_sandbox_config.rs index 3b7ab6db21..c4f4530321 100644 --- a/codex-rs/core/src/config/windows_sandbox_config.rs +++ b/codex-rs/core/src/config/windows_sandbox_config.rs @@ -1,12 +1,45 @@ -//! Windows sandbox mode selection after managed features have been resolved. -//! Keep configured mode distinct from the effective level unless policy pins it. +//! Configured Windows sandbox modes and the effective local backend. +//! Keep the configured backend for remote inheritance; apply the local rollout +//! using the preference resolved during config loading. +use super::Config; +use super::EffectivePermissionSelection; use super::apply_requirement_constrained_value; +use super::network_proxy_toml_config; +use super::permissions::network_proxy_config_for_profile_selection; +use super::profile_allows_configured_network_proxy; use codex_config::ConstrainedWithSource; +use codex_config::NetworkConstraints; +use codex_config::Sourced; use codex_config::types::WindowsSandboxModeToml; +use codex_features::FeaturesToml; use codex_protocol::config_types::WindowsSandboxLevel; +use codex_protocol::models::PermissionProfile; use codex_sandboxing::SandboxType; +impl Config { + /// Configured backend, without the local rollout preference. Remote executors inherit this. + pub fn windows_sandbox_type_from_config(&self) -> SandboxType { + self.permissions.windows_sandbox_type + } + + /// Effective local backend, using eligibility and availability resolved at config load. + pub fn effective_local_windows_sandbox_type(&self) -> SandboxType { + resolve_windows_sandbox_type(self.windows_sandbox_type_from_config(), self.prefer_mxc) + } +} + +pub(super) fn resolve_windows_sandbox_type( + configured: SandboxType, + prefer_mxc: bool, +) -> SandboxType { + if prefer_mxc { + SandboxType::WindowsMxc + } else { + configured + } +} + #[derive(Debug, PartialEq)] pub struct PreparedWindowsSandboxConfig { /// Explicit or requirement-constrained mode; excludes feature-only fallback. @@ -58,6 +91,36 @@ pub fn prepare_windows_sandbox_config( }) } +/// Managed requirements take precedence; otherwise preserve explicit +/// feature-level or active-profile binding denials during automatic selection. +pub(super) fn network_config_allows_mxc( + permission_selection: &EffectivePermissionSelection<'_>, + profiles_are_active: bool, + permission_profile: Option<&PermissionProfile>, + network_requirements: Option<&Sourced>, + features: Option<&FeaturesToml>, + enable_network_proxy: bool, +) -> std::io::Result { + let profile_local_binding = if profiles_are_active + && permission_profile.is_none_or(profile_allows_configured_network_proxy) + && let Some(profile) = permission_selection.selected_profile_id + { + network_proxy_config_for_profile_selection(permission_selection.profiles.as_ref(), profile)? + .allow_local_binding + } else { + None + }; + let allow_local_binding = network_requirements + .and_then(|requirements| requirements.value.allow_local_binding) + .or_else(|| { + network_proxy_toml_config(features) + .filter(|config| enable_network_proxy && config.allow_local_binding == Some(false)) + .and_then(|config| config.allow_local_binding) + }) + .or(profile_local_binding); + Ok(allow_local_binding != Some(false)) +} + #[cfg(test)] #[path = "windows_sandbox_config_tests.rs"] mod tests; diff --git a/codex-rs/core/src/environment_selection.rs b/codex-rs/core/src/environment_selection.rs index 3f9c98d9ac..5f1a29a43f 100644 --- a/codex-rs/core/src/environment_selection.rs +++ b/codex-rs/core/src/environment_selection.rs @@ -247,7 +247,7 @@ impl ThreadEnvironments { pub(crate) fn new( environment_manager: Arc, local_shell: Shell, - thread_environment_config: EnvironmentConfig, + thread_environment_config: impl Fn(&Environment) -> EnvironmentConfig, shell_snapshot: ShellSnapshot, current: TurnEnvironmentSnapshot, non_blocking_snapshots: bool, @@ -296,7 +296,8 @@ impl ThreadEnvironments { pending_completion: None, }; // Child threads re-infer thread-owned config while preserving owner config. - inherited_environment.refresh_thread_config(&thread_environment_config); + let config = thread_environment_config(&inherited_environment.environment); + inherited_environment.refresh_thread_config(&config); Some(inherited_environment) }) .collect(); @@ -343,7 +344,7 @@ impl ThreadEnvironments { pub(crate) fn update_selections( &self, environments: &[TurnEnvironmentSelection], - thread_environment_config: &EnvironmentConfig, + thread_environment_config: impl Fn(&Environment) -> EnvironmentConfig, ) { let mut current = self .environments @@ -355,8 +356,6 @@ impl ThreadEnvironments { if !seen_environment_ids.insert(selected_environment.environment_id.as_str()) { continue; } - let (selected_environment, config_origin) = - resolve_selection_config(selected_environment.clone(), thread_environment_config); if let Some(environment) = current.iter().find(|environment| { let previous = &environment.selection; previous.environment_id == selected_environment.environment_id @@ -376,6 +375,10 @@ impl ThreadEnvironments { ); if !failed && !restarting_as_pending { + let (selected_environment, config_origin) = resolve_selection_config( + selected_environment.clone(), + &thread_environment_config(&environment.environment), + ); let shell_settings_changed = matches!( (&environment.selection.config, &selected_environment.config), (EnvironmentConfigState::Ready(previous), EnvironmentConfigState::Ready(current)) @@ -402,6 +405,10 @@ impl ThreadEnvironments { tracing::warn!("skipping unknown turn environment `{environment_id}`"); continue; }; + let (selected_environment, config_origin) = resolve_selection_config( + selected_environment.clone(), + &thread_environment_config(&environment), + ); // Connection state belongs to the environment instance, not its cwd or roots. let connection_events_task = current .iter() @@ -523,12 +530,16 @@ impl ThreadEnvironments { } /// Refreshes attachments whose configuration is inferred from the thread. - pub(crate) fn update_thread_config(&self, config: &EnvironmentConfig) { + pub(crate) fn update_thread_config( + &self, + thread_environment_config: impl Fn(&Environment) -> EnvironmentConfig, + ) { let mut environments = self .environments .lock() .unwrap_or_else(PoisonError::into_inner); for environment in environments.iter_mut() { + let config = thread_environment_config(&environment.environment); let shell_settings_changed = environment.config_origin == EnvironmentConfigOrigin::Thread && matches!( @@ -537,7 +548,7 @@ impl ThreadEnvironments { if previous.allow_login_shell != config.allow_login_shell || previous.shell_environment_policy != config.shell_environment_policy ); - environment.refresh_thread_config(config); + environment.refresh_thread_config(&config); if shell_settings_changed && !environment.environment.is_remote() && self.shell_snapshot.should_rebuild_inherited() @@ -1143,6 +1154,7 @@ mod tests { use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::FileSystemSandboxPolicyContext; use codex_protocol::protocol::TurnEnvironmentSelection; + use codex_protocol::sandbox::SandboxType; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path_uri::PathUri; use futures::SinkExt; @@ -1184,12 +1196,12 @@ mod tests { let turn_environments = Arc::new(ThreadEnvironments::new( environment_manager, crate::shell::default_user_shell(), - test_environment_config(), + |_| test_environment_config(), ShellSnapshot::disabled(), TurnEnvironmentSnapshot::default(), /*non_blocking_snapshots*/ false, )); - turn_environments.update_selections(selections, &test_environment_config()); + turn_environments.update_selections(selections, |_| test_environment_config()); turn_environments.snapshot().await; turn_environments } @@ -1253,6 +1265,7 @@ mod tests { "userHomeDir": "file:///home/remote", "platformOs": "windows", "temporaryDirectories": ["file:///tmp/remote"], + "capabilities": {"windowsMxc": true}, } }) .to_string() @@ -1362,7 +1375,7 @@ url = "ws://127.0.0.1:8765" let turn_environments = ThreadEnvironments::new( Arc::new(EnvironmentManager::default_for_tests()), local_shell.clone(), - expected_config.clone(), + |_| expected_config.clone(), ShellSnapshot::disabled(), TurnEnvironmentSnapshot::default(), /*non_blocking_snapshots*/ false, @@ -1374,7 +1387,7 @@ url = "ws://127.0.0.1:8765" workspace_roots: Vec::new(), config: EnvironmentConfigState::FromThread, }], - &expected_config, + |_| expected_config.clone(), ); let snapshot = turn_environments.snapshot().await; @@ -1537,13 +1550,14 @@ url = "ws://127.0.0.1:8765" let environments = Arc::new(ThreadEnvironments::new( manager, crate::shell::default_user_shell(), - test_environment_config(), + |_| test_environment_config(), ShellSnapshot::disabled(), TurnEnvironmentSnapshot::default(), /*non_blocking_snapshots*/ false, )); - environments - .update_selections(std::slice::from_ref(&selection), &test_environment_config()); + environments.update_selections(std::slice::from_ref(&selection), |_| { + test_environment_config() + }); // Exercise first-use callsite registration from a thread without a subscriber. std::thread::spawn({ let environments = Arc::clone(&environments); @@ -1590,8 +1604,15 @@ url = "ws://127.0.0.1:8765" ); } + #[test_case::test_case(SandboxType::None, SandboxType::None, WindowsSandboxLevel::Disabled; "legacy")] + #[test_case::test_case(SandboxType::WindowsRestrictedToken, SandboxType::WindowsMxc, WindowsSandboxLevel::Elevated; "local preference")] + #[test_case::test_case(SandboxType::WindowsMxc, SandboxType::WindowsMxc, WindowsSandboxLevel::Disabled; "explicit MXC")] #[tokio::test] - async fn snapshot_refreshes_readiness_in_selection_order() { + async fn snapshot_refreshes_readiness_in_selection_order( + configured_sandbox: SandboxType, + local_sandbox: SandboxType, + legacy_level: WindowsSandboxLevel, + ) { let listener = TcpListener::bind("127.0.0.1:0") .await .expect("bind websocket listener"); @@ -1609,8 +1630,8 @@ url = "ws://127.0.0.1:8765" let expected_config = EnvironmentConfig { allow_login_shell: false, workspace_roots: Vec::new(), - windows_sandbox_level: WindowsSandboxLevel::Disabled, - windows_sandbox_type: codex_protocol::sandbox::SandboxType::None, + windows_sandbox_level: legacy_level, + windows_sandbox_type: configured_sandbox, use_legacy_landlock: false, permission_profile: PermissionProfileSnapshot::active_with_profile_workspace_roots( PermissionProfile::read_only(), @@ -1623,6 +1644,19 @@ url = "ws://127.0.0.1:8765" network_policy: None, selected_capability_roots: Vec::new(), }; + let expected_local_config = EnvironmentConfig { + windows_sandbox_type: local_sandbox, + ..expected_config.clone() + }; + let config_for = + |config: &EnvironmentConfig, environment: &Environment| EnvironmentConfig { + windows_sandbox_type: if environment.is_remote() { + config.windows_sandbox_type + } else { + local_sandbox + }, + ..config.clone() + }; let cwd = PathUri::from_abs_path(&cwd); let remote = TurnEnvironmentSelection { environment_id: REMOTE_ENVIRONMENT_ID.to_string(), @@ -1637,20 +1671,23 @@ url = "ws://127.0.0.1:8765" config: EnvironmentConfigState::FromThread, }; let turn_environments = ThreadEnvironments::new( - manager, + Arc::clone(&manager), crate::shell::default_user_shell(), - test_environment_config(), + |environment| config_for(&test_environment_config(), environment), ShellSnapshot::disabled(), TurnEnvironmentSnapshot::default(), /*non_blocking_snapshots*/ true, ); - turn_environments - .update_selections(std::slice::from_ref(&local), &test_environment_config()); + turn_environments.update_selections(std::slice::from_ref(&local), |environment| { + config_for(&test_environment_config(), environment) + }); let resolution = turn_environments.environments.lock().unwrap()[0] .resolution .clone(); resolution.await.expect("local environment should resolve"); - turn_environments.update_selections(&[remote.clone(), local.clone()], &expected_config); + turn_environments.update_selections(&[remote.clone(), local.clone()], |environment| { + config_for(&expected_config, environment) + }); let starting = turn_environments.snapshot().await; let resolved_remote = TurnEnvironmentSelection { @@ -1669,7 +1706,7 @@ url = "ws://127.0.0.1:8765" .turn_environments() .map(|environment| environment.config().clone()) .collect::>(), - vec![expected_config.clone()] + vec![expected_local_config.clone()] ); assert_eq!( starting @@ -1689,7 +1726,7 @@ url = "ws://127.0.0.1:8765" permission_profile: PermissionProfileSnapshot::legacy(PermissionProfile::Disabled), ..test_environment_config() }; - turn_environments.update_thread_config(&next_config); + turn_environments.update_thread_config(|environment| config_for(&next_config, environment)); let next_starting = turn_environments.snapshot().await; assert!( !next_starting.has_full_access(AskForApproval::Never, &PermissionProfile::Disabled) @@ -1713,6 +1750,27 @@ url = "ws://127.0.0.1:8765" let attached = starting.refresh_readiness(); assert!(attached.starting().next().is_none()); + let child = ThreadEnvironments::new( + manager, + crate::shell::default_user_shell(), + |environment| config_for(&next_config, environment), + ShellSnapshot::disabled(), + attached.clone(), + /*non_blocking_snapshots*/ true, + ); + assert_eq!( + child + .snapshot() + .await + .turn_environments() + .map(|environment| environment.config().clone()) + .collect::>(), + next_starting + .refresh_readiness() + .turn_environments() + .map(|environment| environment.config().clone()) + .collect::>(), + ); assert_eq!( attached .turn_environments() @@ -1725,7 +1783,7 @@ url = "ws://127.0.0.1:8765" .turn_environments() .map(|environment| environment.config().clone()) .collect::>(), - vec![expected_config.clone(), expected_config] + vec![expected_config, expected_local_config] ); assert_eq!( attached @@ -1756,7 +1814,13 @@ url = "ws://127.0.0.1:8765" .turn_environments() .map(|environment| environment.config().clone()) .collect::>(), - vec![next_config.clone(), next_config] + vec![ + next_config.clone(), + EnvironmentConfig { + windows_sandbox_type: local_sandbox, + ..next_config + } + ] ); server.await.expect("server task"); } @@ -1779,13 +1843,14 @@ url = "ws://127.0.0.1:8765" let environments = ThreadEnvironments::new( Arc::clone(&manager), crate::shell::default_user_shell(), - test_environment_config(), + |_| test_environment_config(), ShellSnapshot::disabled(), TurnEnvironmentSnapshot::default(), /*non_blocking_snapshots*/ true, ); - environments - .update_selections(std::slice::from_ref(&selection), &test_environment_config()); + environments.update_selections(std::slice::from_ref(&selection), |_| { + test_environment_config() + }); let starting = environments.snapshot().await; let failed_resolution = environments.environments.lock().unwrap()[0] .resolution @@ -1833,12 +1898,12 @@ url = "ws://127.0.0.1:8765" allow_login_shell: false, ..test_environment_config() }; - environments.update_thread_config(&next_config); + environments.update_thread_config(|_| next_config.clone()); assert!( failed_resolution.ptr_eq(&environments.environments.lock().unwrap()[0].resolution), "updating environment config must not retry a failed environment" ); - environments.update_selections(std::slice::from_ref(&selection), &next_config); + environments.update_selections(std::slice::from_ref(&selection), |_| next_config.clone()); let replacement = environments.snapshot().await; let replacement = replacement @@ -1882,14 +1947,15 @@ url = "ws://127.0.0.1:8765" let environments = Arc::new(ThreadEnvironments::new( Arc::clone(&manager), crate::shell::default_user_shell(), - test_environment_config(), + |_| test_environment_config(), ShellSnapshot::disabled(), TurnEnvironmentSnapshot::default(), /*non_blocking_snapshots*/ true, )); environments.start_connection_event_forwarding(tx_event); - environments - .update_selections(std::slice::from_ref(&selection), &test_environment_config()); + environments.update_selections(std::slice::from_ref(&selection), |_| { + test_environment_config() + }); let initial_snapshot = environments.snapshot().await; let second_listener = TcpListener::bind("127.0.0.1:0") .await @@ -1907,15 +1973,16 @@ url = "ws://127.0.0.1:8765" ) .expect("replace environment"); - environments - .update_selections(std::slice::from_ref(&selection), &test_environment_config()); + environments.update_selections(std::slice::from_ref(&selection), |_| { + test_environment_config() + }); let reused_snapshot = environments.snapshot().await; environments.update_selections( &[TurnEnvironmentSelection { cwd: PathUri::from_abs_path(&cwd.join("changed")), ..selection }], - &test_environment_config(), + |_| test_environment_config(), ); let changed_snapshot = environments.snapshot().await; @@ -2014,7 +2081,7 @@ url = "ws://127.0.0.1:8765" let environments = ThreadEnvironments::new( manager, crate::shell::default_user_shell(), - child_config.clone(), + |_| child_config.clone(), ShellSnapshot::disabled(), TurnEnvironmentSnapshot { environments: vec![TurnEnvironmentState::Ready(inherited)], @@ -2022,7 +2089,7 @@ url = "ws://127.0.0.1:8765" /*non_blocking_snapshots*/ false, ); - environments.update_selections(std::slice::from_ref(&selection), &child_config); + environments.update_selections(std::slice::from_ref(&selection), |_| child_config.clone()); let snapshot = environments.snapshot().await; let inherited = snapshot.primary().expect("inherited environment"); @@ -2079,18 +2146,18 @@ url = "ws://127.0.0.1:8765" }; let mut owner_selection = selection.clone(); owner_selection.config = EnvironmentConfigState::Ready(parent_owner_config.clone()); - parent.update_selections( - std::slice::from_ref(&owner_selection), - &test_environment_config(), - ); + parent.update_selections(std::slice::from_ref(&owner_selection), |_| { + test_environment_config() + }); let child_thread_config = EnvironmentConfig { + windows_sandbox_type: SandboxType::WindowsMxc, permission_profile: PermissionProfileSnapshot::legacy( PermissionProfile::workspace_write(), ), ..test_environment_config() }; - parent.update_thread_config(&child_thread_config); + parent.update_thread_config(|_| child_thread_config.clone()); assert_eq!( parent .snapshot() @@ -2103,7 +2170,7 @@ url = "ws://127.0.0.1:8765" let child = ThreadEnvironments::new( manager, crate::shell::default_user_shell(), - child_thread_config.clone(), + |_| child_thread_config.clone(), ShellSnapshot::disabled(), parent.snapshot().await, /*non_blocking_snapshots*/ false, @@ -2116,7 +2183,9 @@ url = "ws://127.0.0.1:8765" ..parent_owner_config.clone() }; owner_selection.config = EnvironmentConfigState::Ready(cleared_parent_owner_config.clone()); - parent.update_selections(std::slice::from_ref(&owner_selection), &child_thread_config); + parent.update_selections(std::slice::from_ref(&owner_selection), |_| { + child_thread_config.clone() + }); let cleared_snapshot = parent.snapshot().await; assert_eq!( cleared_snapshot @@ -2132,7 +2201,9 @@ url = "ws://127.0.0.1:8765" ..selection }; let new_thread_config = test_environment_config(); - parent.update_selections(std::slice::from_ref(&changed_selection), &new_thread_config); + parent.update_selections(std::slice::from_ref(&changed_selection), |_| { + new_thread_config.clone() + }); let changed_snapshot = parent.snapshot().await; let changed_environment = changed_snapshot.primary().expect("changed environment"); assert_eq!(changed_environment.config(), &new_thread_config); diff --git a/codex-rs/core/src/environment_selection_snapshot_tests.rs b/codex-rs/core/src/environment_selection_snapshot_tests.rs index b9bbf9d492..1fe5204116 100644 --- a/codex-rs/core/src/environment_selection_snapshot_tests.rs +++ b/codex-rs/core/src/environment_selection_snapshot_tests.rs @@ -24,12 +24,12 @@ async fn unpolled_snapshot_does_not_delay_canceling_a_removed_environment() { let environments = ThreadEnvironments::new( Arc::new(EnvironmentManager::default_for_tests()), crate::shell::default_user_shell(), - config.clone(), + |_| config.clone(), ShellSnapshot::disabled(), TurnEnvironmentSnapshot::default(), /*non_blocking_snapshots*/ false, ); - environments.update_selections(&[selection], &config); + environments.update_selections(&[selection], |_| config.clone()); let starting = environments .snapshot() .await @@ -39,7 +39,7 @@ async fn unpolled_snapshot_does_not_delay_canceling_a_removed_environment() { .clone(); let held_snapshot = environments.snapshot(); - environments.update_selections(&[], &config); + environments.update_selections(&[], |_| config.clone()); let error = timeout(Duration::from_secs(5), starting.wait_until_ready()) .await @@ -87,7 +87,7 @@ async fn credential_refresh_does_not_restore_a_removed_environment() { let environments = Arc::new(ThreadEnvironments::new( Arc::new(EnvironmentManager::default_for_tests()), crate::shell::default_user_shell(), - config.clone(), + |_| config.clone(), shell_snapshot, TurnEnvironmentSnapshot::default(), /*non_blocking_snapshots*/ false, @@ -99,7 +99,7 @@ async fn credential_refresh_does_not_restore_a_removed_environment() { workspace_roots: vec![cwd_uri], config: EnvironmentConfigState::FromThread, }], - &config, + |_| config.clone(), ); let resolution = environments.environments.lock().unwrap()[0] .resolution @@ -128,7 +128,7 @@ async fn credential_refresh_does_not_restore_a_removed_environment() { let removing = Arc::clone(&environments); let mut removal = tokio::task::spawn_blocking(move || { removal_started_tx.send(()).expect("signal removal started"); - removing.update_selections(&[], &config); + removing.update_selections(&[], |_| config.clone()); }); timeout(Duration::from_secs(/*secs*/ 5), removal_started_rx) .await diff --git a/codex-rs/core/src/mcp_openai_file.rs b/codex-rs/core/src/mcp_openai_file.rs index 103303ff92..1e33c74215 100644 --- a/codex-rs/core/src/mcp_openai_file.rs +++ b/codex-rs/core/src/mcp_openai_file.rs @@ -373,12 +373,14 @@ mod tests { let environments = crate::environment_selection::ThreadEnvironments::new( session.services.turn_environments.environment_manager(), crate::shell::default_user_shell(), - environment_config.clone(), + |_| environment_config.clone(), crate::shell_snapshot::ShellSnapshot::disabled(), Default::default(), /*non_blocking_snapshots*/ true, ); - environments.update_selections(std::slice::from_ref(&selection), &environment_config); + environments.update_selections(std::slice::from_ref(&selection), |_| { + environment_config.clone() + }); turn_context.initial_environments = environments.snapshot().await; turn_context .initial_environments diff --git a/codex-rs/core/src/session/environment.rs b/codex-rs/core/src/session/environment.rs index d77d9df3da..bd4217d116 100644 --- a/codex-rs/core/src/session/environment.rs +++ b/codex-rs/core/src/session/environment.rs @@ -158,10 +158,11 @@ impl Session { } } if environments != self.services.turn_environments.selections() { - self.services.turn_environments.update_selections( - &environments, - &configuration.inferred_environment_config(), - ); + self.services + .turn_environments + .update_selections(&environments, |environment| { + configuration.inferred_environment_config_for(environment) + }); } } self.services.turn_environments.snapshot() @@ -256,10 +257,13 @@ impl Session { if update_current { // Invalidate MCP before installed configuration can wake a waiting turn. self.mark_mcp_runtime_dirty(); - self.services.turn_environments.update_selections( - ¤t, - &state.session_configuration.inferred_environment_config(), - ); + self.services + .turn_environments + .update_selections(¤t, |environment| { + state + .session_configuration + .inferred_environment_config_for(environment) + }); } Ok(()) } diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 579644823d..52ef829394 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -842,7 +842,7 @@ impl Session { allow_login_shell: config.permissions.allow_login_shell, shell_environment_policy: config.permissions.shell_environment_policy.clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), - windows_sandbox_type: config.permissions.windows_sandbox_type, + windows_sandbox_type: config.effective_local_windows_sandbox_type(), use_legacy_landlock: config.features.use_legacy_landlock(), legacy_fallback_cwd: config.cwd.clone(), runtime_workspace_roots: config.workspace_roots.clone(), @@ -1908,7 +1908,9 @@ impl Session { if state.session_configuration.inferred_environment_config() != environment_config { self.services .turn_environments - .update_thread_config(&environment_config); + .update_thread_config(|environment| { + updated.inferred_environment_config_for(environment) + }); } state.session_configuration = updated; if root_service_tier_changed { diff --git a/codex-rs/core/src/session/review.rs b/codex-rs/core/src/session/review.rs index df65e13704..4728c750d9 100644 --- a/codex-rs/core/src/session/review.rs +++ b/codex-rs/core/src/session/review.rs @@ -122,7 +122,9 @@ pub(super) async fn spawn_review_thread( .map(TurnEnvironment::windows_sandbox_selection_for_turn_metadata) .unwrap_or_else(|| { crate::tools::sandboxing::configured_windows_sandbox_selection( - parent_turn_context.config.permissions.windows_sandbox_type, + parent_turn_context + .config + .effective_local_windows_sandbox_type(), parent_turn_context.windows_sandbox_level, &codex_utils_path_uri::PathUri::from_abs_path(&parent_turn_context.cwd), ) diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index bec92fcaf4..43a0af3c36 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -178,7 +178,9 @@ impl SessionConfiguration { permission_profile: self.permission_profile_state.snapshot(), shell_environment_policy: self.shell_environment_policy.clone(), windows_sandbox_level: self.windows_sandbox_level, - windows_sandbox_type: self.windows_sandbox_type, + windows_sandbox_type: self + .original_config_do_not_use + .windows_sandbox_type_from_config(), use_legacy_landlock: self.use_legacy_landlock, exec_policy: None, mcp_policy: None, @@ -187,6 +189,18 @@ impl SessionConfiguration { } } + /// Apply the local rollout only when inferring config for a local environment. + pub(super) fn inferred_environment_config_for( + &self, + environment: &Environment, + ) -> EnvironmentConfig { + let mut config = self.inferred_environment_config(); + if !environment.is_remote() { + config.windows_sandbox_type = self.windows_sandbox_type; + } + config + } + pub(super) fn permission_profile(&self) -> PermissionProfile { self.permission_profile_state.permission_profile().clone() } @@ -1412,14 +1426,14 @@ impl Session { let turn_environments = Arc::new(ThreadEnvironments::new( environment_manager, default_shell.clone(), - session_configuration.inferred_environment_config(), + |environment| session_configuration.inferred_environment_config_for(environment), shell_snapshot, inherited_environments.unwrap_or_default(), config.features.enabled(Feature::DeferredExecutor), )); turn_environments.update_selections( environment_selections, - &session_configuration.inferred_environment_config(), + |environment| session_configuration.inferred_environment_config_for(environment), ); session_configuration.environments = turn_environments.selections(); let resolved_environments = turn_environments.snapshot().await; @@ -1522,7 +1536,7 @@ impl Session { spec, current_exec_policy.as_ref(), config.permissions.permission_profile(), - config.permissions.windows_sandbox_type, + config.effective_local_windows_sandbox_type(), network_policy_decider.as_ref().map(Arc::clone), blocked_request_observer.as_ref().map(Arc::clone), managed_network_requirements_configured, diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 911fb1e4a6..68bbc8ff29 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -5002,15 +5002,14 @@ async fn resolved_environments_for_configuration( let turn_environments = ThreadEnvironments::new( Arc::clone(&environment_manager), default_user_shell(), - session_configuration.inferred_environment_config(), + |environment| session_configuration.inferred_environment_config_for(environment), ShellSnapshot::disabled(), TurnEnvironmentSnapshot::default(), /*non_blocking_snapshots*/ false, ); - turn_environments.update_selections( - environment_selections, - &session_configuration.inferred_environment_config(), - ); + turn_environments.update_selections(environment_selections, |environment| { + session_configuration.inferred_environment_config_for(environment) + }); (environment_manager, turn_environments.snapshot().await) } @@ -6401,7 +6400,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { let turn_environments = Arc::new(ThreadEnvironments::new( environment_manager, default_user_shell(), - session_configuration.inferred_environment_config(), + |environment| session_configuration.inferred_environment_config_for(environment), ShellSnapshot::disabled(), resolved_environments, /*non_blocking_snapshots*/ false, @@ -8668,7 +8667,7 @@ where let turn_environments = Arc::new(ThreadEnvironments::new( environment_manager, default_user_shell(), - session_configuration.inferred_environment_config(), + |environment| session_configuration.inferred_environment_config_for(environment), ShellSnapshot::disabled(), resolved_turn_environments.clone(), /*non_blocking_snapshots*/ false, diff --git a/codex-rs/core/src/shell_snapshot_tests.rs b/codex-rs/core/src/shell_snapshot_tests.rs index a007da563e..ead4186256 100644 --- a/codex-rs/core/src/shell_snapshot_tests.rs +++ b/codex-rs/core/src/shell_snapshot_tests.rs @@ -313,7 +313,7 @@ async fn inactive_profiles_keep_snapshots_but_active_brokers_require_sandbox() - let environments = ThreadEnvironments::new( Arc::clone(&manager), shell.clone(), - config.clone(), + |_| config.clone(), snapshot_builder.clone(), TurnEnvironmentSnapshot::default(), /*non_blocking_snapshots*/ false, @@ -325,7 +325,7 @@ async fn inactive_profiles_keep_snapshots_but_active_brokers_require_sandbox() - workspace_roots: Vec::new(), config: EnvironmentConfigState::FromThread, }], - &config, + |_| config.clone(), ); for (state, expect_snapshot) in [ (SnapshotCredentialBrokerState::Starting, false), @@ -354,7 +354,7 @@ async fn inactive_profiles_keep_snapshots_but_active_brokers_require_sandbox() - .shell_environment_policy .r#set .insert("CORP_REGION".into(), "west".into()); - environments.update_thread_config(&config); + environments.update_thread_config(|_| config.clone()); let updated = environments.snapshot().await; assert!(!Arc::ptr_eq( &environment.shell_snapshot_cache, @@ -366,7 +366,7 @@ async fn inactive_profiles_keep_snapshots_but_active_brokers_require_sandbox() - let child = ThreadEnvironments::new( Arc::clone(&manager), shell.clone(), - config.clone(), + |_| config.clone(), snapshot_builder.clone(), updated.clone(), /*non_blocking_snapshots*/ false, @@ -393,7 +393,7 @@ async fn inactive_profiles_keep_snapshots_but_active_brokers_require_sandbox() - environments.set_snapshot_credential_broker(SnapshotCredentialBrokerState::Ready( started_proxy.proxy(), )); - environments.update_thread_config(&config); + environments.update_thread_config(|_| config.clone()); let turn = environments.snapshot().await; let environment = turn.primary().expect("brokered environment"); let mut tool_config = crate::config::ConfigBuilder::without_managed_config_for_tests() diff --git a/codex-rs/core/src/test_support.rs b/codex-rs/core/src/test_support.rs index 0cf04f0f4c..2badfd6546 100644 --- a/codex-rs/core/src/test_support.rs +++ b/codex-rs/core/src/test_support.rs @@ -49,6 +49,26 @@ static TEST_MODEL_PRESETS: Lazy> = Lazy::new(|| { presets }); +/// Inspect the same resolved environment configurations used to construct sampling steps. +pub async fn environment_windows_sandbox_types( + thread: &crate::CodexThread, +) -> Vec<(String, codex_sandboxing::SandboxType)> { + thread + .session + .services + .turn_environments + .snapshot() + .await + .turn_environments() + .map(|environment| { + ( + environment.selection.environment_id.clone(), + environment.config().windows_sandbox_type, + ) + }) + .collect() +} + /// Reattaches request-only observations to a completed turn's history for capture assertions. /// Tests inspect this separately from the destination-filtered HTTP/WS request. pub async fn history_with_tool_call_metadata( diff --git a/codex-rs/core/tests/common/context_snapshot/context_snapshot_tests.rs b/codex-rs/core/tests/common/context_snapshot/context_snapshot_tests.rs index 1e6c3aeab9..94208c0929 100644 --- a/codex-rs/core/tests/common/context_snapshot/context_snapshot_tests.rs +++ b/codex-rs/core/tests/common/context_snapshot/context_snapshot_tests.rs @@ -699,7 +699,7 @@ fn detailed_permissions_normalize_paths_and_keep_policy_changes_visible() { "Some additional sandbox guidance. ".repeat(5) ); let environment = format!( - "\n{cwd}\n{external}\n" + "\n{cwd}\n{external}{external}{cwd}{separator}{denied}\n" ); let body = json!({"input": [ {"type": "message", "role": "developer", "content": [{"type": "input_text", "text": permissions}]}, diff --git a/codex-rs/core/tests/common/context_snapshot/normalize.rs b/codex-rs/core/tests/common/context_snapshot/normalize.rs index 55bd40ff08..b9d82891ff 100644 --- a/codex-rs/core/tests/common/context_snapshot/normalize.rs +++ b/codex-rs/core/tests/common/context_snapshot/normalize.rs @@ -54,12 +54,12 @@ impl Normalizer { }; let text = normalize_line_endings(&text); let segment = known_segment_name(&text, source); - let text = self.normalize_values(&text); let text = match segment.as_deref() { Some("PERMISSIONS_INSTRUCTIONS") => self.permissions(&text), Some("ENVIRONMENT_CONTEXT") => self.environment(&text), _ => text, }; + let text = self.normalize_values(&text); if options.rewrite_known_segments && let Some(segment) = segment { diff --git a/codex-rs/core/tests/suite/network_approval_completion.rs b/codex-rs/core/tests/suite/network_approval_completion.rs index d9067dd94c..efc0c9946f 100644 --- a/codex-rs/core/tests/suite/network_approval_completion.rs +++ b/codex-rs/core/tests/suite/network_approval_completion.rs @@ -39,15 +39,17 @@ use tokio::net::TcpStream; use tokio::time::timeout; use tokio_tungstenite::WebSocketStream; -#[test_case(SandboxType::WindowsRestrictedToken, "allow_local_binding = true", Ok(true); "explicit_local_binding")] -#[test_case(SandboxType::WindowsMxc, "", Ok(true); "mxc_default_local_binding")] -#[test_case(SandboxType::WindowsMxc, "allow_local_binding = false", Err("MXC cannot enforce allow_local_binding=false"); "mxc_rejects_false")] -#[test_case(SandboxType::WindowsRestrictedToken, "", Ok(false); "non_mxc_default_local_binding")] +#[test_case(SandboxType::WindowsRestrictedToken, "allow_local_binding = true", Ok(true), false; "explicit_local_binding")] +#[test_case(SandboxType::WindowsMxc, "", Ok(true), false; "mxc_default_local_binding")] +#[test_case(SandboxType::WindowsMxc, "allow_local_binding = false", Err("MXC cannot enforce allow_local_binding=false"), false; "mxc_rejects_false")] +#[test_case(SandboxType::WindowsRestrictedToken, "", Ok(false), false; "non_mxc_default_local_binding")] +#[test_case(SandboxType::WindowsRestrictedToken, "", Ok(false), true; "local_mxc_preference_keeps_remote_legacy")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn remote_local_binding_policy_and_review_cleanup( sandbox_type: SandboxType, local_binding: &'static str, expected_local_binding: Result, + prefer_mxc: bool, ) -> Result<()> { let server = start_mock_server().await; let listener = TcpListener::bind("127.0.0.1:0").await?; @@ -199,12 +201,19 @@ mode = "full" config.project_doc_max_bytes = 0; config.approvals_reviewer = ApprovalsReviewer::AutoReview; config.permissions.windows_sandbox_type = sandbox_type; + config.prefer_mxc = prefer_mxc; #[cfg(windows)] config.set_windows_sandbox_enabled(/*value*/ true); }) // This test supplies its own fake executor and must not select a CI executor. .build(&server) .await?; + if prefer_mxc { + assert_eq!( + test.config.effective_local_windows_sandbox_type(), + SandboxType::WindowsMxc + ); + } let (sandbox_policy, permission_profile) = turn_permission_fields( test.session_configured.permission_profile.clone(), test.config.cwd.as_path(), diff --git a/codex-rs/core/tests/suite/scenarios.rs b/codex-rs/core/tests/suite/scenarios.rs index fa86826cea..e40a21006e 100644 --- a/codex-rs/core/tests/suite/scenarios.rs +++ b/codex-rs/core/tests/suite/scenarios.rs @@ -84,6 +84,9 @@ mod preparation; #[path = "scenarios_shared_instructions.rs"] mod shared_instructions; +#[path = "scenarios_mxc.rs"] +mod mxc; + fn skills_extensions() -> Arc> { let mut extensions = ExtensionRegistryBuilder::::new(); install(&mut extensions, |config: &Config| SkillsExtensionConfig { diff --git a/codex-rs/core/tests/suite/scenarios_mxc.rs b/codex-rs/core/tests/suite/scenarios_mxc.rs new file mode 100644 index 0000000000..fb2e24c16a --- /dev/null +++ b/codex-rs/core/tests/suite/scenarios_mxc.rs @@ -0,0 +1,67 @@ +//! Exercises resolved local MXC selection in a real thread and snapshots its permission context. + +use anyhow::Result; +use codex_protocol::models::PermissionProfile; +use codex_protocol::permissions::NetworkSandboxPolicy; +use codex_protocol::sandbox::SandboxType; +use core_test_support::context_snapshot; +use core_test_support::context_snapshot::ContextSnapshotOptions; +use core_test_support::responses::ev_completed; +use core_test_support::responses::mount_sse_once; +use core_test_support::responses::sse; +use core_test_support::responses::start_mock_server; +use core_test_support::test_codex::test_codex; +use pretty_assertions::assert_eq; + +#[test_case::test_case(true; "preferred")] +#[test_case::test_case(false; "legacy")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn preferred_mxc_reaches_local_thread_and_permission_context(prefer_mxc: bool) -> Result<()> { + let server = start_mock_server().await; + let mock = mount_sse_once(&server, sse(vec![ev_completed("done")])).await; + let test = test_codex() + .with_config(move |config| { + // Start from the resolved preference; native probing is covered by config tests. + config.prefer_mxc = prefer_mxc; + config.permissions.windows_sandbox_type = SandboxType::None; + config + .permissions + .set_permission_profile(if prefer_mxc { + PermissionProfile::workspace_write_with( + &[], + NetworkSandboxPolicy::Restricted, + /*exclude_tmpdir_env_var*/ true, + /*exclude_slash_tmp*/ true, + ) + } else { + PermissionProfile::read_only() + }) + .expect("set test permissions"); + }) + // This explicitly exercises host-local selection, including on remote-executor CI. + .build(&server) + .await?; + test.submit_text_turn("Inspect the sandbox.").await?; + assert_eq!( + codex_core::test_support::environment_windows_sandbox_types(&test.codex).await, + vec![( + "local".to_string(), + if prefer_mxc { + SandboxType::WindowsMxc + } else { + SandboxType::None + } + )] + ); + if prefer_mxc { + insta::assert_snapshot!( + "preferred_mxc_permissions", + context_snapshot::format_request_history_snapshot( + "Resolved local MXC selection preserves workspace-write permissions in a real agent turn.", + &mock.requests(), + &ContextSnapshotOptions::default(), + ) + ); + } + Ok(()) +} diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__mxc__preferred_mxc_permissions.snap b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__mxc__preferred_mxc_permissions.snap new file mode 100644 index 0000000000..8b231ed53c --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__mxc__preferred_mxc_permissions.snap @@ -0,0 +1,36 @@ +--- +source: core/tests/suite/scenarios_mxc.rs +expression: "context_snapshot::format_request_history_snapshot(\"Resolved local MXC selection preserves workspace-write permissions in a real agent turn.\",\n&mock.requests(), &ContextSnapshotOptions::default(),)" +--- +Scenario: Resolved local MXC selection preserves workspace-write permissions in a real agent turn. + +## Window 1 +-- request 1 (turn) -- +00:message/developer: + + Filesystem sandboxing defines which files can be read or written. `sandbox_mode` is `workspace-write`: T...ries requires approval. Network access is restricted. [hash=99963de5837f6638] + # Escalation Requests + + Commands are run outside the sandbox if they are approved by the user, or match an existing rule that al...hell control operators, including but not limited to: [hash=d24ebf5fa4e4b9bb] + + - Pipes: | + - Logical operators: &&, || + + + ### Examples + Good examples of prefixes: + - ["npm", "run", "dev"] + - ["gh", "pr", "check"] + - ["cargo", "test"] + The writable root is ``. + +01:message/user: + + + + + + [hash=c88e32494c7023a5] + +02:message/user: + Inspect the sandbox. diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 0db72911be..5ea619d0ab 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -397,6 +397,8 @@ pub enum Feature { WindowsSandboxElevated, /// Attempt elevated Windows sandbox provisioning through the installed service. WindowsSandboxService, + /// Prefer the local native Windows sandbox when available, retaining legacy fallback. + PreferMxc, /// Legacy remote models flag kept for backward compatibility. RemoteModels, /// Removed legacy git commit attribution guidance flag. @@ -1254,6 +1256,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::UnderDevelopment, default_enabled: false, }, + FeatureSpec { + id: Feature::PreferMxc, + key: "prefer_mxc", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::RemoteModels, key: "remote_models", diff --git a/codex-rs/mxc-sandbox/README.md b/codex-rs/mxc-sandbox/README.md index 5c2d8c691e..c36689ca30 100644 --- a/codex-rs/mxc-sandbox/README.md +++ b/codex-rs/mxc-sandbox/README.md @@ -26,6 +26,17 @@ direct non-loopback egress and general inbound network access. Win32k calls and desktop handles remain available for PowerShell startup; clipboard, input-injection, and desktop/system-control restrictions remain. +## Selection and rollout + +`windows.sandbox = "mxc"` is strict. The default-off `features.prefer_mxc` +selects MXC for local execution when available unless local binding is explicitly +forbidden; otherwise legacy configuration and setup apply. Remote executors keep +their configured backend. Command failures never trigger backend fallback. +Desktop can supply `-c features.prefer_mxc=true` from its saved rollout decision +on the next local Windows launch. Gate changes require a subsequent launch, and +rollback may require legacy setup. `config/read` retains the configured sandbox; +`windowsSandbox/readiness` reflects the effective local selection. + ## Launch contract `create_command_args()` wraps the command like the Seatbelt backend, using the diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index a3708ddf46..44f1197fa6 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -320,6 +320,7 @@ fn new_config(model: Option, arg0_paths: Arg0DispatchPaths) -> anyhow::R current_time_reminder: None, sleep_tool_mode: Default::default(), features: Default::default(), + prefer_mxc: false, suppress_unstable_features_warning: false, active_project: ProjectConfig { trust_level: None }, notices: Notice::default(),