From 3cd059585ef483f5be60be2836efbeefd7bede73 Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Mon, 15 Jun 2026 23:14:18 +0000 Subject: [PATCH] Enforce managed authentication during bootstrap --- codex-rs/config/src/config_requirements.rs | 132 +++++++++++++++++- .../config/src/requirements_layers/stack.rs | 8 ++ .../src/requirements_layers/stack_tests.rs | 42 ++++++ codex-rs/config/src/types.rs | 16 +-- codex-rs/core/config.schema.json | 10 +- codex-rs/core/src/config/config_tests.rs | 91 ++++++++++++ codex-rs/core/src/config/mod.rs | 50 ++++--- codex-rs/core/src/config/requirements.rs | 102 +++++++++++++- codex-rs/exec/src/lib.rs | 4 +- codex-rs/protocol/src/config_types.rs | 17 +++ codex-rs/tui/src/lib.rs | 4 +- codex-rs/tui/src/session_archive_commands.rs | 4 +- 12 files changed, 431 insertions(+), 49 deletions(-) diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index bf761bc844..5f379ab097 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,46 @@ command = "python3 /enterprise/hooks/pre.py" Ok(()) } + + #[test] + fn normalize_rejects_unknown_login_method() -> Result<()> { + let source = RequirementSource::Unknown; + let mut requirements = ConfigRequirementsWithSources::default(); + requirements.allowed_login_methods = Some(Sourced::new( + BTreeMap::from([("sso".to_string(), true)]), + source.clone(), + )); + + 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 mut requirements = ConfigRequirementsWithSources::default(); + requirements.allowed_chatgpt_workspaces = Some(Sourced::new( + BTreeMap::from([(" ".to_string(), true)]), + source.clone(), + )); + + 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 b76762dbd7..fd28cf7c96 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 7be51e2b33..80bdfc27e3 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 dc68290e27..f5f935dd64 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 9554cdcbac..c2d365df22 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/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index e0e29094eb..1ac18c80e0 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,90 @@ 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" + +[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, + auth_keyring_backend_kind, + ), + ( + Some(AuthCredentialsStoreMode::Keyring), + Some("https://managed.example/backend-api".to_string()), + AuthKeyringBackendKind::Secrets, + ) + ); + Ok(()) +} diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 8ba4437851..5bba15ea78 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -1738,6 +1738,26 @@ 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()); + } + Ok(result) +} + pub fn deserialize_config_toml_with_base( root_value: TomlValue, config_base_dir: &Path, @@ -2663,7 +2683,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 +2696,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 +3302,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 +3560,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 21eb64b5a4..144a6d7518 100644 --- a/codex-rs/core/src/config/requirements.rs +++ b/codex-rs/core/src/config/requirements.rs @@ -3,6 +3,15 @@ use codex_config::RequirementSource; use codex_config::Sourced; use codex_config::config_toml::ConfigToml; use codex_config::types::FeedbackConfigToml; +use codex_config::types::AuthCredentialsStoreMode; +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}." )); } + +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 f8ac851bf9..a8100f4ae0 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -64,7 +64,7 @@ use codex_core::config::ConfigBuilder; use codex_core::config::ConfigOverrides; use codex_core::config::ConfigTomlLoadResult; 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; @@ -623,7 +623,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 4e54301d91..051fd2e682 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 f092a92fd7..593d142186 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -8,7 +8,7 @@ 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::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; @@ -1938,7 +1938,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 852aaad5ce..e1aca68d33 100644 --- a/codex-rs/tui/src/session_archive_commands.rs +++ b/codex-rs/tui/src/session_archive_commands.rs @@ -11,7 +11,7 @@ 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::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; @@ -312,7 +312,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(),