diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index 938d539a9f69..df833986601c 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -372,6 +372,7 @@ pub async fn start(args: InProcessStartArgs) -> IoResult } async fn start_uninitialized(args: InProcessStartArgs) -> IoResult { + crate::enforce_auth_restrictions(&args.config).await; let channel_capacity = args.channel_capacity.max(1); let installation_id = resolve_installation_id(&args.config.codex_home).await?; let (client_tx, mut client_rx) = mpsc::channel::(channel_capacity); diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 3b02d7e3d979..76b02558bed0 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -739,6 +739,7 @@ pub async fn run_main_with_transport_options( } drop(unix_socket_startup_lock); + enforce_auth_restrictions(&config).await; let auth_manager = AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false).await; @@ -1171,6 +1172,12 @@ pub async fn run_main_with_transport_options( Ok(()) } +async fn enforce_auth_restrictions(config: &Config) { + if let Err(err) = codex_login::enforce_login_restrictions(&config.auth_config()).await { + tracing::warn!("applied auth restrictions after logging out: {err}"); + } +} + struct SqliteRecoveryNotice { details: String, } diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 603810f24217..77c2c5249e4d 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -509,6 +509,7 @@ impl MessageProcessor { outgoing.clone(), config_manager.clone(), thread_manager.clone(), + account_processor.clone(), analytics_events_client, ); let external_agent_config_processor = ExternalAgentConfigRequestProcessor::new( diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 640e975a0cd2..b36689d47a4b 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -365,6 +365,7 @@ use codex_login::ServerOptions as LoginServerOptions; use codex_login::ShutdownHandle; use codex_login::auth::login_with_chatgpt_auth_tokens; use codex_login::complete_device_code_login; +use codex_login::enforce_login_restrictions; use codex_login::login_with_api_key; use codex_login::oauth_client_id; use codex_login::request_device_code; diff --git a/codex-rs/app-server/src/request_processors/account_processor.rs b/codex-rs/app-server/src/request_processors/account_processor.rs index 3dbef6773a68..9884baa75e3c 100644 --- a/codex-rs/app-server/src/request_processors/account_processor.rs +++ b/codex-rs/app-server/src/request_processors/account_processor.rs @@ -84,6 +84,21 @@ impl AccountRequestProcessor { } } + async fn load_latest_config(&self) -> Result { + self.config_manager + .load_latest_config(/*fallback_cwd*/ None) + .await + .map_err(|err| internal_error(format!("failed to reload auth configuration: {err}"))) + } + + pub(crate) async fn refresh_auth_restrictions(&self, config: &Config) -> std::io::Result<()> { + let result = enforce_login_restrictions(&config.auth_config()).await; + self.auth_manager + .set_forced_chatgpt_workspace_id(config.forced_chatgpt_workspace_id.clone()); + self.auth_manager.reload().await; + result + } + pub(crate) async fn login_account( &self, request_id: ConnectionRequestId, @@ -270,14 +285,12 @@ impl AccountRequestProcessor { &self, params: &LoginApiKeyParams, ) -> std::result::Result<(), JSONRPCErrorError> { + let config = self.load_latest_config().await?; if self.auth_manager.is_external_chatgpt_auth_active() { return Err(self.external_auth_active_error()); } - if matches!( - self.config.forced_login_method, - Some(ForcedLoginMethod::Chatgpt) - ) { + if matches!(config.forced_login_method, Some(ForcedLoginMethod::Chatgpt)) { return Err(invalid_request( "API key login is disabled. Use ChatGPT login instead.", )); @@ -292,10 +305,10 @@ impl AccountRequestProcessor { } match login_with_api_key( - &self.config.codex_home, + &config.codex_home, ¶ms.api_key, - self.config.cli_auth_credentials_store_mode, - self.config.auth_keyring_backend_kind(), + config.cli_auth_credentials_store_mode, + config.auth_keyring_backend_kind(), ) { Ok(()) => { self.auth_manager.reload().await; @@ -323,8 +336,8 @@ impl AccountRequestProcessor { async fn login_chatgpt_common( &self, codex_streamlined_login: bool, - ) -> std::result::Result { - let config = self.config.as_ref(); + ) -> std::result::Result<(LoginServerOptions, String), JSONRPCErrorError> { + let config = self.load_latest_config().await?; if self.auth_manager.is_external_chatgpt_auth_active() { return Err(self.external_auth_active_error()); @@ -358,7 +371,7 @@ impl AccountRequestProcessor { opts }; - Ok(opts) + Ok((opts, config.chatgpt_base_url)) } fn login_chatgpt_device_code_start_error(err: IoError) -> JSONRPCErrorError { @@ -383,7 +396,7 @@ impl AccountRequestProcessor { &self, codex_streamlined_login: bool, ) -> Result { - let opts = self.login_chatgpt_common(codex_streamlined_login).await?; + let (opts, chatgpt_base_url) = self.login_chatgpt_common(codex_streamlined_login).await?; let server = run_login_server(opts) .map_err(|err| internal_error(format!("failed to start login server: {err}")))?; let login_id = Uuid::new_v4(); @@ -404,7 +417,6 @@ impl AccountRequestProcessor { let outgoing_clone = self.outgoing.clone(); let config_manager = self.config_manager.clone(); let thread_manager = Arc::clone(&self.thread_manager); - let chatgpt_base_url = self.config.chatgpt_base_url.clone(); let active_login = self.active_login.clone(); let auth_url = server.auth_url.clone(); tokio::spawn(async move { @@ -454,7 +466,7 @@ impl AccountRequestProcessor { async fn login_chatgpt_device_code_response( &self, ) -> Result { - let opts = self + let (opts, chatgpt_base_url) = self .login_chatgpt_common(/*codex_streamlined_login*/ false) .await?; let device_code = request_device_code(&opts) @@ -480,7 +492,6 @@ impl AccountRequestProcessor { let outgoing_clone = self.outgoing.clone(); let config_manager = self.config_manager.clone(); let thread_manager = Arc::clone(&self.thread_manager); - let chatgpt_base_url = self.config.chatgpt_base_url.clone(); let active_login = self.active_login.clone(); tokio::spawn(async move { let (success, error_msg) = tokio::select! { @@ -573,10 +584,8 @@ impl AccountRequestProcessor { chatgpt_account_id: String, chatgpt_plan_type: Option, ) -> Result { - if matches!( - self.config.forced_login_method, - Some(ForcedLoginMethod::Api) - ) { + let config = self.load_latest_config().await?; + if matches!(config.forced_login_method, Some(ForcedLoginMethod::Api)) { return Err(invalid_request( "External ChatGPT auth is disabled. Use API key login instead.", )); @@ -590,7 +599,7 @@ impl AccountRequestProcessor { } } - if let Some(expected_workspaces) = self.config.forced_chatgpt_workspace_id.as_deref() + if let Some(expected_workspaces) = config.forced_chatgpt_workspace_id.as_deref() && !expected_workspaces.contains(&chatgpt_account_id) { return Err(invalid_request(format!( @@ -599,7 +608,7 @@ impl AccountRequestProcessor { } login_with_chatgpt_auth_tokens( - &self.config.codex_home, + &config.codex_home, &access_token, &chatgpt_account_id, chatgpt_plan_type.as_deref(), @@ -608,7 +617,7 @@ impl AccountRequestProcessor { self.auth_manager.reload().await; self.config_manager.replace_cloud_config_bundle_loader( self.auth_manager.clone(), - self.config.chatgpt_base_url.clone(), + config.chatgpt_base_url.clone(), ); self.config_manager .sync_default_client_residency_requirement() 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 2a0e09ef58c8..b9a3ad2c9adc 100644 --- a/codex-rs/app-server/src/request_processors/config_processor.rs +++ b/codex-rs/app-server/src/request_processors/config_processor.rs @@ -6,6 +6,7 @@ use crate::error_code::internal_error; use crate::error_code::invalid_request; use crate::outgoing_message::ConnectionRequestId; use crate::outgoing_message::OutgoingMessageSender; +use crate::request_processors::AccountRequestProcessor; use codex_analytics::AnalyticsEventsClient; use codex_app_server_protocol::ClientResponsePayload; use codex_app_server_protocol::ComputerUseRequirements; @@ -59,6 +60,7 @@ pub(crate) struct ConfigRequestProcessor { outgoing: Arc, config_manager: ConfigManager, thread_manager: Arc, + account_processor: AccountRequestProcessor, analytics_events_client: AnalyticsEventsClient, } @@ -67,12 +69,14 @@ impl ConfigRequestProcessor { outgoing: Arc, config_manager: ConfigManager, thread_manager: Arc, + account_processor: AccountRequestProcessor, analytics_events_client: AnalyticsEventsClient, ) -> Self { Self { outgoing, config_manager, thread_manager, + account_processor, analytics_events_client, } } @@ -282,6 +286,13 @@ impl ConfigRequestProcessor { return; } }; + if let Err(err) = self + .account_processor + .refresh_auth_restrictions(&next_config) + .await + { + tracing::warn!("applied updated auth restrictions after logging out: {err}"); + } let thread_ids = self.thread_manager.list_thread_ids().await; for thread_id in thread_ids { let Ok(thread) = self.thread_manager.get_thread(thread_id).await else { diff --git a/codex-rs/app-server/tests/suite/v2/account.rs b/codex-rs/app-server/tests/suite/v2/account.rs index aab0a357a0fa..09936d7ee848 100644 --- a/codex-rs/app-server/tests/suite/v2/account.rs +++ b/codex-rs/app-server/tests/suite/v2/account.rs @@ -17,6 +17,9 @@ use codex_app_server_protocol::CancelLoginAccountResponse; use codex_app_server_protocol::CancelLoginAccountStatus; use codex_app_server_protocol::ChatgptAuthTokensRefreshReason; use codex_app_server_protocol::ChatgptAuthTokensRefreshResponse; +use codex_app_server_protocol::ConfigBatchWriteParams; +use codex_app_server_protocol::ConfigEdit; +use codex_app_server_protocol::ConfigWriteResponse; use codex_app_server_protocol::GetAccountParams; use codex_app_server_protocol::GetAccountResponse; use codex_app_server_protocol::GetAuthStatusParams; @@ -27,11 +30,13 @@ use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::LoginAccountResponse; use codex_app_server_protocol::LogoutAccountResponse; +use codex_app_server_protocol::MergeStrategy; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::TurnCompletedNotification; use codex_app_server_protocol::TurnStatus; +use codex_app_server_protocol::WriteStatus; use codex_config::types::AuthCredentialsStoreMode; use codex_login::AuthKeyringBackendKind; use codex_login::CLIENT_ID_OVERRIDE_ENV_VAR; @@ -251,6 +256,97 @@ async fn logout_account_removes_auth_and_notifies() -> Result<()> { Ok(()) } +#[tokio::test] +async fn startup_removes_disallowed_stored_auth() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + CreateConfigTomlParams { + forced_method: Some("chatgpt".to_string()), + ..Default::default() + }, + )?; + login_with_api_key( + codex_home.path(), + "sk-test-key", + AuthCredentialsStoreMode::File, + AuthKeyringBackendKind::default(), + )?; + + let mut mcp = + TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + assert!(!codex_home.path().join("auth.json").exists()); + + let request_id = mcp + .send_get_account_request(GetAccountParams { + refresh_token: false, + }) + .await?; + let response = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: GetAccountResponse = to_response(response)?; + assert_eq!(response.account, None); + + Ok(()) +} + +#[tokio::test] +async fn config_reload_removes_newly_disallowed_auth() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), CreateConfigTomlParams::default())?; + login_with_api_key( + codex_home.path(), + "sk-test-key", + AuthCredentialsStoreMode::File, + AuthKeyringBackendKind::default(), + )?; + + let mut mcp = + TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_config_batch_write_request(ConfigBatchWriteParams { + file_path: None, + edits: vec![ConfigEdit { + key_path: "forced_login_method".to_string(), + value: json!("chatgpt"), + merge_strategy: MergeStrategy::Replace, + }], + expected_version: None, + reload_user_config: true, + }) + .await?; + let response = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: ConfigWriteResponse = to_response(response)?; + assert_eq!(response.status, WriteStatus::Ok); + assert!(!codex_home.path().join("auth.json").exists()); + + let request_id = mcp + .send_get_account_request(GetAccountParams { + refresh_token: false, + }) + .await?; + let response = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: GetAccountResponse = to_response(response)?; + assert_eq!(response.account, None); + + Ok(()) +} + #[tokio::test] async fn set_auth_token_updates_account_and_notifies() -> Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index bf761bc8444a..12164550052b 100644 --- a/codex-rs/config/src/config_requirements.rs +++ b/codex-rs/config/src/config_requirements.rs @@ -20,6 +20,7 @@ use crate::ConstraintError; use crate::ManagedHooksRequirementsToml; use crate::mcp_types::AppToolApproval; use crate::permissions_toml::PermissionProfileToml; +use crate::types::AuthCredentialsStoreMode; use crate::types::FeedbackConfigToml; use crate::types::WindowsSandboxModeToml; @@ -143,6 +144,10 @@ impl std::ops::DerefMut for ConstrainedWithSource { /// normalization. #[derive(Debug, Clone, PartialEq)] pub struct ConfigRequirements { + pub allowed_login_methods: Option>>, + pub allowed_chatgpt_workspaces: Option>>, + pub cli_auth_credentials_store: Option>, + pub chatgpt_base_url: Option>, pub sqlite_home: Option>, pub log_dir: Option>, pub model_catalog_json: Option>, @@ -176,6 +181,10 @@ pub struct ConfigRequirements { impl Default for ConfigRequirements { fn default() -> Self { Self { + allowed_login_methods: None, + allowed_chatgpt_workspaces: None, + cli_auth_credentials_store: None, + chatgpt_base_url: None, sqlite_home: None, log_dir: None, model_catalog_json: None, @@ -837,6 +846,10 @@ pub(crate) fn merge_app_requirements_descending( /// Base config deserialized from system `requirements.toml` or MDM. #[derive(Deserialize, Debug, Clone, Default, PartialEq)] pub struct ConfigRequirementsToml { + pub allowed_login_methods: Option>, + pub allowed_chatgpt_workspaces: Option>, + pub cli_auth_credentials_store: Option, + pub chatgpt_base_url: Option, pub sqlite_home: Option, pub log_dir: Option, pub model_catalog_json: Option, @@ -899,6 +912,10 @@ impl std::ops::Deref for Sourced { #[derive(Debug, Clone, Default, PartialEq)] pub struct ConfigRequirementsWithSources { + pub allowed_login_methods: Option>>, + pub allowed_chatgpt_workspaces: Option>>, + pub cli_auth_credentials_store: Option>, + pub chatgpt_base_url: Option>, pub sqlite_home: Option>, pub log_dir: Option>, pub model_catalog_json: Option>, @@ -947,6 +964,10 @@ impl ConfigRequirementsWithSources { // Destructure without `..` so adding fields to `ConfigRequirementsToml` // forces this merge logic to be updated. let ConfigRequirementsToml { + allowed_login_methods: _, + allowed_chatgpt_workspaces: _, + cli_auth_credentials_store: _, + chatgpt_base_url: _, sqlite_home: _, log_dir: _, model_catalog_json: _, @@ -990,6 +1011,10 @@ impl ConfigRequirementsWithSources { other, source, { + allowed_login_methods, + allowed_chatgpt_workspaces, + cli_auth_credentials_store, + chatgpt_base_url, sqlite_home, log_dir, model_catalog_json, @@ -1030,6 +1055,10 @@ impl ConfigRequirementsWithSources { pub fn into_toml(self) -> ConfigRequirementsToml { let ConfigRequirementsWithSources { + allowed_login_methods, + allowed_chatgpt_workspaces, + cli_auth_credentials_store, + chatgpt_base_url, sqlite_home, log_dir, model_catalog_json, @@ -1059,6 +1088,10 @@ impl ConfigRequirementsWithSources { guardian_policy_config, } = self; ConfigRequirementsToml { + allowed_login_methods: allowed_login_methods.map(|sourced| sourced.value), + allowed_chatgpt_workspaces: allowed_chatgpt_workspaces.map(|sourced| sourced.value), + cli_auth_credentials_store: cli_auth_credentials_store.map(|sourced| sourced.value), + chatgpt_base_url: chatgpt_base_url.map(|sourced| sourced.value), sqlite_home: sqlite_home.map(|sourced| sourced.value), log_dir: log_dir.map(|sourced| sourced.value), model_catalog_json: model_catalog_json.map(|sourced| sourced.value), @@ -1155,7 +1188,11 @@ impl ConfigRequirementsToml { } pub fn is_empty(&self) -> bool { - self.sqlite_home.is_none() + self.allowed_login_methods.is_none() + && self.allowed_chatgpt_workspaces.is_none() + && self.cli_auth_credentials_store.is_none() + && self.chatgpt_base_url.is_none() + && self.sqlite_home.is_none() && self.log_dir.is_none() && self.model_catalog_json.is_none() && self.check_for_update_on_startup.is_none() @@ -1218,6 +1255,10 @@ impl TryFrom for ConfigRequirements { // config loading and requirements API projection. The normalized // constraints below only need the compiled PermissionProfile envelope. let ConfigRequirementsWithSources { + allowed_login_methods, + allowed_chatgpt_workspaces, + cli_auth_credentials_store, + chatgpt_base_url, sqlite_home, log_dir, model_catalog_json, @@ -1247,6 +1288,37 @@ impl TryFrom for ConfigRequirements { guardian_policy_config, } = toml; + if let Some(Sourced { + value: methods, + source, + }) = &allowed_login_methods + && let Some(unknown) = methods + .keys() + .find(|method| method.as_str() != "api" && method.as_str() != "chatgpt") + { + return Err(ConstraintError::InvalidValue { + field_name: "allowed_login_methods", + candidate: unknown.clone(), + allowed: "[\"api\", \"chatgpt\"]".to_string(), + requirement_source: source.clone(), + }); + } + if let Some(Sourced { + value: workspaces, + source, + }) = &allowed_chatgpt_workspaces + && let Some(empty) = workspaces + .keys() + .find(|workspace| workspace.trim().is_empty()) + { + return Err(ConstraintError::InvalidValue { + field_name: "allowed_chatgpt_workspaces", + candidate: empty.clone(), + allowed: "non-empty workspace identifiers".to_string(), + requirement_source: source.clone(), + }); + } + let approval_policy = match allowed_approval_policies { Some(Sourced { value: policies, @@ -1522,6 +1594,10 @@ impl TryFrom for ConfigRequirements { }); let guardian_policy_config_source = guardian_policy_config.map(|sourced| sourced.source); Ok(ConfigRequirements { + allowed_login_methods, + allowed_chatgpt_workspaces, + cli_auth_credentials_store, + chatgpt_base_url, sqlite_home, log_dir, model_catalog_json, @@ -1619,6 +1695,10 @@ mod tests { fn with_unknown_source(toml: ConfigRequirementsToml) -> ConfigRequirementsWithSources { let ConfigRequirementsToml { + allowed_login_methods, + allowed_chatgpt_workspaces, + cli_auth_credentials_store, + chatgpt_base_url, sqlite_home, log_dir, model_catalog_json, @@ -1649,6 +1729,14 @@ mod tests { guardian_policy_config, } = toml; ConfigRequirementsWithSources { + allowed_login_methods: allowed_login_methods + .map(|value| Sourced::new(value, RequirementSource::Unknown)), + allowed_chatgpt_workspaces: allowed_chatgpt_workspaces + .map(|value| Sourced::new(value, RequirementSource::Unknown)), + cli_auth_credentials_store: cli_auth_credentials_store + .map(|value| Sourced::new(value, RequirementSource::Unknown)), + chatgpt_base_url: chatgpt_base_url + .map(|value| Sourced::new(value, RequirementSource::Unknown)), sqlite_home: sqlite_home.map(|value| Sourced::new(value, RequirementSource::Unknown)), log_dir: log_dir.map(|value| Sourced::new(value, RequirementSource::Unknown)), model_catalog_json: model_catalog_json @@ -3628,4 +3716,50 @@ command = "python3 /enterprise/hooks/pre.py" Ok(()) } + + #[test] + fn normalize_rejects_unknown_login_method() -> Result<()> { + let source = RequirementSource::Unknown; + let requirements = ConfigRequirementsWithSources { + allowed_login_methods: Some(Sourced::new( + BTreeMap::from([("sso".to_string(), true)]), + source.clone(), + )), + ..Default::default() + }; + + assert_eq!( + ConfigRequirements::try_from(requirements), + Err(ConstraintError::InvalidValue { + field_name: "allowed_login_methods", + candidate: "sso".to_string(), + allowed: "[\"api\", \"chatgpt\"]".to_string(), + requirement_source: source, + }) + ); + Ok(()) + } + + #[test] + fn normalize_rejects_blank_chatgpt_workspace_id() -> Result<()> { + let source = RequirementSource::Unknown; + let requirements = ConfigRequirementsWithSources { + allowed_chatgpt_workspaces: Some(Sourced::new( + BTreeMap::from([(" ".to_string(), true)]), + source.clone(), + )), + ..Default::default() + }; + + assert_eq!( + ConfigRequirements::try_from(requirements), + Err(ConstraintError::InvalidValue { + field_name: "allowed_chatgpt_workspaces", + candidate: " ".to_string(), + allowed: "non-empty workspace identifiers".to_string(), + requirement_source: source, + }) + ); + Ok(()) + } } diff --git a/codex-rs/config/src/requirements_layers/stack.rs b/codex-rs/config/src/requirements_layers/stack.rs index b76762dbd71c..fd28cf7c96ab 100644 --- a/codex-rs/config/src/requirements_layers/stack.rs +++ b/codex-rs/config/src/requirements_layers/stack.rs @@ -173,6 +173,10 @@ fn populate_merged_regular_fields_with_sources( // Destructure without `..` so every new requirements field must choose // whether it belongs in the regular TOML merge path or in a special merger. let ConfigRequirementsToml { + allowed_login_methods, + allowed_chatgpt_workspaces, + cli_auth_credentials_store, + chatgpt_base_url, sqlite_home, log_dir, model_catalog_json, @@ -203,6 +207,10 @@ fn populate_merged_regular_fields_with_sources( guardian_policy_config, } = requirements; + set_sourced!(allowed_login_methods, &["allowed_login_methods"]); + set_sourced!(allowed_chatgpt_workspaces, &["allowed_chatgpt_workspaces"]); + set_sourced!(cli_auth_credentials_store, &["cli_auth_credentials_store"]); + set_sourced!(chatgpt_base_url, &["chatgpt_base_url"]); set_sourced!(sqlite_home, &["sqlite_home"]); set_sourced!(log_dir, &["log_dir"]); set_sourced!(model_catalog_json, &["model_catalog_json"]); diff --git a/codex-rs/config/src/requirements_layers/stack_tests.rs b/codex-rs/config/src/requirements_layers/stack_tests.rs index 7be51e2b33cc..80bdfc27e384 100644 --- a/codex-rs/config/src/requirements_layers/stack_tests.rs +++ b/codex-rs/config/src/requirements_layers/stack_tests.rs @@ -949,3 +949,45 @@ fn parse_error_names_layer() { assert!(err.to_string().contains("Bad layer (req_bad)")); assert!(err.to_string().contains("allowed_approval_policies")); } + +#[test] +fn managed_config_table_is_composed_and_sourced() { + let high_source = RequirementSource::EnterpriseManaged { + id: "req_high".to_string(), + name: "High".to_string(), + }; + let low_source = RequirementSource::EnterpriseManaged { + id: "req_low".to_string(), + name: "Low".to_string(), + }; + let composed = compose_requirements_for_hostname( + vec![ + RequirementsLayerEntry::from_toml( + low_source.clone(), + r#" +[allowed_login_methods] +chatgpt = true +api = true +"#, + ), + RequirementsLayerEntry::from_toml( + high_source.clone(), + r#" +[allowed_login_methods] +api = false +"#, + ), + ], + /*hostname*/ None, + ) + .expect("compose requirements") + .expect("requirements present"); + + assert_eq!( + composed.allowed_login_methods, + Some(Sourced::new( + BTreeMap::from([("api".to_string(), false), ("chatgpt".to_string(), true),]), + RequirementSource::composite([high_source, low_source]), + )) + ); +} diff --git a/codex-rs/config/src/types.rs b/codex-rs/config/src/types.rs index dc68290e27d1..f5f935dd64c6 100644 --- a/codex-rs/config/src/types.rs +++ b/codex-rs/config/src/types.rs @@ -13,6 +13,7 @@ pub use crate::mcp_types::McpServerTransportConfig; pub use crate::mcp_types::RawMcpServerConfig; pub use codex_protocol::config_types::AltScreenMode; pub use codex_protocol::config_types::ApprovalsReviewer; +pub use codex_protocol::config_types::AuthCredentialsStoreMode; use codex_protocol::config_types::EnvironmentVariablePattern; pub use codex_protocol::config_types::ModeKind; pub use codex_protocol::config_types::Personality; @@ -84,21 +85,6 @@ impl fmt::Display for SessionPickerViewMode { } } -/// Determine where Codex should store CLI auth credentials. -#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "lowercase")] -pub enum AuthCredentialsStoreMode { - #[default] - /// Persist credentials in CODEX_HOME/auth.json. - File, - /// Persist credentials in the keyring. Fail if unavailable. - Keyring, - /// Use keyring when available; otherwise, fall back to a file in CODEX_HOME. - Auto, - /// Store credentials in memory only for the current process. - Ephemeral, -} - /// Determine where Codex should store and read MCP credentials. #[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "lowercase")] diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 9554cdcbac18..c2d365df22aa 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -281,24 +281,24 @@ ] }, "AuthCredentialsStoreMode": { - "description": "Determine where Codex should store CLI auth credentials.", + "description": "Determines where Codex stores CLI authentication credentials.", "oneOf": [ { - "description": "Persist credentials in CODEX_HOME/auth.json.", + "description": "Persist credentials in `CODEX_HOME/auth.json`.", "enum": [ "file" ], "type": "string" }, { - "description": "Persist credentials in the keyring. Fail if unavailable.", + "description": "Persist credentials in the operating system keyring. Fail if unavailable.", "enum": [ "keyring" ], "type": "string" }, { - "description": "Use keyring when available; otherwise, fall back to a file in CODEX_HOME.", + "description": "Use the operating system keyring when available; otherwise, fall back to `CODEX_HOME/auth.json`.", "enum": [ "auto" ], @@ -5295,4 +5295,4 @@ }, "title": "ConfigToml", "type": "object" -} \ No newline at end of file +} diff --git a/codex-rs/core/src/config/auth_keyring.rs b/codex-rs/core/src/config/auth_keyring.rs index e6a1a94f5cb3..671f3e397430 100644 --- a/codex-rs/core/src/config/auth_keyring.rs +++ b/codex-rs/core/src/config/auth_keyring.rs @@ -1,11 +1,14 @@ use super::Config; use super::ConfigTomlLoadResult; use super::ManagedFeatures; +use codex_config::config_toml::ForcedChatgptWorkspaceIds; use codex_config::types::AuthKeyringBackendKind; use codex_features::Feature; use codex_features::FeatureConfigSource; use codex_features::FeatureOverrides; use codex_features::Features; +use codex_login::AuthConfig; +use std::path::Path; impl Config { pub fn auth_keyring_backend_kind(&self) -> AuthKeyringBackendKind { @@ -13,6 +16,35 @@ impl Config { self.features.enabled(Feature::SecretAuthStorage), ) } + + pub fn auth_config(&self) -> AuthConfig { + AuthConfig { + codex_home: self.codex_home.to_path_buf(), + auth_credentials_store_mode: self.cli_auth_credentials_store_mode, + keyring_backend_kind: self.auth_keyring_backend_kind(), + forced_login_method: self.forced_login_method, + chatgpt_base_url: Some(self.chatgpt_base_url.clone()), + forced_chatgpt_workspace_id: self.forced_chatgpt_workspace_id.clone(), + } + } +} + +pub fn bootstrap_auth_config( + codex_home: &Path, + bootstrap_config: &ConfigTomlLoadResult, +) -> std::io::Result { + let config = &bootstrap_config.config_toml; + Ok(AuthConfig { + codex_home: codex_home.to_path_buf(), + auth_credentials_store_mode: config.cli_auth_credentials_store.unwrap_or_default(), + keyring_backend_kind: resolve_bootstrap_auth_keyring_backend_kind(bootstrap_config)?, + forced_login_method: config.forced_login_method, + chatgpt_base_url: config.chatgpt_base_url.clone(), + forced_chatgpt_workspace_id: config + .forced_chatgpt_workspace_id + .clone() + .map(ForcedChatgptWorkspaceIds::into_vec), + }) } /// Resolve the auth keyring backend from a partially loaded bootstrap config. diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index e0e29094eb60..c1e0bdcb1f03 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -8330,6 +8330,10 @@ async fn test_requirements_web_search_mode_allowlist_does_not_warn_when_unset() let fixture = create_test_fixture()?; let requirements_toml = codex_config::ConfigRequirementsToml { + allowed_login_methods: None, + allowed_chatgpt_workspaces: None, + cli_auth_credentials_store: None, + chatgpt_base_url: None, sqlite_home: None, log_dir: None, model_catalog_json: None, @@ -10943,3 +10947,104 @@ async fn absent_allow_login_shell_does_not_report_an_override() -> std::io::Resu ); Ok(()) } + +#[tokio::test] +async fn conflicting_login_method_requirements_fail_closed() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + "forced_login_method = \"api\"\n", + )?; + let err = + load_with_enterprise_requirement(&codex_home, "[allowed_login_methods]\nchatgpt = true\n") + .await + .expect_err("conflicting login restrictions should fail closed"); + + assert_eq!(err.kind(), ErrorKind::InvalidInput); + assert!( + err.to_string() + .contains("conflicts with `allowed_login_methods`") + ); + Ok(()) +} + +#[tokio::test] +async fn workspace_requirements_can_disable_all_chatgpt_workspaces() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let config = load_with_enterprise_requirement( + &codex_home, + r#" +[allowed_chatgpt_workspaces] +"A" = false +"#, + ) + .await?; + + assert_eq!(config.forced_chatgpt_workspace_id, Some(Vec::new())); + Ok(()) +} + +#[tokio::test] +async fn bootstrap_config_applies_local_auth_requirements() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let requirements_path = codex_home.path().join("requirements.toml"); + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + r#" +cli_auth_credentials_store = "file" +chatgpt_base_url = "https://user.example/backend-api" +"#, + )?; + std::fs::write( + &requirements_path, + r#" +cli_auth_credentials_store = "keyring" +chatgpt_base_url = "https://managed.example/backend-api" + +[allowed_login_methods] +chatgpt = true +api = false + +[allowed_chatgpt_workspaces] +"123e4567-e89b-42d3-a456-426614174000" = true + +[features] +secret_auth_storage = true +"#, + )?; + let mut loader_overrides = LoaderOverrides::without_managed_config_for_tests(); + loader_overrides.system_requirements_path = Some(requirements_path); + + let bootstrap_config = load_bootstrap_config_toml_with_layer_stack( + codex_home.path(), + Some(&codex_home.path().abs()), + Vec::new(), + ConfigLoadOptions { + loader_overrides, + ..Default::default() + }, + ) + .await?; + let auth_keyring_backend_kind = resolve_bootstrap_auth_keyring_backend_kind(&bootstrap_config)?; + + assert_eq!( + ( + bootstrap_config.config_toml.cli_auth_credentials_store, + bootstrap_config.config_toml.chatgpt_base_url, + bootstrap_config.config_toml.forced_login_method, + bootstrap_config + .config_toml + .forced_chatgpt_workspace_id + .map(codex_config::config_toml::ForcedChatgptWorkspaceIds::into_vec), + auth_keyring_backend_kind, + ), + ( + Some(AuthCredentialsStoreMode::Keyring), + Some("https://managed.example/backend-api".to_string()), + Some(ForcedLoginMethod::Chatgpt), + Some(vec!["123e4567-e89b-42d3-a456-426614174000".to_string()]), + AuthKeyringBackendKind::Secrets, + ) + ); + Ok(()) +} diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 8ba4437851dd..30348b2a20a2 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -25,6 +25,7 @@ use codex_config::ThreadConfigLoader; use codex_config::config_toml::ConfigLockfileToml; use codex_config::config_toml::ConfigToml; use codex_config::config_toml::DEFAULT_PROJECT_DOC_MAX_BYTES; +use codex_config::config_toml::ForcedChatgptWorkspaceIds; use codex_config::config_toml::ProjectConfig; use codex_config::config_toml::RealtimeAudioConfig; use codex_config::config_toml::RealtimeConfig; @@ -149,6 +150,7 @@ mod requirements; mod resolved_permission_profile; #[cfg(test)] mod schema; +pub use auth_keyring::bootstrap_auth_config; pub use auth_keyring::resolve_bootstrap_auth_keyring_backend_kind; pub use codex_config::ConfigLoadOptions; pub use codex_config::Constrained; @@ -1738,6 +1740,34 @@ pub async fn load_config_toml_with_layer_stack( }) } +/// Load the bootstrap config together with its layer stack, applying exact +/// auth requirements needed before cloud requirements can be fetched. +pub async fn load_bootstrap_config_toml_with_layer_stack( + codex_home: &Path, + cwd: Option<&AbsolutePathBuf>, + cli_overrides: Vec<(String, TomlValue)>, + options: impl Into, +) -> std::io::Result { + let mut result = + load_config_toml_with_layer_stack(codex_home, cwd, cli_overrides, options).await?; + let requirements = result.config_layer_stack.requirements_toml(); + if let Some(required) = requirements.cli_auth_credentials_store { + result.config_toml.cli_auth_credentials_store = Some(required); + } + if let Some(required) = requirements.chatgpt_base_url.as_ref() { + result.config_toml.chatgpt_base_url = Some(required.clone()); + } + let (forced_login_method, forced_chatgpt_workspace_id) = + requirements::resolve_auth_restrictions( + &result.config_toml, + result.config_layer_stack.requirements(), + )?; + result.config_toml.forced_login_method = forced_login_method; + result.config_toml.forced_chatgpt_workspace_id = + forced_chatgpt_workspace_id.map(ForcedChatgptWorkspaceIds::Multiple); + Ok(result) +} + pub fn deserialize_config_toml_with_base( root_value: TomlValue, config_base_dir: &Path, @@ -2663,7 +2693,11 @@ impl Config { .startup_warnings() .unwrap_or_default() .to_vec(); - requirements::apply_to_config( + let requirements::AppliedConfigRequirements { + cli_auth_credentials_store_mode, + forced_login_method, + forced_chatgpt_workspace_id, + } = requirements::apply_to_config( &mut cfg, config_layer_stack.requirements(), &mut startup_warnings, @@ -2672,6 +2706,10 @@ impl Config { // Destructure every field to ensure ConfigRequirements additions are // either applied above or handled while constructing the final Config. let ConfigRequirements { + allowed_login_methods: _, + allowed_chatgpt_workspaces: _, + cli_auth_credentials_store: _, + chatgpt_base_url: _, sqlite_home: _, log_dir: _, model_catalog_json: _, @@ -3274,21 +3312,6 @@ impl Config { let use_experimental_unified_exec_tool = features.enabled(Feature::UnifiedExec); - let forced_chatgpt_workspace_id = cfg - .forced_chatgpt_workspace_id - .clone() - .map(codex_config::config_toml::ForcedChatgptWorkspaceIds::into_vec) - .map(|values| { - values - .into_iter() - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) - .collect::>() - }) - .filter(|values| !values.is_empty()); - - let forced_login_method = cfg.forced_login_method; - let model = model.or(cfg.model); let notices = cfg.notice.unwrap_or_default(); let service_tier = match service_tier_override { @@ -3547,10 +3570,7 @@ impl Config { include_environment_context, // The config.toml omits "_mode" because it's a config file. However, "_mode" // is important in code to differentiate the mode from the store implementation. - cli_auth_credentials_store_mode: resolve_cli_auth_credentials_store_mode( - cfg.cli_auth_credentials_store.unwrap_or_default(), - env!("CARGO_PKG_VERSION"), - ), + cli_auth_credentials_store_mode, mcp_servers, // The config.toml omits "_mode" because it's a config file. However, "_mode" // is important in code to differentiate the mode from the store implementation. diff --git a/codex-rs/core/src/config/requirements.rs b/codex-rs/core/src/config/requirements.rs index 21eb64b5a435..0c7204fb657d 100644 --- a/codex-rs/core/src/config/requirements.rs +++ b/codex-rs/core/src/config/requirements.rs @@ -2,7 +2,16 @@ use codex_config::ConfigRequirements; use codex_config::RequirementSource; use codex_config::Sourced; use codex_config::config_toml::ConfigToml; +use codex_config::types::AuthCredentialsStoreMode; use codex_config::types::FeedbackConfigToml; +use codex_protocol::config_types::ForcedLoginMethod; + +/// Runtime values that cannot be applied by mutating [`ConfigToml`] alone. +pub(super) struct AppliedConfigRequirements { + pub cli_auth_credentials_store_mode: AuthCredentialsStoreMode, + pub forced_login_method: Option, + pub forced_chatgpt_workspace_id: Option>, +} /// Applies managed requirements to regular config before final config construction. /// @@ -12,7 +21,7 @@ pub(super) fn apply_to_config( config: &mut ConfigToml, requirements: &ConfigRequirements, startup_warnings: &mut Vec, -) -> std::io::Result<()> { +) -> std::io::Result { macro_rules! apply_exact { ($field:ident) => { apply_exact_requirement( @@ -24,6 +33,8 @@ pub(super) fn apply_to_config( }; } + apply_exact!(cli_auth_credentials_store); + apply_exact!(chatgpt_base_url); apply_exact!(sqlite_home); apply_exact!(log_dir); apply_exact!(model_catalog_json); @@ -46,7 +57,23 @@ pub(super) fn apply_to_config( ); } - Ok(()) + let (forced_login_method, forced_chatgpt_workspace_id) = + resolve_auth_restrictions(config, requirements)?; + let configured_auth_credentials_store = config.cli_auth_credentials_store.unwrap_or_default(); + let cli_auth_credentials_store_mode = if requirements.cli_auth_credentials_store.is_some() { + configured_auth_credentials_store + } else { + super::resolve_cli_auth_credentials_store_mode( + configured_auth_credentials_store, + env!("CARGO_PKG_VERSION"), + ) + }; + + Ok(AppliedConfigRequirements { + cli_auth_credentials_store_mode, + forced_login_method, + forced_chatgpt_workspace_id, + }) } fn apply_exact_requirement( @@ -123,3 +150,74 @@ pub(super) fn push_structured_requirement_override_warning( "Configured values under `{field_name}` are overridden by requirements from {source}." )); } + +pub(super) fn resolve_auth_restrictions( + config: &ConfigToml, + requirements: &ConfigRequirements, +) -> std::io::Result<(Option, Option>)> { + let configured_forced_chatgpt_workspace_id = config + .forced_chatgpt_workspace_id + .clone() + .map(codex_config::config_toml::ForcedChatgptWorkspaceIds::into_vec) + .map(|values| { + values + .into_iter() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .collect::>() + }) + .filter(|values| !values.is_empty()); + let forced_chatgpt_workspace_id = match requirements.allowed_chatgpt_workspaces.as_ref() { + Some(requirement) => Some(match configured_forced_chatgpt_workspace_id { + Some(configured) => configured + .into_iter() + .filter(|workspace| requirement.value.get(workspace).copied().unwrap_or(false)) + .collect(), + None => requirement + .value + .iter() + .filter_map(|(workspace, allowed)| allowed.then_some(workspace.clone())) + .collect(), + }), + None => configured_forced_chatgpt_workspace_id, + }; + + let forced_login_method = match requirements.allowed_login_methods.as_ref() { + Some(requirement) => { + let chatgpt_allowed = requirement.value.get("chatgpt").copied().unwrap_or(false); + let api_allowed = requirement.value.get("api").copied().unwrap_or(false); + match config.forced_login_method { + Some(ForcedLoginMethod::Chatgpt) if chatgpt_allowed => { + Some(ForcedLoginMethod::Chatgpt) + } + Some(ForcedLoginMethod::Api) if api_allowed => Some(ForcedLoginMethod::Api), + Some(configured) => { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!( + "configured `forced_login_method = \"{configured}\"` conflicts with `allowed_login_methods` from {}", + requirement.source + ), + )); + } + None => match (chatgpt_allowed, api_allowed) { + (true, true) => None, + (true, false) => Some(ForcedLoginMethod::Chatgpt), + (false, true) => Some(ForcedLoginMethod::Api), + (false, false) => { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!( + "`allowed_login_methods` from {} does not allow any login method", + requirement.source + ), + )); + } + }, + } + } + None => config.forced_login_method, + }; + + Ok((forced_login_method, forced_chatgpt_workspace_id)) +} diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index f8ac851bf97c..eb84b6339e96 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -63,8 +63,9 @@ use codex_core::config::Config; use codex_core::config::ConfigBuilder; use codex_core::config::ConfigOverrides; use codex_core::config::ConfigTomlLoadResult; +use codex_core::config::bootstrap_auth_config; use codex_core::config::find_codex_home; -use codex_core::config::load_config_toml_with_layer_stack; +use codex_core::config::load_bootstrap_config_toml_with_layer_stack; use codex_core::config::resolve_bootstrap_auth_keyring_backend_kind; use codex_core::config::resolve_oss_provider; use codex_core::config::resolve_profile_v2_config_path; @@ -344,6 +345,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result ) .await; let bootstrap_config_toml = &bootstrap_config.config_toml; + enforce_login_restrictions(&bootstrap_auth_config(&codex_home, &bootstrap_config)?).await?; let chatgpt_base_url = bootstrap_config_toml .chatgpt_base_url @@ -623,7 +625,7 @@ async fn load_bootstrap_config_or_exit( strict_config: bool, cloud_config_bundle: CloudConfigBundleLoader, ) -> ConfigTomlLoadResult { - match load_config_toml_with_layer_stack( + match load_bootstrap_config_toml_with_layer_stack( codex_home, cwd, cli_kv_overrides, diff --git a/codex-rs/protocol/src/config_types.rs b/codex-rs/protocol/src/config_types.rs index 4e54301d91ca..051fd2e68252 100644 --- a/codex-rs/protocol/src/config_types.rs +++ b/codex-rs/protocol/src/config_types.rs @@ -21,6 +21,23 @@ use wildmatch::WildMatchPattern; use crate::openai_models::ReasoningEffort; +/// Determines where Codex stores CLI authentication credentials. +#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export_to = "v2/")] +pub enum AuthCredentialsStoreMode { + /// Persist credentials in `CODEX_HOME/auth.json`. + #[default] + File, + /// Persist credentials in the operating system keyring. Fail if unavailable. + Keyring, + /// Use the operating system keyring when available; otherwise, fall back to + /// `CODEX_HOME/auth.json`. + Auto, + /// Store credentials in memory only for the current process. + Ephemeral, +} + /// Selects which part of the active context is charged against /// `model_auto_compact_token_limit`. #[derive( diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index f092a92fd7ab..e4f5b4d1a479 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -8,7 +8,8 @@ use crate::legacy_core::config::Config; use crate::legacy_core::config::ConfigBuilder; use crate::legacy_core::config::ConfigOverrides; use crate::legacy_core::config::ConfigTomlLoadResult; -use crate::legacy_core::config::load_config_toml_with_layer_stack; +use crate::legacy_core::config::bootstrap_auth_config; +use crate::legacy_core::config::load_bootstrap_config_toml_with_layer_stack; use crate::legacy_core::config::resolve_bootstrap_auth_keyring_backend_kind; use crate::legacy_core::config::resolve_oss_provider; use crate::legacy_core::config::resolve_profile_v2_config_path; @@ -951,6 +952,7 @@ pub async fn run_main( ) .await; let bootstrap_config_toml = &bootstrap_config.config_toml; + enforce_login_restrictions(&bootstrap_auth_config(&codex_home, &bootstrap_config)?).await?; let chatgpt_base_url = bootstrap_config_toml .chatgpt_base_url @@ -1938,7 +1940,7 @@ async fn load_bootstrap_config_or_exit( strict_config: bool, cloud_config_bundle: CloudConfigBundleLoader, ) -> ConfigTomlLoadResult { - match load_config_toml_with_layer_stack( + match load_bootstrap_config_toml_with_layer_stack( codex_home, cwd, cli_kv_overrides, diff --git a/codex-rs/tui/src/session_archive_commands.rs b/codex-rs/tui/src/session_archive_commands.rs index 852aaad5ce58..cd542de2c565 100644 --- a/codex-rs/tui/src/session_archive_commands.rs +++ b/codex-rs/tui/src/session_archive_commands.rs @@ -11,7 +11,8 @@ use crate::Cli; use crate::app_server_session::AppServerSession; use crate::legacy_core::config::ConfigBuilder; use crate::legacy_core::config::ConfigOverrides; -use crate::legacy_core::config::load_config_toml_with_layer_stack; +use crate::legacy_core::config::bootstrap_auth_config; +use crate::legacy_core::config::load_bootstrap_config_toml_with_layer_stack; use crate::legacy_core::config::resolve_bootstrap_auth_keyring_backend_kind; use crate::legacy_core::config::resolve_oss_provider; use crate::legacy_core::config::resolve_profile_v2_config_path; @@ -25,6 +26,7 @@ use codex_config::ConfigLoadOptions; use codex_config::LoaderOverrides; use codex_exec_server::EnvironmentManager; use codex_exec_server::ExecServerRuntimePaths; +use codex_login::enforce_login_restrictions; use codex_protocol::ThreadId; use codex_utils_cli::CliConfigOverrides; use codex_utils_home_dir::find_codex_home; @@ -312,7 +314,7 @@ async fn start_app_server_for_archive_command( loader_overrides.user_config_profile = Some(profile_v2.clone()); } - let bootstrap_config = load_config_toml_with_layer_stack( + let bootstrap_config = load_bootstrap_config_toml_with_layer_stack( codex_home.as_path(), config_cwd.as_ref(), cli_kv_overrides.clone(), @@ -324,6 +326,11 @@ async fn start_app_server_for_archive_command( ) .await .wrap_err("failed to load config.toml")?; + enforce_login_restrictions(&bootstrap_auth_config( + codex_home.as_path(), + &bootstrap_config, + )?) + .await?; let config_toml = &bootstrap_config.config_toml; let chatgpt_base_url = config_toml .chatgpt_base_url