diff --git a/codex-rs/app-server/src/config_manager.rs b/codex-rs/app-server/src/config_manager.rs index 10739fb0ef..d5f2c2b89a 100644 --- a/codex-rs/app-server/src/config_manager.rs +++ b/codex-rs/app-server/src/config_manager.rs @@ -399,6 +399,10 @@ impl ConfigManager { cwd, ) .await + .and_then(|config| { + config.validate_windows_mxc_requirement()?; + Ok(config) + }) } /// Reload sources using the task's session flags before materializing config. @@ -437,6 +441,10 @@ impl ConfigManager { Some(cwd.to_path_buf()), ) .await + .and_then(|config| { + config.validate_windows_mxc_requirement()?; + Ok(config) + }) } #[instrument(level = "trace", skip_all)] diff --git a/codex-rs/app-server/src/in_process_bootstrap.rs b/codex-rs/app-server/src/in_process_bootstrap.rs index a21c3b0b96..17b37b9d6d 100644 --- a/codex-rs/app-server/src/in_process_bootstrap.rs +++ b/codex-rs/app-server/src/in_process_bootstrap.rs @@ -133,6 +133,7 @@ pub(super) async fn configure( let policy_config = config_manager .load_startup_config(Some(config.cwd.to_path_buf())) .await?; + config.validate_windows_mxc_requirement_from(&policy_config)?; let auth_config = codex_login::AuthConfig { chatgpt_base_url: Some(policy_config.chatgpt_base_url.clone()), forced_login_method: policy_config.forced_login_method, diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index e170dd222e..87798b2ffa 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -579,6 +579,7 @@ pub async fn run_main_with_transport_options( })? } }; + config.validate_windows_mxc_requirement()?; config.auth_config().validate()?; let auth_manager = AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false) diff --git a/codex-rs/app-server/src/request_processors/config_processor.rs b/codex-rs/app-server/src/request_processors/config_processor.rs index 914abf4fd8..1343329ae1 100644 --- a/codex-rs/app-server/src/request_processors/config_processor.rs +++ b/codex-rs/app-server/src/request_processors/config_processor.rs @@ -1246,6 +1246,7 @@ client_id = "mcp-client" codex_config::WindowsSandboxImplementationToml::Unelevated, ]), allow_mxc: None, + require_mxc: None, }), ..ConfigRequirementsToml::default() }); diff --git a/codex-rs/app-server/tests/suite/v2/account.rs b/codex-rs/app-server/tests/suite/v2/account.rs index 6b06bbb7a8..94a3eb2e45 100644 --- a/codex-rs/app-server/tests/suite/v2/account.rs +++ b/codex-rs/app-server/tests/suite/v2/account.rs @@ -413,10 +413,30 @@ async fn logout_account_succeeds_when_config_reload_fails() -> Result<()> { Ok(()) } +#[test_case(false, false; "local_auth_before_cloud")] +#[test_case(true, false; "local_mxc_after_cloud_selection")] +#[test_case(false, true; "cloud_mxc_without_default_recovery")] #[tokio::test] -async fn startup_enforces_local_auth_requirements_before_cloud_fetch() -> Result<()> { +async fn startup_enforces_requirements_at_the_correct_load_stage( + local_mxc: bool, + cloud_mxc: bool, +) -> Result<()> { let codex_home = TempDir::new()?; let mock_server = MockServer::start().await; + let require_mxc = "[windows]\nrequire_mxc = true\n"; + let fetch_cloud = local_mxc || cloud_mxc; + if fetch_cloud { + let bundle = codex_config::test_support::CloudConfigBundleFixture::enterprise_requirement( + if cloud_mxc { require_mxc } else { "" }, + ) + .add_enterprise_config(format!("[features]\nprefer_mxc = {local_mxc}\n")) + .into_bundle(); + Mock::given(path("/backend-api/wham/config/bundle")) + .respond_with(ResponseTemplate::new(200).set_body_json(bundle)) + .expect(1..) + .mount(&mock_server) + .await; + } create_config_toml( codex_home.path(), CreateConfigTomlParams { @@ -426,7 +446,13 @@ async fn startup_enforces_local_auth_requirements_before_cloud_fetch() -> Result )?; std::fs::write( codex_home.path().join("requirements.toml"), - "allowed_login_methods = [\"api\"]\n", + if local_mxc { + require_mxc + } else if cloud_mxc { + "" + } else { + "allowed_login_methods = [\"api\"]\n" + }, )?; write_chatgpt_auth( codex_home.path(), @@ -441,20 +467,30 @@ async fn startup_enforces_local_auth_requirements_before_cloud_fetch() -> Result let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) .without_auto_env() - .build_initialized_with_timeout(DEFAULT_READ_TIMEOUT) + .build() .await?; - assert!( + if cfg!(windows) && fetch_cloud && (!local_mxc || !codex_sandboxing::windows_mxc_available()) { + assert!( + !timeout(DEFAULT_READ_TIMEOUT, mcp.wait_for_exit()) + .await?? + .success() + ); + } else { + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + if !fetch_cloud { + assert_eq!(read_account(&mut mcp).await?.account, None); + } + } + assert_eq!( mock_server .received_requests() .await .expect("recorded requests") .is_empty(), - "disallowed ChatGPT auth must not fetch cloud requirements" + !fetch_cloud, ); - assert_eq!(read_account(&mut mcp).await?.account, None); - Ok(()) } diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs index e4cf88b4ff..c0efcdb1f6 100644 --- a/codex-rs/cli/src/debug_sandbox.rs +++ b/codex-rs/cli/src/debug_sandbox.rs @@ -754,7 +754,9 @@ async fn build_debug_sandbox_config_with_loader_overrides( .codex_home(codex_home.clone()) .fallback_cwd(Some(codex_home)); } - builder.build().await + let config = builder.build().await?; + config.validate_windows_mxc_requirement()?; + Ok(config) } fn config_uses_permission_profiles(config: &Config) -> bool { diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index 250b3b3c15..464c55e94a 100644 --- a/codex-rs/config/src/config_requirements.rs +++ b/codex-rs/config/src/config_requirements.rs @@ -872,6 +872,8 @@ pub struct WindowsRequirementsToml { pub allowed_sandbox_implementations: Option>, /// False blocks both explicit MXC configuration and automatic selection. pub allow_mxc: Option, + /// Require MXC as the selected local Windows backend. + pub require_mxc: Option, } #[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq)] @@ -883,7 +885,9 @@ pub enum WindowsSandboxImplementationToml { impl WindowsRequirementsToml { pub fn is_empty(&self) -> bool { - self.allowed_sandbox_implementations.is_none() && self.allow_mxc.is_none() + self.allowed_sandbox_implementations.is_none() + && self.allow_mxc.is_none() + && self.require_mxc.is_none() } } @@ -1927,6 +1931,7 @@ impl TryFrom for ConfigRequirements { WindowsRequirementsToml { allowed_sandbox_implementations: Some(implementations), allow_mxc: _, + require_mxc: _, }, source: requirement_source, }) => { @@ -1973,6 +1978,7 @@ impl TryFrom for ConfigRequirements { WindowsRequirementsToml { allowed_sandbox_implementations: None, allow_mxc: _, + require_mxc: _, }, .. }) @@ -2850,6 +2856,7 @@ mod tests { let windows = WindowsRequirementsToml { allowed_sandbox_implementations: Some(vec![WindowsSandboxImplementationToml::Elevated]), allow_mxc: None, + require_mxc: Some(true), }; let enforce_residency = ResidencyRequirement::Us; let enforce_source = source.clone(); @@ -3804,6 +3811,9 @@ allowed_approvals_reviewers = ["user"] #[test] fn deserialize_allowed_windows_sandbox_implementations() -> Result<()> { + let requirements: ConfigRequirementsToml = from_str("[windows]\nrequire_mxc = true")?; + assert!(!requirements.is_empty()); + let toml_str = r#" [windows] allowed_sandbox_implementations = ["elevated"] diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 2046b5e98c..4edb8adaa8 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -11094,8 +11094,12 @@ async fn explicit_sandbox_mode_falls_back_when_disallowed_by_requirements() -> s Ok(()) } +#[test_case::test_case(false; "optional")] +#[test_case::test_case(true; "required")] #[tokio::test] -async fn local_mxc_preference_preserves_configured_backend() -> anyhow::Result<()> { +async fn local_mxc_preference_preserves_configured_backend( + require_mxc: bool, +) -> anyhow::Result<()> { use codex_sandboxing::SandboxType::WindowsMxc; use codex_sandboxing::SandboxType::WindowsRestrictedToken as RestrictedToken; @@ -11122,16 +11126,53 @@ async fn local_mxc_preference_preserves_configured_backend() -> anyhow::Result<( codex_home.path().join(CONFIG_TOML_FILE), toml::to_string(&cfg)?, )?; - let mut config = ConfigBuilder::without_managed_config_for_tests() + let loaded = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) .cloud_config_bundle( CloudConfigBundleFixture::loader_with_enterprise_requirement(format!( - "[windows]\nallow_mxc = {allow_mxc}\n" + "[windows]\nallow_mxc = {allow_mxc}\nrequire_mxc = {require_mxc}\n" )), ) .build() .await?; + let mut config = Config::rebuild_with_session_layers( + &loaded.config_layer_stack, + loaded.cwd.to_path_buf(), + &loaded.config_layer_stack, + loaded.codex_home.clone(), + /*default_zsh_path*/ None, + ) + .await?; + let mut caller_config = config.clone(); + caller_config.config_layer_stack = ConfigLayerStack::default(); + caller_config.prefer_mxc = false; + caller_config.permissions.windows_sandbox_type = RestrictedToken; + assert_eq!( + caller_config + .validate_windows_mxc_requirement_from(&config) + .is_err(), + cfg!(windows) && require_mxc, + ); + let result = config.validate_windows_mxc_requirement(); + caller_config.config_layer_stack = config.config_layer_stack.clone(); + assert_eq!( + config + .resolve_runtime_refresh(&caller_config, RuntimeConfigRefresh::Mcp) + .is_err(), + result.is_err(), + ); + if cfg!(windows) + && require_mxc + && mode != "mxc" + && !(prefer && binding && allow_mxc && codex_sandboxing::windows_mxc_available()) + { + let error = result.expect_err("MXC must already be selected"); + assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput); + assert!(error.to_string().contains("windows.require_mxc = true")); + continue; + } + result?; assert_eq!( config_allows_mxc( &config diff --git a/codex-rs/core/src/config/runtime_refresh.rs b/codex-rs/core/src/config/runtime_refresh.rs index 995117dedf..abcf4fbcae 100644 --- a/codex-rs/core/src/config/runtime_refresh.rs +++ b/codex-rs/core/src/config/runtime_refresh.rs @@ -1,5 +1,6 @@ //! Resolves refreshable MCP inputs without rebuilding unrelated session configuration. //! Existing sessions refresh ordinary MCP state without gaining enterprise authority. +//! Validate the retained backend after applying the refreshable requirements. use crate::config::Config; use crate::config::ManagedFeatures; @@ -168,6 +169,7 @@ impl Config { if enterprise_retired { config.disable_mcp_enterprise_auth(); } + config.validate_windows_mxc_requirement()?; Ok(config) } diff --git a/codex-rs/core/src/config/windows_sandbox_config.rs b/codex-rs/core/src/config/windows_sandbox_config.rs index 8df89df767..0b681f1d8a 100644 --- a/codex-rs/core/src/config/windows_sandbox_config.rs +++ b/codex-rs/core/src/config/windows_sandbox_config.rs @@ -1,6 +1,7 @@ //! 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. +//! Validate MXC requirements only after bootstrap has loaded the complete config. use super::Config; use super::EffectivePermissionSelection; @@ -19,6 +20,35 @@ use codex_protocol::models::PermissionProfile; use codex_sandboxing::SandboxType; impl Config { + /// Reject a non-MXC selection without changing the final configuration. + /// Call after cloud configuration loads and outside config-recovery fallbacks. + pub fn validate_windows_mxc_requirement(&self) -> std::io::Result<()> { + self.validate_windows_mxc_requirement_from(self) + } + + /// Check this execution config against separately loaded managed requirements. + pub fn validate_windows_mxc_requirement_from( + &self, + policy_config: &Self, + ) -> std::io::Result<()> { + if cfg!(windows) + && policy_config + .config_layer_stack + .requirements_toml() + .windows + .as_ref() + .and_then(|windows| windows.require_mxc) + == Some(true) + && self.effective_local_windows_sandbox_type() != SandboxType::WindowsMxc + { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "windows.require_mxc = true requires MXC to be selected on this Windows host", + )); + } + Ok(()) + } + /// 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 diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 4714566430..0c667d1be5 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -553,6 +553,7 @@ impl Session { } async fn spawn_internal(args: SessionSpawnArgs) -> CodexResult<(Arc, SessionIo)> { + args.config.validate_windows_mxc_requirement()?; let SessionSpawnArgs { startup, config, diff --git a/codex-rs/tui/src/debug_config.rs b/codex-rs/tui/src/debug_config.rs index 2840e99609..f1fc03a58f 100644 --- a/codex-rs/tui/src/debug_config.rs +++ b/codex-rs/tui/src/debug_config.rs @@ -975,6 +975,7 @@ interrupt_message = false windows: Some(WindowsRequirementsToml { allowed_sandbox_implementations: None, allow_mxc: None, + require_mxc: None, }), additional_developer_instructions: None, guardian_policy_config: Some("Use the managed guardian policy.".to_string()),