diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index ee7d7fcb1..b5d2a1440 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3199,6 +3199,7 @@ dependencies = [ "codex-model-provider-info", "codex-otel", "codex-protocol", + "codex-secrets", "codex-terminal-detection", "codex-utils-template", "core_test_support", diff --git a/codex-rs/app-server-transport/src/transport/remote_control/tests.rs b/codex-rs/app-server-transport/src/transport/remote_control/tests.rs index ecc9acafa..03bb1efaf 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/tests.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/tests.rs @@ -30,6 +30,7 @@ use codex_config::types::AuthCredentialsStoreMode; use codex_core::test_support::auth_manager_from_auth; use codex_core::test_support::auth_manager_from_auth_with_home; use codex_login::AuthDotJson; +use codex_login::AuthKeyringBackendKind; use codex_login::AuthManager; use codex_login::CodexAuth; use codex_login::save_auth; @@ -933,6 +934,7 @@ async fn remote_control_start_allows_missing_auth_when_enabled() { /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, + AuthKeyringBackendKind::default(), ) .await; let (transport_event_tx, _transport_event_rx) = @@ -1744,6 +1746,7 @@ async fn remote_control_waits_for_account_id_before_enrolling() { codex_home.path(), &remote_control_auth_dot_json(/*account_id*/ None), AuthCredentialsStoreMode::File, + AuthKeyringBackendKind::default(), ) .expect("auth without account id should save"); let state_db = remote_control_state_runtime(&codex_home).await; @@ -1752,6 +1755,7 @@ async fn remote_control_waits_for_account_id_before_enrolling() { /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, + AuthKeyringBackendKind::default(), ) .await; let expected_server_name = gethostname().to_string_lossy().trim().to_string(); @@ -1793,6 +1797,7 @@ async fn remote_control_waits_for_account_id_before_enrolling() { codex_home.path(), &remote_control_auth_dot_json(Some("account_id")), AuthCredentialsStoreMode::File, + AuthKeyringBackendKind::default(), ) .expect("auth with account id should save"); auth_manager.reload().await; @@ -1835,6 +1840,7 @@ async fn persisted_enable_does_not_follow_auth_to_an_account_without_a_preferenc codex_home.path(), &remote_control_auth_dot_json(Some("account_a")), AuthCredentialsStoreMode::File, + AuthKeyringBackendKind::default(), ) .expect("account A auth should save"); let state_db = remote_control_state_runtime(&codex_home).await; @@ -1843,6 +1849,7 @@ async fn persisted_enable_does_not_follow_auth_to_an_account_without_a_preferenc /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, + AuthKeyringBackendKind::default(), ) .await; let remote_control_target = @@ -1906,6 +1913,7 @@ async fn persisted_enable_does_not_follow_auth_to_an_account_without_a_preferenc codex_home.path(), &remote_control_auth_dot_json(Some("account_b")), AuthCredentialsStoreMode::File, + AuthKeyringBackendKind::default(), ) .expect("account B auth should save"); auth_manager.reload().await; diff --git a/codex-rs/app-server-transport/src/transport/remote_control/tests/clients_tests.rs b/codex-rs/app-server-transport/src/transport/remote_control/tests/clients_tests.rs index fe4408a84..21e06a6d5 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/tests/clients_tests.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/tests/clients_tests.rs @@ -7,6 +7,7 @@ use codex_app_server_protocol::RemoteControlClientsListParams; use codex_app_server_protocol::RemoteControlClientsListResponse; use codex_app_server_protocol::RemoteControlClientsRevokeParams; use codex_app_server_protocol::RemoteControlClientsRevokeResponse; +use codex_login::AuthKeyringBackendKind; use pretty_assertions::assert_eq; fn client_management_handle( @@ -173,6 +174,7 @@ async fn list_remote_control_clients_recovers_auth_after_unauthorized() { codex_home.path(), &stale_auth, AuthCredentialsStoreMode::File, + AuthKeyringBackendKind::default(), ) .expect("stale auth should save"); let auth_manager = AuthManager::shared( @@ -180,6 +182,7 @@ async fn list_remote_control_clients_recovers_auth_after_unauthorized() { /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, + AuthKeyringBackendKind::default(), ) .await; let mut fresh_auth = remote_control_auth_dot_json(Some("account_id")); @@ -192,6 +195,7 @@ async fn list_remote_control_clients_recovers_auth_after_unauthorized() { codex_home.path(), &fresh_auth, AuthCredentialsStoreMode::File, + AuthKeyringBackendKind::default(), ) .expect("fresh auth should save"); @@ -254,6 +258,7 @@ async fn list_remote_control_clients_retries_unauthorized_only_once() { codex_home.path(), &stale_auth, AuthCredentialsStoreMode::File, + AuthKeyringBackendKind::default(), ) .expect("stale auth should save"); let auth_manager = AuthManager::shared( @@ -261,6 +266,7 @@ async fn list_remote_control_clients_retries_unauthorized_only_once() { /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, + AuthKeyringBackendKind::default(), ) .await; let mut fresh_auth = remote_control_auth_dot_json(Some("account_id")); @@ -273,6 +279,7 @@ async fn list_remote_control_clients_retries_unauthorized_only_once() { codex_home.path(), &fresh_auth, AuthCredentialsStoreMode::File, + AuthKeyringBackendKind::default(), ) .expect("fresh auth should save"); diff --git a/codex-rs/app-server-transport/src/transport/remote_control/tests/pairing_tests.rs b/codex-rs/app-server-transport/src/transport/remote_control/tests/pairing_tests.rs index 4dbb4c6d4..ff146492f 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/tests/pairing_tests.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/tests/pairing_tests.rs @@ -1,6 +1,7 @@ use super::super::protocol::RemoteControlPairingStatusRequest; use super::super::protocol::StartRemoteControlPairingRequest; use super::*; +use codex_login::AuthKeyringBackendKind; use pretty_assertions::assert_eq; use std::io; @@ -528,6 +529,7 @@ async fn remote_control_handle_recovers_auth_before_refreshing_pairing() { codex_home.path(), &stale_auth, AuthCredentialsStoreMode::File, + AuthKeyringBackendKind::default(), ) .expect("stale auth should save"); let auth_manager = AuthManager::shared( @@ -535,6 +537,7 @@ async fn remote_control_handle_recovers_auth_before_refreshing_pairing() { /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, + AuthKeyringBackendKind::default(), ) .await; let mut fresh_auth = remote_control_auth_dot_json(Some("account_id")); @@ -547,6 +550,7 @@ async fn remote_control_handle_recovers_auth_before_refreshing_pairing() { codex_home.path(), &fresh_auth, AuthCredentialsStoreMode::File, + AuthKeyringBackendKind::default(), ) .expect("fresh auth should save"); let remote_handle = @@ -793,6 +797,7 @@ async fn remote_control_handle_discards_pairing_response_after_auth_change() { codex_home.path(), &remote_control_auth_dot_json(Some("account_id")), AuthCredentialsStoreMode::File, + AuthKeyringBackendKind::default(), ) .expect("initial auth should save"); let auth_manager = AuthManager::shared( @@ -800,6 +805,7 @@ async fn remote_control_handle_discards_pairing_response_after_auth_change() { /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, + AuthKeyringBackendKind::default(), ) .await; let remote_handle = @@ -821,6 +827,7 @@ async fn remote_control_handle_discards_pairing_response_after_auth_change() { codex_home.path(), &remote_control_auth_dot_json(Some("next_account_id")), AuthCredentialsStoreMode::File, + AuthKeyringBackendKind::default(), ) .expect("next auth should save"); auth_manager.reload().await; diff --git a/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs b/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs index 6d58b2862..3f594a1b4 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs @@ -1821,6 +1821,7 @@ mod tests { use codex_config::types::AuthCredentialsStoreMode; use codex_core::test_support::auth_manager_from_auth; use codex_login::AuthDotJson; + use codex_login::AuthKeyringBackendKind; use codex_login::CodexAuth; use codex_login::save_auth; use codex_login::token_data::TokenData; @@ -2202,6 +2203,7 @@ mod tests { codex_home.path(), &remote_control_auth_dot_json("stale-token"), AuthCredentialsStoreMode::File, + AuthKeyringBackendKind::default(), ) .expect("stale auth should save"); let state_db = remote_control_state_runtime(&codex_home).await; @@ -2210,6 +2212,7 @@ mod tests { /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, + AuthKeyringBackendKind::default(), ) .await; let mut auth_recovery = auth_manager.unauthorized_recovery(); @@ -2220,6 +2223,7 @@ mod tests { codex_home.path(), &remote_control_auth_dot_json("fresh-token"), AuthCredentialsStoreMode::File, + AuthKeyringBackendKind::default(), ) .expect("fresh auth should save"); @@ -2296,6 +2300,7 @@ mod tests { codex_home.path(), &remote_control_auth_dot_json("stale-token"), AuthCredentialsStoreMode::File, + AuthKeyringBackendKind::default(), ) .expect("stale auth should save"); let state_db = remote_control_state_runtime(&codex_home).await; @@ -2304,6 +2309,7 @@ mod tests { /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, + AuthKeyringBackendKind::default(), ) .await; let mut auth_recovery = auth_manager.unauthorized_recovery(); @@ -2316,6 +2322,7 @@ mod tests { codex_home.path(), &remote_control_auth_dot_json("fresh-token"), AuthCredentialsStoreMode::File, + AuthKeyringBackendKind::default(), ) .expect("fresh auth should save"); @@ -2424,6 +2431,7 @@ mod tests { /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, + AuthKeyringBackendKind::default(), ) .await; let mut auth_recovery = auth_manager.unauthorized_recovery(); 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 019533484..debb02a39 100644 --- a/codex-rs/app-server/src/request_processors/account_processor.rs +++ b/codex-rs/app-server/src/request_processors/account_processor.rs @@ -293,6 +293,7 @@ impl AccountRequestProcessor { &self.config.codex_home, ¶ms.api_key, self.config.cli_auth_credentials_store_mode, + self.config.auth_keyring_backend_kind(), ) { Ok(()) => { self.auth_manager.reload().await; @@ -341,6 +342,7 @@ impl AccountRequestProcessor { CLIENT_ID.to_string(), config.forced_chatgpt_workspace_id.clone(), config.cli_auth_credentials_store_mode, + config.auth_keyring_backend_kind(), ) }; #[cfg(debug_assertions)] diff --git a/codex-rs/app-server/tests/common/auth_fixtures.rs b/codex-rs/app-server/tests/common/auth_fixtures.rs index 18cfd7ed6..ed3fe923a 100644 --- a/codex-rs/app-server/tests/common/auth_fixtures.rs +++ b/codex-rs/app-server/tests/common/auth_fixtures.rs @@ -9,6 +9,7 @@ use chrono::Utc; use codex_app_server_protocol::AuthMode; use codex_config::types::AuthCredentialsStoreMode; use codex_login::AuthDotJson; +use codex_login::AuthKeyringBackendKind; use codex_login::save_auth; use codex_login::token_data::TokenData; use codex_login::token_data::parse_chatgpt_jwt_claims; @@ -168,5 +169,11 @@ pub fn write_chatgpt_auth( bedrock_api_key: None, }; - save_auth(codex_home, &auth, cli_auth_credentials_store_mode).context("write auth.json") + save_auth( + codex_home, + &auth, + cli_auth_credentials_store_mode, + AuthKeyringBackendKind::default(), + ) + .context("write auth.json") } diff --git a/codex-rs/app-server/tests/suite/v2/account.rs b/codex-rs/app-server/tests/suite/v2/account.rs index 1b95bc7d5..901182e1d 100644 --- a/codex-rs/app-server/tests/suite/v2/account.rs +++ b/codex-rs/app-server/tests/suite/v2/account.rs @@ -33,6 +33,7 @@ use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::TurnCompletedNotification; use codex_app_server_protocol::TurnStatus; use codex_config::types::AuthCredentialsStoreMode; +use codex_login::AuthKeyringBackendKind; use codex_login::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; use codex_login::login_with_api_key; use codex_protocol::account::PlanType as AccountPlanType; @@ -198,6 +199,7 @@ async fn logout_account_removes_auth_and_notifies() -> Result<()> { codex_home.path(), "sk-test-key", AuthCredentialsStoreMode::File, + AuthKeyringBackendKind::default(), )?; assert!(codex_home.path().join("auth.json").exists()); diff --git a/codex-rs/app-server/tests/suite/v2/app_list.rs b/codex-rs/app-server/tests/suite/v2/app_list.rs index fa4c2042a..d92ff0406 100644 --- a/codex-rs/app-server/tests/suite/v2/app_list.rs +++ b/codex-rs/app-server/tests/suite/v2/app_list.rs @@ -35,6 +35,7 @@ use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_config::types::AuthCredentialsStoreMode; use codex_login::AuthDotJson; +use codex_login::AuthKeyringBackendKind; use codex_login::save_auth; use pretty_assertions::assert_eq; use rmcp::handler::server::ServerHandler; @@ -122,6 +123,7 @@ async fn list_apps_returns_empty_with_api_key_auth() -> Result<()> { bedrock_api_key: None, }, AuthCredentialsStoreMode::File, + AuthKeyringBackendKind::default(), )?; let mut mcp = TestAppServer::new(codex_home.path()).await?; diff --git a/codex-rs/cli/src/doctor.rs b/codex-rs/cli/src/doctor.rs index 0b7e7cfe7..c0f310e70 100644 --- a/codex-rs/cli/src/doctor.rs +++ b/codex-rs/cli/src/doctor.rs @@ -1196,7 +1196,11 @@ fn auth_check(config: &Config) -> DoctorCheck { return check; } - match load_auth_dot_json(&config.codex_home, config.cli_auth_credentials_store_mode) { + match load_auth_dot_json( + &config.codex_home, + config.cli_auth_credentials_store_mode, + config.auth_keyring_backend_kind(), + ) { Ok(Some(auth)) => { details.push(format!("stored auth mode: {}", stored_auth_mode(&auth))); details.push(format!("stored API key: {}", auth.openai_api_key.is_some())); @@ -2528,10 +2532,13 @@ impl ProviderAuthReachabilityMode { } fn provider_reachability_plan(config: &Config) -> ReachabilityPlan { - let stored_auth = - load_auth_dot_json(&config.codex_home, config.cli_auth_credentials_store_mode) - .ok() - .flatten(); + let stored_auth = load_auth_dot_json( + &config.codex_home, + config.cli_auth_credentials_store_mode, + config.auth_keyring_backend_kind(), + ) + .ok() + .flatten(); let mode = provider_auth_reachability_mode_from_auth( config.model_provider.requires_openai_auth, env_var_present, diff --git a/codex-rs/cli/src/login.rs b/codex-rs/cli/src/login.rs index 44f91eb07..dc3d0af87 100644 --- a/codex-rs/cli/src/login.rs +++ b/codex-rs/cli/src/login.rs @@ -10,6 +10,7 @@ use codex_app_server_protocol::AuthMode; use codex_config::types::AuthCredentialsStoreMode; use codex_core::config::Config; +use codex_login::AuthKeyringBackendKind; use codex_login::CLIENT_ID; use codex_login::CodexAuth; use codex_login::ServerOptions; @@ -117,8 +118,15 @@ fn print_login_server_start(actual_port: u16, auth_url: &str) { async fn clear_existing_auth_before_login( codex_home: &Path, auth_credentials_store_mode: AuthCredentialsStoreMode, + auth_keyring_backend_kind: AuthKeyringBackendKind, ) { - if let Err(err) = logout_with_revoke(codex_home, auth_credentials_store_mode).await { + if let Err(err) = logout_with_revoke( + codex_home, + auth_credentials_store_mode, + auth_keyring_backend_kind, + ) + .await + { tracing::warn!("failed to clear existing auth before login: {err}"); } } @@ -127,14 +135,21 @@ pub async fn login_with_chatgpt( codex_home: PathBuf, forced_chatgpt_workspace_id: Option>, cli_auth_credentials_store_mode: AuthCredentialsStoreMode, + auth_keyring_backend_kind: AuthKeyringBackendKind, ) -> std::io::Result<()> { - clear_existing_auth_before_login(&codex_home, cli_auth_credentials_store_mode).await; + clear_existing_auth_before_login( + &codex_home, + cli_auth_credentials_store_mode, + auth_keyring_backend_kind, + ) + .await; let opts = ServerOptions::new( codex_home, CLIENT_ID.to_string(), forced_chatgpt_workspace_id, cli_auth_credentials_store_mode, + auth_keyring_backend_kind, ); let server = run_login_server(opts)?; @@ -159,6 +174,7 @@ pub async fn run_login_with_chatgpt(cli_config_overrides: CliConfigOverrides) -> config.codex_home.to_path_buf(), forced_chatgpt_workspace_id, config.cli_auth_credentials_store_mode, + config.auth_keyring_backend_kind(), ) .await { @@ -190,6 +206,7 @@ pub async fn run_login_with_api_key( &config.codex_home, &api_key, config.cli_auth_credentials_store_mode, + config.auth_keyring_backend_kind(), ) { Ok(_) => { eprintln!("{LOGIN_SUCCESS_MESSAGE}"); @@ -221,6 +238,7 @@ pub async fn run_login_with_access_token( config.cli_auth_credentials_store_mode, config.forced_chatgpt_workspace_id.as_deref(), Some(&config.chatgpt_base_url), + config.auth_keyring_backend_kind(), ) .await { @@ -289,14 +307,19 @@ pub async fn run_login_with_device_code( eprintln!("{CHATGPT_LOGIN_DISABLED_MESSAGE}"); std::process::exit(1); } - clear_existing_auth_before_login(&config.codex_home, config.cli_auth_credentials_store_mode) - .await; + clear_existing_auth_before_login( + &config.codex_home, + config.cli_auth_credentials_store_mode, + config.auth_keyring_backend_kind(), + ) + .await; let forced_chatgpt_workspace_id = config.forced_chatgpt_workspace_id.clone(); let mut opts = ServerOptions::new( config.codex_home.to_path_buf(), client_id.unwrap_or(CLIENT_ID.to_string()), forced_chatgpt_workspace_id, config.cli_auth_credentials_store_mode, + config.auth_keyring_backend_kind(), ); if let Some(iss) = issuer_base_url { opts.issuer = iss; @@ -329,8 +352,12 @@ pub async fn run_login_with_device_code_fallback_to_browser( eprintln!("{CHATGPT_LOGIN_DISABLED_MESSAGE}"); std::process::exit(1); } - clear_existing_auth_before_login(&config.codex_home, config.cli_auth_credentials_store_mode) - .await; + clear_existing_auth_before_login( + &config.codex_home, + config.cli_auth_credentials_store_mode, + config.auth_keyring_backend_kind(), + ) + .await; let forced_chatgpt_workspace_id = config.forced_chatgpt_workspace_id.clone(); let mut opts = ServerOptions::new( @@ -338,6 +365,7 @@ pub async fn run_login_with_device_code_fallback_to_browser( client_id.unwrap_or(CLIENT_ID.to_string()), forced_chatgpt_workspace_id, config.cli_auth_credentials_store_mode, + config.auth_keyring_backend_kind(), ); if let Some(iss) = issuer_base_url { opts.issuer = iss; @@ -386,6 +414,7 @@ pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! { &config.codex_home, config.cli_auth_credentials_store_mode, Some(&config.chatgpt_base_url), + config.auth_keyring_backend_kind(), ) .await { @@ -431,7 +460,13 @@ pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! { pub async fn run_logout(cli_config_overrides: CliConfigOverrides) -> ! { let config = load_config_or_exit(cli_config_overrides).await; - match logout_with_revoke(&config.codex_home, config.cli_auth_credentials_store_mode).await { + match logout_with_revoke( + &config.codex_home, + config.cli_auth_credentials_store_mode, + config.auth_keyring_backend_kind(), + ) + .await + { Ok(true) => { eprintln!("Successfully logged out"); std::process::exit(0); @@ -477,6 +512,7 @@ fn safe_format_key(key: &str) -> String { #[cfg(test)] mod tests { use codex_config::types::AuthCredentialsStoreMode; + use codex_login::AuthKeyringBackendKind; use codex_login::load_auth_dot_json; use codex_login::login_with_api_key; use pretty_assertions::assert_eq; @@ -492,13 +528,23 @@ mod tests { codex_home.path(), "sk-existing", AuthCredentialsStoreMode::File, + AuthKeyringBackendKind::default(), ) .expect("save existing auth"); - clear_existing_auth_before_login(codex_home.path(), AuthCredentialsStoreMode::File).await; + clear_existing_auth_before_login( + codex_home.path(), + AuthCredentialsStoreMode::File, + AuthKeyringBackendKind::default(), + ) + .await; - let auth = load_auth_dot_json(codex_home.path(), AuthCredentialsStoreMode::File) - .expect("load auth after cleanup"); + let auth = load_auth_dot_json( + codex_home.path(), + AuthCredentialsStoreMode::File, + AuthKeyringBackendKind::default(), + ) + .expect("load auth after cleanup"); assert_eq!(auth, None); } diff --git a/codex-rs/cloud-config/src/bundle_loader.rs b/codex-rs/cloud-config/src/bundle_loader.rs index c269460c1..2d76d29a6 100644 --- a/codex-rs/cloud-config/src/bundle_loader.rs +++ b/codex-rs/cloud-config/src/bundle_loader.rs @@ -5,6 +5,7 @@ use codex_config::CloudConfigBundleLoadError; use codex_config::CloudConfigBundleLoadErrorCode; use codex_config::CloudConfigBundleLoader; use codex_config::types::AuthCredentialsStoreMode; +use codex_login::AuthKeyringBackendKind; use codex_login::AuthManager; use std::path::PathBuf; use std::sync::Arc; @@ -55,6 +56,7 @@ pub async fn cloud_config_bundle_loader_for_storage( codex_home: PathBuf, enable_codex_api_key_env: bool, credentials_store_mode: AuthCredentialsStoreMode, + keyring_backend_kind: AuthKeyringBackendKind, chatgpt_base_url: String, ) -> CloudConfigBundleLoader { let auth_manager = AuthManager::shared( @@ -62,6 +64,7 @@ pub async fn cloud_config_bundle_loader_for_storage( enable_codex_api_key_env, credentials_store_mode, Some(chatgpt_base_url.clone()), + keyring_backend_kind, ) .await; cloud_config_bundle_loader(auth_manager, chatgpt_base_url, codex_home) diff --git a/codex-rs/cloud-config/src/service_tests.rs b/codex-rs/cloud-config/src/service_tests.rs index abedca4b0..d63fef4c5 100644 --- a/codex-rs/cloud-config/src/service_tests.rs +++ b/codex-rs/cloud-config/src/service_tests.rs @@ -16,6 +16,7 @@ use codex_config::CloudConfigTomlBundle; use codex_config::CloudRequirementsFragment; use codex_config::CloudRequirementsTomlBundle; use codex_config::types::AuthCredentialsStoreMode; +use codex_login::AuthKeyringBackendKind; use pretty_assertions::assert_eq; use serde_json::json; use std::collections::VecDeque; @@ -48,6 +49,7 @@ async fn auth_manager_with_api_key() -> Arc { /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, + AuthKeyringBackendKind::default(), ) .await, ) @@ -76,6 +78,7 @@ async fn auth_manager_with_plan_and_identity( /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, + AuthKeyringBackendKind::default(), ) .await, ) @@ -633,6 +636,7 @@ async fn get_bundle_recovers_after_unauthorized_reload() { /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, + AuthKeyringBackendKind::default(), ) .await, ); @@ -687,6 +691,7 @@ async fn get_bundle_recovers_after_unauthorized_reload_updates_cache_identity() /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, + AuthKeyringBackendKind::default(), ) .await, ); @@ -749,6 +754,7 @@ async fn get_bundle_surfaces_auth_recovery_message() { /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, + AuthKeyringBackendKind::default(), ) .await, ); @@ -813,6 +819,7 @@ async fn get_bundle_unauthorized_without_recovery_uses_generic_message() { /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, + AuthKeyringBackendKind::default(), ) .await, ); diff --git a/codex-rs/cloud-tasks/src/util.rs b/codex-rs/cloud-tasks/src/util.rs index 9a5056aa6..f683c58a4 100644 --- a/codex-rs/cloud-tasks/src/util.rs +++ b/codex-rs/cloud-tasks/src/util.rs @@ -49,7 +49,8 @@ pub async fn load_auth_manager(chatgpt_base_url: Option) -> Option AuthKeyringBackendKind { + Config::auth_keyring_backend_kind(self) + } + fn forced_chatgpt_workspace_id(&self) -> Option> { self.forced_chatgpt_workspace_id.clone() } diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 4545e711d..c41593f51 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -9,6 +9,7 @@ use codex_core::resolve_installation_id; use codex_core::thread_store_from_config; use codex_extension_api::empty_extension_registry; use codex_features::Feature; +use codex_login::AuthKeyringBackendKind; use codex_login::AuthManager; use codex_login::CodexAuth; use codex_login::default_client::originator; @@ -1178,6 +1179,7 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() { codex_home.path(), AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, + AuthKeyringBackendKind::default(), ) .await { diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 5dce65d71..6578523e9 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -62,8 +62,10 @@ use codex_core::check_execpolicy_for_warnings; use codex_core::config::Config; 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_as_toml_with_cli_and_load_options; +use codex_core::config::load_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; use codex_core::find_thread_meta_by_name_str; @@ -331,7 +333,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result ..Default::default() }; - let bootstrap_config_toml = load_config_toml_or_exit( + let bootstrap_config = load_bootstrap_config_or_exit( &codex_home, Some(&config_cwd), cli_kv_overrides.clone(), @@ -340,6 +342,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result CloudConfigBundleLoader::default(), ) .await; + let bootstrap_config_toml = &bootstrap_config.config_toml; let chatgpt_base_url = bootstrap_config_toml .chatgpt_base_url @@ -351,6 +354,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result bootstrap_config_toml .cli_auth_credentials_store .unwrap_or_default(), + resolve_bootstrap_auth_keyring_backend_kind(&bootstrap_config)?, chatgpt_base_url, ) .await; @@ -359,12 +363,12 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result let run_cloud_config_bundle = cloud_config_bundle.clone(); let model_provider = if oss { - let config_toml_with_cloud_config; + let bootstrap_config_with_cloud_config; let config_toml_for_oss = if oss_provider.is_none() { // The first load intentionally skips cloud config so we can read // auth/base-url settings needed to fetch the bundle. If OSS mode // needs a default provider from config, reload with the bundle. - config_toml_with_cloud_config = load_config_toml_or_exit( + bootstrap_config_with_cloud_config = load_bootstrap_config_or_exit( &codex_home, Some(&config_cwd), cli_kv_overrides.clone(), @@ -373,9 +377,9 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result cloud_config_bundle.clone(), ) .await; - &config_toml_with_cloud_config + &bootstrap_config_with_cloud_config.config_toml } else { - &bootstrap_config_toml + bootstrap_config_toml }; let resolved = resolve_oss_provider(oss_provider.as_deref(), config_toml_for_oss); @@ -466,6 +470,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result if let Err(err) = enforce_login_restrictions(&AuthConfig { codex_home: config.codex_home.to_path_buf(), auth_credentials_store_mode: config.cli_auth_credentials_store_mode, + keyring_backend_kind: config.auth_keyring_backend_kind(), forced_login_method: config.forced_login_method, forced_chatgpt_workspace_id: config.forced_chatgpt_workspace_id.clone(), chatgpt_base_url: Some(config.chatgpt_base_url.clone()), @@ -609,15 +614,15 @@ where } #[allow(clippy::print_stderr)] -async fn load_config_toml_or_exit( +async fn load_bootstrap_config_or_exit( codex_home: &Path, cwd: Option<&AbsolutePathBuf>, cli_kv_overrides: Vec<(String, codex_config::TomlValue)>, loader_overrides: LoaderOverrides, strict_config: bool, cloud_config_bundle: CloudConfigBundleLoader, -) -> codex_config::config_toml::ConfigToml { - match load_config_as_toml_with_cli_and_load_options( +) -> ConfigTomlLoadResult { + match load_config_toml_with_layer_stack( codex_home, cwd, cli_kv_overrides, diff --git a/codex-rs/login/Cargo.toml b/codex-rs/login/Cargo.toml index c6d52953f..d34e9d879 100644 --- a/codex-rs/login/Cargo.toml +++ b/codex-rs/login/Cargo.toml @@ -18,6 +18,7 @@ codex-keyring-store = { workspace = true } codex-model-provider-info = { workspace = true } codex-otel = { workspace = true } codex-protocol = { workspace = true } +codex-secrets = { workspace = true } codex-terminal-detection = { workspace = true } codex-utils-template = { workspace = true } once_cell = { workspace = true } diff --git a/codex-rs/login/src/auth/auth_tests.rs b/codex-rs/login/src/auth/auth_tests.rs index f64ea7a57..465dd6217 100644 --- a/codex-rs/login/src/auth/auth_tests.rs +++ b/codex-rs/login/src/auth/auth_tests.rs @@ -43,6 +43,7 @@ async fn refresh_without_id_token() { let storage = create_auth_storage( codex_home.path().to_path_buf(), AuthCredentialsStoreMode::File, + AuthKeyringBackendKind::default(), ); let updated = super::persist_tokens( &storage, @@ -77,8 +78,13 @@ fn login_with_api_key_overwrites_existing_auth_json() { ) .unwrap(); - super::login_with_api_key(dir.path(), "sk-new", AuthCredentialsStoreMode::File) - .expect("login_with_api_key should succeed"); + super::login_with_api_key( + dir.path(), + "sk-new", + AuthCredentialsStoreMode::File, + AuthKeyringBackendKind::default(), + ) + .expect("login_with_api_key should succeed"); let storage = FileAuthStorage::new(dir.path().to_path_buf()); let auth = storage @@ -110,6 +116,7 @@ async fn login_with_access_token_writes_only_token() { AuthCredentialsStoreMode::File, /*forced_chatgpt_workspace_id*/ None, Some(&chatgpt_base_url), + AuthKeyringBackendKind::default(), ) .await .expect("login_with_access_token should succeed"); @@ -152,6 +159,7 @@ async fn login_with_access_token_writes_only_personal_access_token() { AuthCredentialsStoreMode::File, Some(&allowed_workspaces), /*chatgpt_base_url*/ None, + AuthKeyringBackendKind::default(), ) .await .expect("personal access token login should succeed"); @@ -203,6 +211,7 @@ async fn login_with_access_token_rejects_personal_access_token_workspace_mismatc AuthCredentialsStoreMode::File, Some(&allowed_workspaces), /*chatgpt_base_url*/ None, + AuthKeyringBackendKind::default(), ) .await .expect_err("personal access token workspace mismatch should fail"); @@ -234,6 +243,7 @@ async fn login_with_access_token_rejects_invalid_personal_access_token() { AuthCredentialsStoreMode::File, /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, + AuthKeyringBackendKind::default(), ) .await .expect_err("invalid personal access token should fail"); @@ -256,6 +266,7 @@ async fn login_with_access_token_rejects_invalid_jwt() { AuthCredentialsStoreMode::File, /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, + AuthKeyringBackendKind::default(), ) .await .expect_err("invalid access token should fail"); @@ -287,6 +298,7 @@ async fn login_with_access_token_rejects_unsigned_jwt() { AuthCredentialsStoreMode::File, /*forced_chatgpt_workspace_id*/ None, Some(&chatgpt_base_url), + AuthKeyringBackendKind::default(), ) .await .expect_err("unsigned access token should fail"); @@ -307,6 +319,7 @@ async fn missing_auth_json_returns_none() { dir.path(), AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, + AuthKeyringBackendKind::default(), ) .await .expect("call should succeed"); @@ -334,6 +347,7 @@ async fn pro_account_with_no_api_key_uses_chatgpt_auth() { AuthCredentialsStoreMode::File, /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, + AuthKeyringBackendKind::Direct, ) .await .unwrap() @@ -393,6 +407,7 @@ async fn loads_api_key_from_auth_json() { AuthCredentialsStoreMode::File, /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, + AuthKeyringBackendKind::Direct, ) .await .unwrap() @@ -415,10 +430,19 @@ fn logout_removes_auth_file() -> Result<(), std::io::Error> { personal_access_token: None, bedrock_api_key: None, }; - super::save_auth(dir.path(), &auth_dot_json, AuthCredentialsStoreMode::File)?; + super::save_auth( + dir.path(), + &auth_dot_json, + AuthCredentialsStoreMode::File, + AuthKeyringBackendKind::default(), + )?; let auth_file = get_auth_file(dir.path()); assert!(auth_file.exists()); - assert!(logout(dir.path(), AuthCredentialsStoreMode::File)?); + assert!(logout( + dir.path(), + AuthCredentialsStoreMode::File, + AuthKeyringBackendKind::default(), + )?); assert!(!auth_file.exists()); Ok(()) } @@ -432,6 +456,7 @@ async fn unauthorized_recovery_reports_mode_and_step_names() { /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, + AuthKeyringBackendKind::default(), ) .await; let managed = UnauthorizedRecovery { @@ -474,6 +499,7 @@ async fn refresh_failure_is_scoped_to_the_matching_auth_snapshot() { AuthCredentialsStoreMode::File, /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, + AuthKeyringBackendKind::Direct, ) .await .expect("load auth") @@ -492,6 +518,7 @@ async fn refresh_failure_is_scoped_to_the_matching_auth_snapshot() { updated_auth_dot_json, AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, + AuthKeyringBackendKind::Direct, ) .await .expect("updated auth should parse"); @@ -793,6 +820,7 @@ async fn build_config( AuthConfig { codex_home: codex_home.to_path_buf(), auth_credentials_store_mode: AuthCredentialsStoreMode::File, + keyring_backend_kind: AuthKeyringBackendKind::Direct, forced_login_method, forced_chatgpt_workspace_id, chatgpt_base_url: None, @@ -876,6 +904,7 @@ async fn load_auth_reads_access_token_from_env() { AuthCredentialsStoreMode::File, /*forced_chatgpt_workspace_id*/ None, Some(&chatgpt_base_url), + AuthKeyringBackendKind::Direct, ) .await .expect("env auth should load") @@ -921,6 +950,7 @@ async fn load_auth_reads_personal_access_token_from_env() { auth_credentials_store_mode, /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, + AuthKeyringBackendKind::default(), ) .await .expect("env auth should load") @@ -974,6 +1004,7 @@ async fn auth_manager_rejects_env_personal_access_token_workspace_mismatch() { /*forced_chatgpt_workspace_id*/ Some(vec![WORKSPACE_ID_ALLOWED.to_string()]), /*chatgpt_base_url*/ None, + AuthKeyringBackendKind::default(), ) .await; @@ -1012,6 +1043,7 @@ async fn auth_manager_rejects_stored_personal_access_token_workspace_mismatch() auth_credentials_store_mode, /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, + AuthKeyringBackendKind::default(), ) .await .expect("personal access token login should succeed"); @@ -1023,6 +1055,7 @@ async fn auth_manager_rejects_stored_personal_access_token_workspace_mismatch() /*forced_chatgpt_workspace_id*/ Some(vec![WORKSPACE_ID_ALLOWED.to_string()]), /*chatgpt_base_url*/ None, + AuthKeyringBackendKind::default(), ) .await; @@ -1054,6 +1087,7 @@ async fn personal_access_token_does_not_offer_unauthorized_recovery() { /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, + AuthKeyringBackendKind::default(), ) .await, ); @@ -1084,6 +1118,7 @@ async fn load_auth_keeps_codex_api_key_env_precedence() { AuthCredentialsStoreMode::File, /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, + AuthKeyringBackendKind::Direct, ) .await .expect("env auth should load") @@ -1097,8 +1132,13 @@ async fn load_auth_keeps_codex_api_key_env_precedence() { async fn enforce_login_restrictions_logs_out_for_method_mismatch() { let codex_home = tempdir().unwrap(); let _access_token_guard = remove_access_token_env_var(); - login_with_api_key(codex_home.path(), "sk-test", AuthCredentialsStoreMode::File) - .expect("seed api key"); + login_with_api_key( + codex_home.path(), + "sk-test", + AuthCredentialsStoreMode::File, + AuthKeyringBackendKind::default(), + ) + .expect("seed api key"); let config = build_config( codex_home.path(), @@ -1174,6 +1214,7 @@ async fn enforce_login_restrictions_logs_out_for_personal_access_token_workspace AuthCredentialsStoreMode::File, /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, + AuthKeyringBackendKind::default(), ) .await .expect("personal access token login should succeed"); @@ -1181,6 +1222,7 @@ async fn enforce_login_restrictions_logs_out_for_personal_access_token_workspace let config = AuthConfig { codex_home: codex_home.path().to_path_buf(), auth_credentials_store_mode: AuthCredentialsStoreMode::File, + keyring_backend_kind: AuthKeyringBackendKind::default(), forced_login_method: None, forced_chatgpt_workspace_id: Some(vec![WORKSPACE_ID_ALLOWED.to_string()]), chatgpt_base_url: None, @@ -1297,12 +1339,14 @@ async fn enforce_login_restrictions_logs_out_for_agent_identity_workspace_mismat bedrock_api_key: None, }, AuthCredentialsStoreMode::File, + AuthKeyringBackendKind::default(), ) .expect("seed agent identity auth"); let config = AuthConfig { codex_home: codex_home.path().to_path_buf(), auth_credentials_store_mode: AuthCredentialsStoreMode::File, + keyring_backend_kind: AuthKeyringBackendKind::Direct, forced_login_method: None, forced_chatgpt_workspace_id: Some(vec![WORKSPACE_ID_ALLOWED.to_string()]), chatgpt_base_url: Some(chatgpt_base_url), @@ -1327,8 +1371,13 @@ async fn enforce_login_restrictions_allows_api_key_if_login_method_not_set_but_f { let codex_home = tempdir().unwrap(); let _access_token_guard = remove_access_token_env_var(); - login_with_api_key(codex_home.path(), "sk-test", AuthCredentialsStoreMode::File) - .expect("seed api key"); + login_with_api_key( + codex_home.path(), + "sk-test", + AuthCredentialsStoreMode::File, + AuthKeyringBackendKind::default(), + ) + .expect("seed api key"); let config = build_config( codex_home.path(), @@ -1553,6 +1602,7 @@ async fn plan_type_maps_known_plan() { AuthCredentialsStoreMode::File, /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, + AuthKeyringBackendKind::Direct, ) .await .expect("load auth") @@ -1582,6 +1632,7 @@ async fn plan_type_maps_self_serve_business_usage_based_plan() { AuthCredentialsStoreMode::File, /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, + AuthKeyringBackendKind::Direct, ) .await .expect("load auth") @@ -1614,6 +1665,7 @@ async fn plan_type_maps_enterprise_cbp_usage_based_plan() { AuthCredentialsStoreMode::File, /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, + AuthKeyringBackendKind::Direct, ) .await .expect("load auth") @@ -1646,6 +1698,7 @@ async fn plan_type_maps_unknown_to_unknown() { AuthCredentialsStoreMode::File, /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, + AuthKeyringBackendKind::Direct, ) .await .expect("load auth") @@ -1675,6 +1728,7 @@ async fn missing_plan_type_maps_to_unknown() { AuthCredentialsStoreMode::File, /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, + AuthKeyringBackendKind::Direct, ) .await .expect("load auth") diff --git a/codex-rs/login/src/auth/bedrock_api_key.rs b/codex-rs/login/src/auth/bedrock_api_key.rs index 803b7a01e..af3d16398 100644 --- a/codex-rs/login/src/auth/bedrock_api_key.rs +++ b/codex-rs/login/src/auth/bedrock_api_key.rs @@ -6,6 +6,7 @@ use serde::Serialize; use super::manager::save_auth; use super::storage::AuthDotJson; +use super::storage::AuthKeyringBackendKind; use codex_app_server_protocol::AuthMode; /// Managed Amazon Bedrock API key persisted in `auth.json`. @@ -21,6 +22,7 @@ pub fn login_with_bedrock_api_key( api_key: &str, region: &str, auth_credentials_store_mode: AuthCredentialsStoreMode, + keyring_backend_kind: AuthKeyringBackendKind, ) -> std::io::Result<()> { let auth_dot_json = AuthDotJson { auth_mode: Some(AuthMode::BedrockApiKey), @@ -34,7 +36,12 @@ pub fn login_with_bedrock_api_key( region: region.to_string(), }), }; - save_auth(codex_home, &auth_dot_json, auth_credentials_store_mode) + save_auth( + codex_home, + &auth_dot_json, + auth_credentials_store_mode, + keyring_backend_kind, + ) } #[cfg(test)] diff --git a/codex-rs/login/src/auth/bedrock_api_key_tests.rs b/codex-rs/login/src/auth/bedrock_api_key_tests.rs index b891edc3a..35ace9fea 100644 --- a/codex-rs/login/src/auth/bedrock_api_key_tests.rs +++ b/codex-rs/login/src/auth/bedrock_api_key_tests.rs @@ -5,6 +5,7 @@ use serial_test::serial; use tempfile::tempdir; use super::*; +use crate::auth::AuthKeyringBackendKind; use crate::auth::AuthManager; use crate::auth::CodexAuth; use crate::auth::storage::AuthStorageBackend; @@ -52,6 +53,7 @@ async fn login_with_bedrock_api_key_replaces_openai_auth() -> anyhow::Result<()> "bedrock-api-key-test", "us-east-1", AuthCredentialsStoreMode::File, + AuthKeyringBackendKind::default(), )?; let auth_manager = AuthManager::new( @@ -59,6 +61,7 @@ async fn login_with_bedrock_api_key_replaces_openai_auth() -> anyhow::Result<()> /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, + AuthKeyringBackendKind::default(), ) .await; @@ -98,12 +101,14 @@ async fn logout_removes_bedrock_auth() -> anyhow::Result<()> { "bedrock-api-key-test", "us-east-1", AuthCredentialsStoreMode::File, + AuthKeyringBackendKind::default(), )?; let auth_manager = AuthManager::new( codex_home.path().to_path_buf(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, + AuthKeyringBackendKind::default(), ) .await; @@ -126,6 +131,7 @@ async fn bedrock_only_auth_storage_creates_primary_auth() -> anyhow::Result<()> /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, + AuthKeyringBackendKind::default(), ) .await; @@ -153,12 +159,14 @@ async fn login_with_api_key_clears_bedrock_api_key() -> anyhow::Result<()> { "bedrock-api-key-test", "us-east-1", AuthCredentialsStoreMode::File, + AuthKeyringBackendKind::default(), )?; crate::auth::login_with_api_key( codex_home.path(), "sk-test-key", AuthCredentialsStoreMode::File, + AuthKeyringBackendKind::default(), )?; assert_eq!(storage.load()?, Some(api_key_auth())); diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index db658e7fd..2b9a7a640 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -34,6 +34,7 @@ pub use crate::auth::bedrock_api_key::BedrockApiKeyAuth; pub use crate::auth::personal_access_token::PersonalAccessTokenAuth; pub use crate::auth::storage::AgentIdentityAuthRecord; pub use crate::auth::storage::AuthDotJson; +pub use crate::auth::storage::AuthKeyringBackendKind; use crate::auth::storage::AuthStorageBackend; use crate::auth::storage::create_auth_storage; use crate::auth::util::try_parse_error_message; @@ -215,6 +216,7 @@ impl CodexAuth { auth_dot_json: AuthDotJson, auth_credentials_store_mode: AuthCredentialsStoreMode, chatgpt_base_url: Option<&str>, + keyring_backend_kind: AuthKeyringBackendKind, ) -> std::io::Result { let auth_mode = auth_dot_json.resolved_mode(); let client = create_client(); @@ -257,7 +259,11 @@ impl CodexAuth { match auth_mode { ApiAuthMode::Chatgpt => { - let storage = create_auth_storage(codex_home.to_path_buf(), storage_mode); + let storage = create_auth_storage( + codex_home.to_path_buf(), + storage_mode, + keyring_backend_kind, + ); Ok(Self::Chatgpt(ChatgptAuth { state, storage })) } ApiAuthMode::ChatgptAuthTokens => { @@ -276,6 +282,7 @@ impl CodexAuth { codex_home: &Path, auth_credentials_store_mode: AuthCredentialsStoreMode, chatgpt_base_url: Option<&str>, + keyring_backend_kind: AuthKeyringBackendKind, ) -> std::io::Result> { load_auth( codex_home, @@ -283,6 +290,7 @@ impl CodexAuth { auth_credentials_store_mode, /*forced_chatgpt_workspace_id*/ None, chatgpt_base_url, + keyring_backend_kind, ) .await } @@ -508,6 +516,7 @@ impl CodexAuth { let storage = create_auth_storage( PathBuf::from(format!("dummy-chatgpt-auth-{dummy_auth_id}")), AuthCredentialsStoreMode::Ephemeral, + AuthKeyringBackendKind::default(), ); Self::Chatgpt(ChatgptAuth { state, storage }) } @@ -581,16 +590,26 @@ async fn verified_agent_identity_record( pub fn logout( codex_home: &Path, auth_credentials_store_mode: AuthCredentialsStoreMode, + keyring_backend_kind: AuthKeyringBackendKind, ) -> std::io::Result { - let storage = create_auth_storage(codex_home.to_path_buf(), auth_credentials_store_mode); + let storage = create_auth_storage( + codex_home.to_path_buf(), + auth_credentials_store_mode, + keyring_backend_kind, + ); storage.delete() } pub async fn logout_with_revoke( codex_home: &Path, auth_credentials_store_mode: AuthCredentialsStoreMode, + keyring_backend_kind: AuthKeyringBackendKind, ) -> std::io::Result { - let auth_dot_json = match load_auth_dot_json(codex_home, auth_credentials_store_mode) { + let auth_dot_json = match load_auth_dot_json( + codex_home, + auth_credentials_store_mode, + keyring_backend_kind, + ) { Ok(auth_dot_json) => auth_dot_json, Err(err) => { tracing::warn!("failed to load stored auth during logout: {err}"); @@ -600,7 +619,11 @@ pub async fn logout_with_revoke( if let Err(err) = revoke_auth_tokens(auth_dot_json.as_ref()).await { tracing::warn!("failed to revoke auth tokens during logout: {err}"); } - logout_all_stores(codex_home, auth_credentials_store_mode) + logout_all_stores( + codex_home, + auth_credentials_store_mode, + keyring_backend_kind, + ) } /// Writes an `auth.json` that contains only the API key. @@ -608,6 +631,7 @@ pub fn login_with_api_key( codex_home: &Path, api_key: &str, auth_credentials_store_mode: AuthCredentialsStoreMode, + keyring_backend_kind: AuthKeyringBackendKind, ) -> std::io::Result<()> { let auth_dot_json = AuthDotJson { auth_mode: Some(ApiAuthMode::ApiKey), @@ -618,7 +642,12 @@ pub fn login_with_api_key( personal_access_token: None, bedrock_api_key: None, }; - save_auth(codex_home, &auth_dot_json, auth_credentials_store_mode) + save_auth( + codex_home, + &auth_dot_json, + auth_credentials_store_mode, + keyring_backend_kind, + ) } /// Writes an `auth.json` that contains only the access token. @@ -628,6 +657,7 @@ pub async fn login_with_access_token( auth_credentials_store_mode: AuthCredentialsStoreMode, forced_chatgpt_workspace_id: Option<&[String]>, chatgpt_base_url: Option<&str>, + keyring_backend_kind: AuthKeyringBackendKind, ) -> std::io::Result<()> { let auth_dot_json = match classify_codex_access_token(access_token) { CodexAccessToken::PersonalAccessToken(access_token) => { @@ -662,7 +692,12 @@ pub async fn login_with_access_token( } } }; - save_auth(codex_home, &auth_dot_json, auth_credentials_store_mode) + save_auth( + codex_home, + &auth_dot_json, + auth_credentials_store_mode, + keyring_backend_kind, + ) } fn ensure_personal_access_token_workspace_allowed( @@ -689,6 +724,7 @@ pub fn login_with_chatgpt_auth_tokens( codex_home, &auth_dot_json, AuthCredentialsStoreMode::Ephemeral, + AuthKeyringBackendKind::default(), ) } @@ -697,8 +733,13 @@ pub fn save_auth( codex_home: &Path, auth: &AuthDotJson, auth_credentials_store_mode: AuthCredentialsStoreMode, + keyring_backend_kind: AuthKeyringBackendKind, ) -> std::io::Result<()> { - let storage = create_auth_storage(codex_home.to_path_buf(), auth_credentials_store_mode); + let storage = create_auth_storage( + codex_home.to_path_buf(), + auth_credentials_store_mode, + keyring_backend_kind, + ); storage.save(auth) } @@ -710,8 +751,13 @@ pub fn save_auth( pub fn load_auth_dot_json( codex_home: &Path, auth_credentials_store_mode: AuthCredentialsStoreMode, + keyring_backend_kind: AuthKeyringBackendKind, ) -> std::io::Result> { - let storage = create_auth_storage(codex_home.to_path_buf(), auth_credentials_store_mode); + let storage = create_auth_storage( + codex_home.to_path_buf(), + auth_credentials_store_mode, + keyring_backend_kind, + ); storage.load() } @@ -719,6 +765,7 @@ pub fn load_auth_dot_json( pub struct AuthConfig { pub codex_home: PathBuf, pub auth_credentials_store_mode: AuthCredentialsStoreMode, + pub keyring_backend_kind: AuthKeyringBackendKind, pub forced_login_method: Option, pub chatgpt_base_url: Option, pub forced_chatgpt_workspace_id: Option>, @@ -731,6 +778,7 @@ pub async fn enforce_login_restrictions(config: &AuthConfig) -> std::io::Result< config.auth_credentials_store_mode, /*forced_chatgpt_workspace_id*/ None, config.chatgpt_base_url.as_deref(), + config.keyring_backend_kind, ) .await? else { @@ -764,6 +812,7 @@ pub async fn enforce_login_restrictions(config: &AuthConfig) -> std::io::Result< &config.codex_home, message, config.auth_credentials_store_mode, + config.keyring_backend_kind, ); } } @@ -784,6 +833,7 @@ pub async fn enforce_login_restrictions(config: &AuthConfig) -> std::io::Result< "Failed to load ChatGPT credentials while enforcing workspace restrictions: {err}. Logging out." ), config.auth_credentials_store_mode, + config.keyring_backend_kind, ); } }; @@ -811,6 +861,7 @@ pub async fn enforce_login_restrictions(config: &AuthConfig) -> std::io::Result< &config.codex_home, message, config.auth_credentials_store_mode, + config.keyring_backend_kind, ); } } @@ -822,10 +873,15 @@ fn logout_with_message( codex_home: &Path, message: String, auth_credentials_store_mode: AuthCredentialsStoreMode, + keyring_backend_kind: AuthKeyringBackendKind, ) -> std::io::Result<()> { // External auth tokens live in the ephemeral store, but persistent auth may still exist // from earlier logins. Clear both so a forced logout truly removes all active auth. - let removal_result = logout_all_stores(codex_home, auth_credentials_store_mode); + let removal_result = logout_all_stores( + codex_home, + auth_credentials_store_mode, + keyring_backend_kind, + ); let error_message = match removal_result { Ok(_) => message, Err(err) => format!("{message}. Failed to remove auth.json: {err}"), @@ -836,12 +892,25 @@ fn logout_with_message( fn logout_all_stores( codex_home: &Path, auth_credentials_store_mode: AuthCredentialsStoreMode, + keyring_backend_kind: AuthKeyringBackendKind, ) -> std::io::Result { if auth_credentials_store_mode == AuthCredentialsStoreMode::Ephemeral { - return logout(codex_home, AuthCredentialsStoreMode::Ephemeral); + return logout( + codex_home, + AuthCredentialsStoreMode::Ephemeral, + AuthKeyringBackendKind::default(), + ); } - let removed_ephemeral = logout(codex_home, AuthCredentialsStoreMode::Ephemeral)?; - let removed_managed = logout(codex_home, auth_credentials_store_mode)?; + let removed_ephemeral = logout( + codex_home, + AuthCredentialsStoreMode::Ephemeral, + AuthKeyringBackendKind::default(), + )?; + let removed_managed = logout( + codex_home, + auth_credentials_store_mode, + keyring_backend_kind, + )?; Ok(removed_ephemeral || removed_managed) } @@ -851,6 +920,7 @@ async fn load_auth( auth_credentials_store_mode: AuthCredentialsStoreMode, forced_chatgpt_workspace_id: Option<&[String]>, chatgpt_base_url: Option<&str>, + keyring_backend_kind: AuthKeyringBackendKind, ) -> std::io::Result> { // API key via env var takes precedence over any other auth method. if enable_codex_api_key_env && let Some(api_key) = read_codex_api_key_from_env() { @@ -862,6 +932,7 @@ async fn load_auth( let ephemeral_storage = create_auth_storage( codex_home.to_path_buf(), AuthCredentialsStoreMode::Ephemeral, + AuthKeyringBackendKind::default(), ); if let Some(auth_dot_json) = ephemeral_storage.load()? { let auth = CodexAuth::from_auth_dot_json( @@ -869,6 +940,7 @@ async fn load_auth( auth_dot_json, AuthCredentialsStoreMode::Ephemeral, chatgpt_base_url, + keyring_backend_kind, ) .await?; if let CodexAuth::PersonalAccessToken(auth) = &auth { @@ -898,7 +970,11 @@ async fn load_auth( } // Fall back to the configured persistent store (file/keyring/auto) for managed auth. - let storage = create_auth_storage(codex_home.to_path_buf(), auth_credentials_store_mode); + let storage = create_auth_storage( + codex_home.to_path_buf(), + auth_credentials_store_mode, + keyring_backend_kind, + ); let auth_dot_json = match storage.load()? { Some(auth) => auth, None => return Ok(None), @@ -909,6 +985,7 @@ async fn load_auth( auth_dot_json, auth_credentials_store_mode, chatgpt_base_url, + keyring_backend_kind, ) .await?; if let CodexAuth::PersonalAccessToken(auth) = &auth { @@ -1405,6 +1482,7 @@ pub struct AuthManager { auth_change_tx: watch::Sender, enable_codex_api_key_env: bool, auth_credentials_store_mode: AuthCredentialsStoreMode, + keyring_backend_kind: AuthKeyringBackendKind, forced_chatgpt_workspace_id: RwLock>>, chatgpt_base_url: Option, refresh_lock: Semaphore, @@ -1424,6 +1502,9 @@ pub trait AuthManagerConfig { /// Returns the CLI auth credential storage mode for auth loading. fn cli_auth_credentials_store_mode(&self) -> AuthCredentialsStoreMode; + /// Returns the backend to use when CLI auth keyring storage is selected. + fn auth_keyring_backend_kind(&self) -> AuthKeyringBackendKind; + /// Returns the workspace IDs that ChatGPT auth should be restricted to, if any. fn forced_chatgpt_workspace_id(&self) -> Option>; @@ -1441,6 +1522,7 @@ impl Debug for AuthManager { "auth_credentials_store_mode", &self.auth_credentials_store_mode, ) + .field("keyring_backend_kind", &self.keyring_backend_kind) .field( "forced_chatgpt_workspace_id", &self.forced_chatgpt_workspace_id, @@ -1461,6 +1543,7 @@ impl AuthManager { enable_codex_api_key_env: bool, auth_credentials_store_mode: AuthCredentialsStoreMode, chatgpt_base_url: Option, + keyring_backend_kind: AuthKeyringBackendKind, ) -> Self { Self::new_with_workspace_restriction( codex_home, @@ -1468,6 +1551,7 @@ impl AuthManager { auth_credentials_store_mode, /*forced_chatgpt_workspace_id*/ None, chatgpt_base_url, + keyring_backend_kind, ) .await } @@ -1478,6 +1562,7 @@ impl AuthManager { auth_credentials_store_mode: AuthCredentialsStoreMode, forced_chatgpt_workspace_id: Option>, chatgpt_base_url: Option, + keyring_backend_kind: AuthKeyringBackendKind, ) -> Self { let managed_auth = load_auth( &codex_home, @@ -1485,6 +1570,7 @@ impl AuthManager { auth_credentials_store_mode, forced_chatgpt_workspace_id.as_deref(), chatgpt_base_url.as_deref(), + keyring_backend_kind, ) .await .ok() @@ -1499,6 +1585,7 @@ impl AuthManager { auth_change_tx, enable_codex_api_key_env, auth_credentials_store_mode, + keyring_backend_kind, forced_chatgpt_workspace_id: RwLock::new(forced_chatgpt_workspace_id), chatgpt_base_url, refresh_lock: Semaphore::new(/*permits*/ 1), @@ -1520,6 +1607,7 @@ impl AuthManager { auth_change_tx, enable_codex_api_key_env: false, auth_credentials_store_mode: AuthCredentialsStoreMode::File, + keyring_backend_kind: AuthKeyringBackendKind::default(), forced_chatgpt_workspace_id: RwLock::new(None), chatgpt_base_url: None, refresh_lock: Semaphore::new(/*permits*/ 1), @@ -1540,6 +1628,7 @@ impl AuthManager { auth_change_tx, enable_codex_api_key_env: false, auth_credentials_store_mode: AuthCredentialsStoreMode::File, + keyring_backend_kind: AuthKeyringBackendKind::default(), forced_chatgpt_workspace_id: RwLock::new(None), chatgpt_base_url: None, refresh_lock: Semaphore::new(/*permits*/ 1), @@ -1558,6 +1647,7 @@ impl AuthManager { auth_change_tx, enable_codex_api_key_env: false, auth_credentials_store_mode: AuthCredentialsStoreMode::File, + keyring_backend_kind: AuthKeyringBackendKind::default(), forced_chatgpt_workspace_id: RwLock::new(None), chatgpt_base_url: None, refresh_lock: Semaphore::new(/*permits*/ 1), @@ -1706,6 +1796,7 @@ impl AuthManager { self.auth_credentials_store_mode, forced_chatgpt_workspace_id.as_deref(), self.chatgpt_base_url.as_deref(), + self.keyring_backend_kind, ) .await .ok() @@ -1779,6 +1870,7 @@ impl AuthManager { enable_codex_api_key_env: bool, auth_credentials_store_mode: AuthCredentialsStoreMode, chatgpt_base_url: Option, + keyring_backend_kind: AuthKeyringBackendKind, ) -> Arc { Arc::new( Self::new( @@ -1786,6 +1878,7 @@ impl AuthManager { enable_codex_api_key_env, auth_credentials_store_mode, chatgpt_base_url, + keyring_backend_kind, ) .await, ) @@ -1803,6 +1896,7 @@ impl AuthManager { config.cli_auth_credentials_store_mode(), config.forced_chatgpt_workspace_id(), Some(config.chatgpt_base_url()), + config.auth_keyring_backend_kind(), ) .await, ) @@ -1943,7 +2037,11 @@ impl AuthManager { /// reloads the in‑memory auth cache so callers immediately observe the /// unauthenticated state. pub async fn logout(&self) -> std::io::Result { - let removed = logout_all_stores(&self.codex_home, self.auth_credentials_store_mode)?; + let removed = logout_all_stores( + &self.codex_home, + self.auth_credentials_store_mode, + self.keyring_backend_kind, + )?; // Always reload to clear any cached auth (even if file absent). self.reload().await; Ok(removed) @@ -1956,7 +2054,11 @@ impl AuthManager { if let Err(err) = revoke_auth_tokens(auth_dot_json.as_ref()).await { tracing::warn!("failed to revoke auth tokens during logout: {err}"); } - let result = logout_all_stores(&self.codex_home, self.auth_credentials_store_mode)?; + let result = logout_all_stores( + &self.codex_home, + self.auth_credentials_store_mode, + self.keyring_backend_kind, + )?; // Always reload to clear any cached auth (even if file absent). self.reload().await; Ok(result) @@ -2059,6 +2161,7 @@ impl AuthManager { &self.codex_home, &auth_dot_json, AuthCredentialsStoreMode::Ephemeral, + AuthKeyringBackendKind::default(), ) .map_err(RefreshTokenError::Transient)?; self.reload().await; diff --git a/codex-rs/login/src/auth/storage.rs b/codex-rs/login/src/auth/storage.rs index 2edb4ca30..ebec06417 100644 --- a/codex-rs/login/src/auth/storage.rs +++ b/codex-rs/login/src/auth/storage.rs @@ -24,9 +24,15 @@ use codex_agent_identity::AgentIdentityJwtClaims; use codex_agent_identity::decode_agent_identity_jwt; use codex_app_server_protocol::AuthMode; use codex_config::types::AuthCredentialsStoreMode; +pub use codex_config::types::AuthKeyringBackendKind; use codex_keyring_store::DefaultKeyringStore; use codex_keyring_store::KeyringStore; use codex_protocol::account::PlanType as AccountPlanType; +use codex_secrets::LocalSecretsNamespace; +use codex_secrets::SecretName; +use codex_secrets::SecretScope; +use codex_secrets::SecretsBackendKind; +use codex_secrets::SecretsManager; use once_cell::sync::Lazy; /// Expected structure for $CODEX_HOME/auth.json. @@ -164,6 +170,11 @@ impl AuthStorageBackend for FileAuthStorage { } } +static CODEX_AUTH_SECRET_NAME: Lazy = + Lazy::new(|| match SecretName::new("CODEX_AUTH") { + Ok(name) => name, + Err(err) => unreachable!("CODEX_AUTH should be a valid secret name: {err}"), + }); const KEYRING_SERVICE: &str = "Codex Auth"; // turns codex_home path into a stable, short key string @@ -181,12 +192,12 @@ fn compute_store_key(codex_home: &Path) -> std::io::Result { } #[derive(Clone, Debug)] -struct KeyringAuthStorage { +struct DirectKeyringAuthStorage { codex_home: PathBuf, keyring_store: Arc, } -impl KeyringAuthStorage { +impl DirectKeyringAuthStorage { fn new(codex_home: PathBuf, keyring_store: Arc) -> Self { Self { codex_home, @@ -224,7 +235,7 @@ impl KeyringAuthStorage { } } -impl AuthStorageBackend for KeyringAuthStorage { +impl AuthStorageBackend for DirectKeyringAuthStorage { fn load(&self) -> std::io::Result> { let key = compute_store_key(&self.codex_home)?; self.load_from_keyring(&key) @@ -254,16 +265,107 @@ impl AuthStorageBackend for KeyringAuthStorage { } } +#[derive(Clone)] +struct SecretsKeyringAuthStorage { + codex_home: PathBuf, + direct_storage: DirectKeyringAuthStorage, + secrets_manager: SecretsManager, +} + +impl Debug for SecretsKeyringAuthStorage { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SecretsKeyringAuthStorage") + .field("codex_home", &self.codex_home) + .finish_non_exhaustive() + } +} + +impl SecretsKeyringAuthStorage { + fn new(codex_home: PathBuf, keyring_store: Arc) -> Self { + let direct_storage = + DirectKeyringAuthStorage::new(codex_home.clone(), Arc::clone(&keyring_store)); + let secrets_manager = SecretsManager::new_with_keyring_store_and_namespace( + codex_home.clone(), + SecretsBackendKind::Local, + keyring_store, + LocalSecretsNamespace::CodexAuth, + ); + Self { + codex_home, + direct_storage, + secrets_manager, + } + } +} + +impl AuthStorageBackend for SecretsKeyringAuthStorage { + fn load(&self) -> std::io::Result> { + match self + .secrets_manager + .get(&SecretScope::Global, &CODEX_AUTH_SECRET_NAME) + .map_err(|err| { + std::io::Error::other(format!( + "failed to load CLI auth from encrypted auth storage: {err}" + )) + })? { + Some(serialized) => serde_json::from_str(&serialized).map(Some).map_err(|err| { + std::io::Error::other(format!( + "failed to deserialize CLI auth from encrypted auth storage: {err}" + )) + }), + None => Ok(None), + } + } + + fn save(&self, auth: &AuthDotJson) -> std::io::Result<()> { + let serialized = serde_json::to_string(auth).map_err(std::io::Error::other)?; + self.secrets_manager + .set(&SecretScope::Global, &CODEX_AUTH_SECRET_NAME, &serialized) + .map_err(|err| { + let message = + format!("failed to write OAuth tokens to encrypted auth storage: {err}"); + warn!("{message}"); + std::io::Error::other(message) + })?; + if let Err(err) = delete_file_if_exists(&self.codex_home) { + warn!("failed to remove CLI auth fallback file: {err}"); + } + Ok(()) + } + + fn delete(&self) -> std::io::Result { + let keyring_removed = self + .secrets_manager + .delete(&SecretScope::Global, &CODEX_AUTH_SECRET_NAME) + .map_err(|err| { + std::io::Error::other(format!( + "failed to delete auth from encrypted auth storage: {err}" + )) + })?; + let file_removed = delete_file_if_exists(&self.codex_home)?; + let direct_removed = self.direct_storage.delete()?; + Ok(keyring_removed || file_removed || direct_removed) + } +} + #[derive(Clone, Debug)] struct AutoAuthStorage { - keyring_storage: Arc, + keyring_storage: Arc, file_storage: Arc, } impl AutoAuthStorage { - fn new(codex_home: PathBuf, keyring_store: Arc) -> Self { + fn new( + codex_home: PathBuf, + keyring_store: Arc, + keyring_backend_kind: AuthKeyringBackendKind, + ) -> Self { Self { - keyring_storage: Arc::new(KeyringAuthStorage::new(codex_home.clone(), keyring_store)), + keyring_storage: create_keyring_auth_storage( + codex_home.clone(), + keyring_store, + keyring_backend_kind, + ), file_storage: Arc::new(FileAuthStorage::new(codex_home)), } } @@ -343,26 +445,47 @@ impl AuthStorageBackend for EphemeralAuthStorage { pub(super) fn create_auth_storage( codex_home: PathBuf, mode: AuthCredentialsStoreMode, + keyring_backend_kind: AuthKeyringBackendKind, ) -> Arc { let keyring_store: Arc = Arc::new(DefaultKeyringStore); - create_auth_storage_with_keyring_store(codex_home, mode, keyring_store) + create_auth_storage_with_store(codex_home, mode, keyring_store, keyring_backend_kind) } -fn create_auth_storage_with_keyring_store( +fn create_auth_storage_with_store( codex_home: PathBuf, mode: AuthCredentialsStoreMode, keyring_store: Arc, + keyring_backend_kind: AuthKeyringBackendKind, ) -> Arc { match mode { AuthCredentialsStoreMode::File => Arc::new(FileAuthStorage::new(codex_home)), AuthCredentialsStoreMode::Keyring => { - Arc::new(KeyringAuthStorage::new(codex_home, keyring_store)) + create_keyring_auth_storage(codex_home, keyring_store, keyring_backend_kind) } - AuthCredentialsStoreMode::Auto => Arc::new(AutoAuthStorage::new(codex_home, keyring_store)), + AuthCredentialsStoreMode::Auto => Arc::new(AutoAuthStorage::new( + codex_home, + keyring_store, + keyring_backend_kind, + )), AuthCredentialsStoreMode::Ephemeral => Arc::new(EphemeralAuthStorage::new(codex_home)), } } +fn create_keyring_auth_storage( + codex_home: PathBuf, + keyring_store: Arc, + keyring_backend_kind: AuthKeyringBackendKind, +) -> Arc { + match keyring_backend_kind { + AuthKeyringBackendKind::Direct => { + Arc::new(DirectKeyringAuthStorage::new(codex_home, keyring_store)) + } + AuthKeyringBackendKind::Secrets => { + Arc::new(SecretsKeyringAuthStorage::new(codex_home, keyring_store)) + } + } +} + #[cfg(test)] #[path = "storage_tests.rs"] mod tests; diff --git a/codex-rs/login/src/auth/storage_tests.rs b/codex-rs/login/src/auth/storage_tests.rs index c3491a8c4..72d59b660 100644 --- a/codex-rs/login/src/auth/storage_tests.rs +++ b/codex-rs/login/src/auth/storage_tests.rs @@ -2,6 +2,11 @@ use super::*; use crate::token_data::IdTokenInfo; use anyhow::Context; use base64::Engine; +use codex_secrets::LocalSecretsNamespace; +use codex_secrets::SecretScope; +use codex_secrets::SecretsBackendKind; +use codex_secrets::SecretsManager; +use codex_secrets::compute_keyring_account; use pretty_assertions::assert_eq; use serde_json::json; use tempfile::tempdir; @@ -152,7 +157,11 @@ fn file_storage_delete_removes_auth_file() -> anyhow::Result<()> { personal_access_token: None, bedrock_api_key: None, }; - let storage = create_auth_storage(dir.path().to_path_buf(), AuthCredentialsStoreMode::File); + let storage = create_auth_storage( + dir.path().to_path_buf(), + AuthCredentialsStoreMode::File, + AuthKeyringBackendKind::default(), + ); storage.save(&auth_dot_json)?; assert!(dir.path().join("auth.json").exists()); let storage = FileAuthStorage::new(dir.path().to_path_buf()); @@ -168,6 +177,7 @@ fn ephemeral_storage_save_load_delete_is_in_memory_only() -> anyhow::Result<()> let storage = create_auth_storage( dir.path().to_path_buf(), AuthCredentialsStoreMode::Ephemeral, + AuthKeyringBackendKind::default(), ); let auth_dot_json = AuthDotJson { auth_mode: Some(AuthMode::ApiKey), @@ -191,51 +201,83 @@ fn ephemeral_storage_save_load_delete_is_in_memory_only() -> anyhow::Result<()> Ok(()) } -fn seed_keyring_and_fallback_auth_file_for_delete( +fn seed_secrets_backend_and_fallback_auth_file_for_delete( mock_keyring: &MockKeyringStore, codex_home: &Path, - compute_key: F, -) -> anyhow::Result<(String, PathBuf)> -where - F: FnOnce() -> std::io::Result, -{ - let key = compute_key()?; - mock_keyring.save(KEYRING_SERVICE, &key, "{}")?; + auth: &AuthDotJson, +) -> anyhow::Result { + let manager = SecretsManager::new_with_keyring_store_and_namespace( + codex_home.to_path_buf(), + SecretsBackendKind::Local, + Arc::new(mock_keyring.clone()), + LocalSecretsNamespace::CodexAuth, + ); + manager.set( + &SecretScope::Global, + &CODEX_AUTH_SECRET_NAME, + &serde_json::to_string(auth)?, + )?; let auth_file = get_auth_file(codex_home); std::fs::write(&auth_file, "stale")?; - Ok((key, auth_file)) + Ok(auth_file) } -fn seed_keyring_with_auth( +fn seed_secrets_backend_with_auth( mock_keyring: &MockKeyringStore, - compute_key: F, + codex_home: &Path, auth: &AuthDotJson, -) -> anyhow::Result<()> -where - F: FnOnce() -> std::io::Result, -{ - let key = compute_key()?; - let serialized = serde_json::to_string(auth)?; - mock_keyring.save(KEYRING_SERVICE, &key, &serialized)?; +) -> anyhow::Result<()> { + let manager = SecretsManager::new_with_keyring_store_and_namespace( + codex_home.to_path_buf(), + SecretsBackendKind::Local, + Arc::new(mock_keyring.clone()), + LocalSecretsNamespace::CodexAuth, + ); + manager.set( + &SecretScope::Global, + &CODEX_AUTH_SECRET_NAME, + &serde_json::to_string(auth)?, + )?; Ok(()) } fn assert_keyring_saved_auth_and_removed_fallback( mock_keyring: &MockKeyringStore, - key: &str, codex_home: &Path, expected: &AuthDotJson, -) { - let saved_value = mock_keyring - .saved_value(key) - .expect("keyring entry should exist"); - let expected_serialized = serde_json::to_string(expected).expect("serialize expected auth"); +) -> anyhow::Result<()> { + let manager = SecretsManager::new_with_keyring_store_and_namespace( + codex_home.to_path_buf(), + SecretsBackendKind::Local, + Arc::new(mock_keyring.clone()), + LocalSecretsNamespace::CodexAuth, + ); + let saved_value = manager + .get(&SecretScope::Global, &CODEX_AUTH_SECRET_NAME)? + .context("encrypted auth entry should exist")?; + let expected_serialized = serde_json::to_string(expected)?; assert_eq!(saved_value, expected_serialized); + let old_key = compute_store_key(codex_home)?; + assert!( + mock_keyring.saved_value(&old_key).is_none(), + "legacy keyring auth entry should not be used" + ); + let secrets_key = compute_keyring_account(codex_home); + assert!( + mock_keyring.saved_value(&secrets_key).is_some(), + "secrets backend should persist an encryption passphrase in the keyring" + ); + assert!(encrypted_auth_file(codex_home).exists()); let auth_file = get_auth_file(codex_home); assert!( !auth_file.exists(), "fallback auth.json should be removed after keyring save" ); + Ok(()) +} + +fn encrypted_auth_file(codex_home: &Path) -> PathBuf { + codex_home.join("secrets").join("codex_auth.age") } fn id_token_with_prefix(prefix: &str) -> IdTokenInfo { @@ -290,10 +332,10 @@ fn jwt_with_payload(payload: serde_json::Value) -> String { } #[test] -fn keyring_auth_storage_load_returns_deserialized_auth() -> anyhow::Result<()> { +fn secrets_keyring_auth_storage_load_returns_deserialized_auth() -> anyhow::Result<()> { let codex_home = tempdir()?; let mock_keyring = MockKeyringStore::default(); - let storage = KeyringAuthStorage::new( + let storage = SecretsKeyringAuthStorage::new( codex_home.path().to_path_buf(), Arc::new(mock_keyring.clone()), ); @@ -306,11 +348,7 @@ fn keyring_auth_storage_load_returns_deserialized_auth() -> anyhow::Result<()> { personal_access_token: None, bedrock_api_key: None, }; - seed_keyring_with_auth( - &mock_keyring, - || compute_store_key(codex_home.path()), - &expected, - )?; + seed_secrets_backend_with_auth(&mock_keyring, codex_home.path(), &expected)?; let loaded = storage.load()?; assert_eq!(Some(expected), loaded); @@ -328,10 +366,107 @@ fn keyring_auth_storage_compute_store_key_for_home_directory() -> anyhow::Result } #[test] -fn keyring_auth_storage_save_persists_and_removes_fallback_file() -> anyhow::Result<()> { +fn direct_keyring_auth_storage_saves_legacy_keyring_entry() -> anyhow::Result<()> { let codex_home = tempdir()?; let mock_keyring = MockKeyringStore::default(); - let storage = KeyringAuthStorage::new( + let storage = DirectKeyringAuthStorage::new( + codex_home.path().to_path_buf(), + Arc::new(mock_keyring.clone()), + ); + let auth_file = get_auth_file(codex_home.path()); + std::fs::write(&auth_file, "stale")?; + let auth = auth_with_prefix("direct"); + + storage.save(&auth)?; + + let legacy_key = compute_store_key(codex_home.path())?; + let saved_value = mock_keyring + .saved_value(&legacy_key) + .context("direct keyring auth entry should exist")?; + assert_eq!(saved_value, serde_json::to_string(&auth)?); + assert!(!encrypted_auth_file(codex_home.path()).exists()); + assert!( + !auth_file.exists(), + "fallback auth.json should be removed after keyring save" + ); + assert_eq!(storage.load()?, Some(auth)); + Ok(()) +} + +#[test] +fn direct_keyring_auth_storage_delete_removes_keyring_and_file() -> anyhow::Result<()> { + let codex_home = tempdir()?; + let mock_keyring = MockKeyringStore::default(); + let storage = DirectKeyringAuthStorage::new( + codex_home.path().to_path_buf(), + Arc::new(mock_keyring.clone()), + ); + let auth = auth_with_prefix("direct-delete"); + storage.save(&auth)?; + let auth_file = get_auth_file(codex_home.path()); + std::fs::write(&auth_file, "stale")?; + + let removed = storage.delete()?; + + assert!(removed, "delete should report removal"); + assert_eq!(storage.load()?, None, "keyring auth should be removed"); + assert!( + mock_keyring + .saved_value(&compute_store_key(codex_home.path())?) + .is_none(), + "legacy keyring auth entry should be removed" + ); + assert!( + !auth_file.exists(), + "fallback auth.json should be removed after keyring delete" + ); + assert!(!encrypted_auth_file(codex_home.path()).exists()); + Ok(()) +} + +#[test] +fn factory_uses_secrets_backend_only_when_requested() -> anyhow::Result<()> { + let direct_home = tempdir()?; + let direct_keyring = MockKeyringStore::default(); + let direct_storage = create_auth_storage_with_store( + direct_home.path().to_path_buf(), + AuthCredentialsStoreMode::Keyring, + Arc::new(direct_keyring.clone()), + AuthKeyringBackendKind::Direct, + ); + let direct_auth = auth_with_prefix("factory-direct"); + direct_storage.save(&direct_auth)?; + assert!( + direct_keyring + .saved_value(&compute_store_key(direct_home.path())?) + .is_some() + ); + assert!(!encrypted_auth_file(direct_home.path()).exists()); + + let secrets_home = tempdir()?; + let secrets_keyring = MockKeyringStore::default(); + let secrets_storage = create_auth_storage_with_store( + secrets_home.path().to_path_buf(), + AuthCredentialsStoreMode::Keyring, + Arc::new(secrets_keyring.clone()), + AuthKeyringBackendKind::Secrets, + ); + let secrets_auth = auth_with_prefix("factory-secrets"); + secrets_storage.save(&secrets_auth)?; + assert!( + secrets_keyring + .saved_value(&compute_keyring_account(secrets_home.path())) + .is_some() + ); + assert!(encrypted_auth_file(secrets_home.path()).exists()); + Ok(()) +} + +#[test] +fn secrets_keyring_auth_storage_save_persists_and_removes_fallback_file() -> anyhow::Result<()> { + let codex_home = tempdir()?; + let mock_keyring = MockKeyringStore::default(); + let storage = SecretsKeyringAuthStorage::new( codex_home.path().to_path_buf(), Arc::new(mock_keyring.clone()), ); @@ -354,30 +489,64 @@ fn keyring_auth_storage_save_persists_and_removes_fallback_file() -> anyhow::Res storage.save(&auth)?; - let key = compute_store_key(codex_home.path())?; - assert_keyring_saved_auth_and_removed_fallback(&mock_keyring, &key, codex_home.path(), &auth); + assert_keyring_saved_auth_and_removed_fallback(&mock_keyring, codex_home.path(), &auth)?; Ok(()) } #[test] -fn keyring_auth_storage_delete_removes_keyring_and_file() -> anyhow::Result<()> { +fn secrets_keyring_auth_storage_delete_removes_keyring_and_file() -> anyhow::Result<()> { let codex_home = tempdir()?; let mock_keyring = MockKeyringStore::default(); - let storage = KeyringAuthStorage::new( + let storage = SecretsKeyringAuthStorage::new( codex_home.path().to_path_buf(), Arc::new(mock_keyring.clone()), ); - let (key, auth_file) = - seed_keyring_and_fallback_auth_file_for_delete(&mock_keyring, codex_home.path(), || { - compute_store_key(codex_home.path()) - })?; + let auth = auth_with_prefix("to-delete"); + let auth_file = seed_secrets_backend_and_fallback_auth_file_for_delete( + &mock_keyring, + codex_home.path(), + &auth, + )?; let removed = storage.delete()?; assert!(removed, "delete should report removal"); + assert_eq!(storage.load()?, None, "encrypted auth should be removed"); assert!( - !mock_keyring.contains(&key), - "keyring entry should be removed" + !auth_file.exists(), + "fallback auth.json should be removed after keyring delete" + ); + Ok(()) +} + +#[test] +fn secrets_keyring_auth_storage_delete_removes_legacy_direct_keyring_entry() -> anyhow::Result<()> { + let codex_home = tempdir()?; + let mock_keyring = MockKeyringStore::default(); + let direct_storage = DirectKeyringAuthStorage::new( + codex_home.path().to_path_buf(), + Arc::new(mock_keyring.clone()), + ); + direct_storage.save(&auth_with_prefix("legacy-direct"))?; + let storage = SecretsKeyringAuthStorage::new( + codex_home.path().to_path_buf(), + Arc::new(mock_keyring.clone()), + ); + let auth = auth_with_prefix("to-delete"); + let auth_file = seed_secrets_backend_and_fallback_auth_file_for_delete( + &mock_keyring, + codex_home.path(), + &auth, + )?; + + let removed = storage.delete()?; + + assert!(removed, "delete should report removal"); + assert_eq!(storage.load()?, None, "encrypted auth should be removed"); + assert_eq!( + direct_storage.load()?, + None, + "legacy direct keyring auth should be removed" ); assert!( !auth_file.exists(), @@ -393,13 +562,10 @@ fn auto_auth_storage_load_prefers_keyring_value() -> anyhow::Result<()> { let storage = AutoAuthStorage::new( codex_home.path().to_path_buf(), Arc::new(mock_keyring.clone()), + AuthKeyringBackendKind::Secrets, ); let keyring_auth = auth_with_prefix("keyring"); - seed_keyring_with_auth( - &mock_keyring, - || compute_store_key(codex_home.path()), - &keyring_auth, - )?; + seed_secrets_backend_with_auth(&mock_keyring, codex_home.path(), &keyring_auth)?; let file_auth = auth_with_prefix("file"); storage.file_storage.save(&file_auth)?; @@ -413,7 +579,11 @@ fn auto_auth_storage_load_prefers_keyring_value() -> anyhow::Result<()> { fn auto_auth_storage_load_uses_file_when_keyring_empty() -> anyhow::Result<()> { let codex_home = tempdir()?; let mock_keyring = MockKeyringStore::default(); - let storage = AutoAuthStorage::new(codex_home.path().to_path_buf(), Arc::new(mock_keyring)); + let storage = AutoAuthStorage::new( + codex_home.path().to_path_buf(), + Arc::new(mock_keyring), + AuthKeyringBackendKind::Secrets, + ); let expected = auth_with_prefix("file-only"); storage.file_storage.save(&expected)?; @@ -430,8 +600,12 @@ fn auto_auth_storage_load_falls_back_when_keyring_errors() -> anyhow::Result<()> let storage = AutoAuthStorage::new( codex_home.path().to_path_buf(), Arc::new(mock_keyring.clone()), + AuthKeyringBackendKind::Secrets, ); - let key = compute_store_key(codex_home.path())?; + let key = compute_keyring_account(codex_home.path()); + + let encrypted = auth_with_prefix("encrypted"); + seed_secrets_backend_with_auth(&mock_keyring, codex_home.path(), &encrypted)?; mock_keyring.set_error(&key, KeyringError::Invalid("error".into(), "load".into())); let expected = auth_with_prefix("fallback"); @@ -449,21 +623,15 @@ fn auto_auth_storage_save_prefers_keyring() -> anyhow::Result<()> { let storage = AutoAuthStorage::new( codex_home.path().to_path_buf(), Arc::new(mock_keyring.clone()), + AuthKeyringBackendKind::Secrets, ); - let key = compute_store_key(codex_home.path())?; - let stale = auth_with_prefix("stale"); storage.file_storage.save(&stale)?; let expected = auth_with_prefix("to-save"); storage.save(&expected)?; - assert_keyring_saved_auth_and_removed_fallback( - &mock_keyring, - &key, - codex_home.path(), - &expected, - ); + assert_keyring_saved_auth_and_removed_fallback(&mock_keyring, codex_home.path(), &expected)?; Ok(()) } @@ -474,8 +642,9 @@ fn auto_auth_storage_save_falls_back_when_keyring_errors() -> anyhow::Result<()> let storage = AutoAuthStorage::new( codex_home.path().to_path_buf(), Arc::new(mock_keyring.clone()), + AuthKeyringBackendKind::Secrets, ); - let key = compute_store_key(codex_home.path())?; + let key = compute_keyring_account(codex_home.path()); mock_keyring.set_error(&key, KeyringError::Invalid("error".into(), "save".into())); let auth = auth_with_prefix("fallback"); @@ -505,19 +674,19 @@ fn auto_auth_storage_delete_removes_keyring_and_file() -> anyhow::Result<()> { let storage = AutoAuthStorage::new( codex_home.path().to_path_buf(), Arc::new(mock_keyring.clone()), + AuthKeyringBackendKind::Secrets, ); - let (key, auth_file) = - seed_keyring_and_fallback_auth_file_for_delete(&mock_keyring, codex_home.path(), || { - compute_store_key(codex_home.path()) - })?; + let auth = auth_with_prefix("to-delete"); + let auth_file = seed_secrets_backend_and_fallback_auth_file_for_delete( + &mock_keyring, + codex_home.path(), + &auth, + )?; let removed = storage.delete()?; assert!(removed, "delete should report removal"); - assert!( - !mock_keyring.contains(&key), - "keyring entry should be removed" - ); + assert_eq!(storage.load()?, None, "encrypted auth should be removed"); assert!( !auth_file.exists(), "fallback auth.json should be removed after delete" diff --git a/codex-rs/login/src/device_code_auth.rs b/codex-rs/login/src/device_code_auth.rs index 4b9cb7c32..6f18c5984 100644 --- a/codex-rs/login/src/device_code_auth.rs +++ b/codex-rs/login/src/device_code_auth.rs @@ -217,6 +217,7 @@ pub async fn complete_device_code_login( tokens.access_token, tokens.refresh_token, opts.cli_auth_credentials_store_mode, + opts.auth_keyring_backend_kind, ) .await } diff --git a/codex-rs/login/src/lib.rs b/codex-rs/login/src/lib.rs index 6b305bae2..48a24d453 100644 --- a/codex-rs/login/src/lib.rs +++ b/codex-rs/login/src/lib.rs @@ -19,6 +19,7 @@ pub use server::run_login_server; pub use auth::AuthConfig; pub use auth::AuthDotJson; +pub use auth::AuthKeyringBackendKind; pub use auth::AuthManager; pub use auth::AuthManagerConfig; pub use auth::CLIENT_ID; diff --git a/codex-rs/login/src/server.rs b/codex-rs/login/src/server.rs index b94f2ac54..67d5e8c66 100644 --- a/codex-rs/login/src/server.rs +++ b/codex-rs/login/src/server.rs @@ -25,6 +25,7 @@ use std::thread; use std::time::Duration; use crate::auth::AuthDotJson; +use crate::auth::AuthKeyringBackendKind; use crate::auth::save_auth; use crate::default_client::originator; use crate::pkce::PkceCodes; @@ -69,6 +70,7 @@ pub struct ServerOptions { pub forced_chatgpt_workspace_id: Option>, pub codex_streamlined_login: bool, pub cli_auth_credentials_store_mode: AuthCredentialsStoreMode, + pub auth_keyring_backend_kind: AuthKeyringBackendKind, } impl ServerOptions { @@ -78,6 +80,7 @@ impl ServerOptions { client_id: String, forced_chatgpt_workspace_id: Option>, cli_auth_credentials_store_mode: AuthCredentialsStoreMode, + auth_keyring_backend_kind: AuthKeyringBackendKind, ) -> Self { Self { codex_home, @@ -89,6 +92,7 @@ impl ServerOptions { forced_chatgpt_workspace_id, codex_streamlined_login: false, cli_auth_credentials_store_mode, + auth_keyring_backend_kind, } } } @@ -359,6 +363,7 @@ async fn process_request( tokens.access_token.clone(), tokens.refresh_token.clone(), opts.cli_auth_credentials_store_mode, + opts.auth_keyring_backend_kind, ) .await { @@ -789,6 +794,7 @@ pub(crate) async fn persist_tokens_async( access_token: String, refresh_token: String, auth_credentials_store_mode: AuthCredentialsStoreMode, + keyring_backend_kind: AuthKeyringBackendKind, ) -> io::Result<()> { // Reuse existing synchronous logic but run it off the async runtime. let codex_home = codex_home.to_path_buf(); @@ -814,7 +820,12 @@ pub(crate) async fn persist_tokens_async( personal_access_token: None, bedrock_api_key: None, }; - save_auth(&codex_home, &auth, auth_credentials_store_mode) + save_auth( + &codex_home, + &auth, + auth_credentials_store_mode, + keyring_backend_kind, + ) }) .await .map_err(|e| io::Error::other(format!("persist task failed: {e}")))? diff --git a/codex-rs/login/tests/suite/auth_refresh.rs b/codex-rs/login/tests/suite/auth_refresh.rs index c86338e70..314b2703f 100644 --- a/codex-rs/login/tests/suite/auth_refresh.rs +++ b/codex-rs/login/tests/suite/auth_refresh.rs @@ -6,6 +6,7 @@ use chrono::Utc; use codex_app_server_protocol::AuthMode; use codex_config::types::AuthCredentialsStoreMode; use codex_login::AuthDotJson; +use codex_login::AuthKeyringBackendKind; use codex_login::AuthManager; use codex_login::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; use codex_login::RefreshTokenError; @@ -297,6 +298,7 @@ async fn refresh_token_skips_refresh_when_auth_changed() -> Result<()> { ctx.codex_home.path(), &disk_auth, AuthCredentialsStoreMode::File, + AuthKeyringBackendKind::default(), )?; ctx.auth_manager @@ -367,6 +369,7 @@ async fn refresh_token_errors_on_account_mismatch() -> Result<()> { ctx.codex_home.path(), &disk_auth, AuthCredentialsStoreMode::File, + AuthKeyringBackendKind::default(), )?; let err = ctx @@ -543,6 +546,7 @@ async fn auth_reloads_disk_auth_when_cached_auth_is_stale() -> Result<()> { ctx.codex_home.path(), &disk_auth, AuthCredentialsStoreMode::File, + AuthKeyringBackendKind::default(), )?; let cached_auth = ctx @@ -610,6 +614,7 @@ async fn auth_reloads_disk_auth_without_calling_expired_refresh_token() -> Resul ctx.codex_home.path(), &disk_auth, AuthCredentialsStoreMode::File, + AuthKeyringBackendKind::default(), )?; let cached_auth = ctx @@ -879,6 +884,7 @@ async fn refresh_token_reloads_changed_auth_after_permanent_failure() -> Result< ctx.codex_home.path(), &disk_auth, AuthCredentialsStoreMode::File, + AuthKeyringBackendKind::default(), )?; ctx.auth_manager @@ -1007,6 +1013,7 @@ async fn unauthorized_recovery_reloads_then_refreshes_tokens() -> Result<()> { ctx.codex_home.path(), &disk_auth, AuthCredentialsStoreMode::File, + AuthKeyringBackendKind::default(), )?; let cached_before = ctx @@ -1106,6 +1113,7 @@ async fn unauthorized_recovery_errors_on_account_mismatch() -> Result<()> { ctx.codex_home.path(), &disk_auth, AuthCredentialsStoreMode::File, + AuthKeyringBackendKind::default(), )?; let cached_before = ctx @@ -1198,6 +1206,7 @@ impl RefreshTokenTestContext { /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, + AuthKeyringBackendKind::default(), ) .await; @@ -1209,9 +1218,13 @@ impl RefreshTokenTestContext { } fn load_auth(&self) -> Result { - load_auth_dot_json(self.codex_home.path(), AuthCredentialsStoreMode::File) - .context("load auth.json")? - .context("auth.json should exist") + load_auth_dot_json( + self.codex_home.path(), + AuthCredentialsStoreMode::File, + AuthKeyringBackendKind::default(), + ) + .context("load auth.json")? + .context("auth.json should exist") } async fn write_auth(&self, auth_dot_json: &AuthDotJson) -> Result<()> { @@ -1219,6 +1232,7 @@ impl RefreshTokenTestContext { self.codex_home.path(), auth_dot_json, AuthCredentialsStoreMode::File, + AuthKeyringBackendKind::default(), )?; self.auth_manager.reload().await; Ok(()) diff --git a/codex-rs/login/tests/suite/device_code_login.rs b/codex-rs/login/tests/suite/device_code_login.rs index e575e6b8c..78adc84c2 100644 --- a/codex-rs/login/tests/suite/device_code_login.rs +++ b/codex-rs/login/tests/suite/device_code_login.rs @@ -4,6 +4,7 @@ use anyhow::Context; use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use codex_config::types::AuthCredentialsStoreMode; +use codex_login::AuthKeyringBackendKind; use codex_login::ServerOptions; use codex_login::auth::load_auth_dot_json; use codex_login::run_device_code_login; @@ -110,6 +111,7 @@ fn server_opts( "client-id".to_string(), /*forced_chatgpt_workspace_id*/ None, cli_auth_credentials_store_mode, + AuthKeyringBackendKind::default(), ); opts.issuer = issuer; opts.open_browser = false; @@ -147,9 +149,13 @@ async fn device_code_login_integration_succeeds() -> anyhow::Result<()> { .await .expect("device code login integration should succeed"); - let auth = load_auth_dot_json(codex_home.path(), AuthCredentialsStoreMode::File) - .context("auth.json should load after login succeeds")? - .context("auth.json written")?; + let auth = load_auth_dot_json( + codex_home.path(), + AuthCredentialsStoreMode::File, + AuthKeyringBackendKind::default(), + ) + .context("auth.json should load after login succeeds")? + .context("auth.json written")?; // assert_eq!(auth.openai_api_key.as_deref(), Some("api-key-321")); let tokens = auth.tokens.expect("tokens persisted"); assert_eq!(tokens.access_token, "access-token-123"); @@ -193,8 +199,12 @@ async fn device_code_login_rejects_workspace_mismatch() -> anyhow::Result<()> { .expect_err("device code login should fail when workspace mismatches"); assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied); - let auth = load_auth_dot_json(codex_home.path(), AuthCredentialsStoreMode::File) - .context("auth.json should load after login fails")?; + let auth = load_auth_dot_json( + codex_home.path(), + AuthCredentialsStoreMode::File, + AuthKeyringBackendKind::default(), + ) + .context("auth.json should load after login fails")?; assert!( auth.is_none(), "auth.json should not be created when workspace validation fails" @@ -224,8 +234,12 @@ async fn device_code_login_integration_handles_usercode_http_failure() -> anyhow "unexpected error: {err:?}" ); - let auth = load_auth_dot_json(codex_home.path(), AuthCredentialsStoreMode::File) - .context("auth.json should load after login fails")?; + let auth = load_auth_dot_json( + codex_home.path(), + AuthCredentialsStoreMode::File, + AuthKeyringBackendKind::default(), + ) + .context("auth.json should load after login fails")?; assert!( auth.is_none(), "auth.json should not be created when login fails" @@ -262,6 +276,7 @@ async fn device_code_login_integration_persists_without_api_key_on_exchange_fail "client-id".to_string(), /*forced_chatgpt_workspace_id*/ None, AuthCredentialsStoreMode::File, + AuthKeyringBackendKind::default(), ); opts.issuer = issuer; opts.open_browser = false; @@ -270,9 +285,13 @@ async fn device_code_login_integration_persists_without_api_key_on_exchange_fail .await .expect("device login should succeed without API key exchange"); - let auth = load_auth_dot_json(codex_home.path(), AuthCredentialsStoreMode::File) - .context("auth.json should load after login succeeds")? - .context("auth.json written")?; + let auth = load_auth_dot_json( + codex_home.path(), + AuthCredentialsStoreMode::File, + AuthKeyringBackendKind::default(), + ) + .context("auth.json should load after login succeeds")? + .context("auth.json written")?; assert!(auth.openai_api_key.is_none()); let tokens = auth.tokens.expect("tokens persisted"); assert_eq!(tokens.access_token, "access-token-123"); @@ -312,6 +331,7 @@ async fn device_code_login_integration_handles_error_payload() -> anyhow::Result "client-id".to_string(), /*forced_chatgpt_workspace_id*/ None, AuthCredentialsStoreMode::File, + AuthKeyringBackendKind::default(), ); opts.issuer = issuer; opts.open_browser = false; @@ -326,8 +346,12 @@ async fn device_code_login_integration_handles_error_payload() -> anyhow::Result "Expected an authorization_declined / 400 / 404 error, got {err:?}" ); - let auth = load_auth_dot_json(codex_home.path(), AuthCredentialsStoreMode::File) - .context("auth.json should load after login fails")?; + let auth = load_auth_dot_json( + codex_home.path(), + AuthCredentialsStoreMode::File, + AuthKeyringBackendKind::default(), + ) + .context("auth.json should load after login fails")?; assert!( auth.is_none(), "auth.json should not be created when device auth fails" diff --git a/codex-rs/login/tests/suite/login_server_e2e.rs b/codex-rs/login/tests/suite/login_server_e2e.rs index bc7bb401e..74a9b430f 100644 --- a/codex-rs/login/tests/suite/login_server_e2e.rs +++ b/codex-rs/login/tests/suite/login_server_e2e.rs @@ -9,6 +9,7 @@ use std::time::Duration; use anyhow::Result; use base64::Engine; use codex_config::types::AuthCredentialsStoreMode; +use codex_login::AuthKeyringBackendKind; use codex_login::ServerOptions; use codex_login::run_login_server; use core_test_support::skip_if_no_network; @@ -128,6 +129,7 @@ async fn end_to_end_login_flow_persists_auth_json() -> Result<()> { force_state: Some(state), forced_chatgpt_workspace_id: Some(vec![chatgpt_account_id.to_string()]), codex_streamlined_login: false, + auth_keyring_backend_kind: AuthKeyringBackendKind::Direct, }; let server = run_login_server(opts)?; assert!( @@ -190,6 +192,7 @@ async fn creates_missing_codex_home_dir() -> Result<()> { force_state: Some(state), forced_chatgpt_workspace_id: None, codex_streamlined_login: false, + auth_keyring_backend_kind: AuthKeyringBackendKind::Direct, }; let server = run_login_server(opts)?; let login_port = server.actual_port; @@ -233,6 +236,7 @@ async fn login_server_includes_forced_workspaces_as_one_query_param() -> Result< WORKSPACE_ID_SECOND_ALLOWED.to_string(), ]), codex_streamlined_login: false, + auth_keyring_backend_kind: AuthKeyringBackendKind::Direct, }; let server = run_login_server(opts)?; let auth_url = Url::parse(&server.auth_url)?; @@ -271,6 +275,7 @@ async fn forced_chatgpt_workspace_id_mismatch_blocks_login() -> Result<()> { force_state: Some(state.clone()), forced_chatgpt_workspace_id: Some(vec![WORKSPACE_ID_ALLOWED.to_string()]), codex_streamlined_login: false, + auth_keyring_backend_kind: AuthKeyringBackendKind::Direct, }; let server = run_login_server(opts)?; assert!( @@ -331,6 +336,7 @@ async fn oauth_access_denied_missing_entitlement_blocks_login_with_clear_error() force_state: Some(state.clone()), forced_chatgpt_workspace_id: None, codex_streamlined_login: false, + auth_keyring_backend_kind: AuthKeyringBackendKind::Direct, }; let server = run_login_server(opts)?; let login_port = server.actual_port; @@ -399,6 +405,7 @@ async fn oauth_access_denied_unknown_reason_uses_generic_error_page() -> Result< force_state: Some(state.clone()), forced_chatgpt_workspace_id: None, codex_streamlined_login: false, + auth_keyring_backend_kind: AuthKeyringBackendKind::Direct, }; let server = run_login_server(opts)?; let login_port = server.actual_port; @@ -499,6 +506,7 @@ async fn falls_back_to_registered_fallback_port_when_default_port_is_in_use() -> codex_login::CLIENT_ID.to_string(), /*forced_chatgpt_workspace_id*/ None, AuthCredentialsStoreMode::File, + AuthKeyringBackendKind::default(), ); opts.issuer = issuer; opts.open_browser = false; @@ -544,6 +552,7 @@ async fn cancels_previous_login_server_when_port_is_in_use() -> Result<()> { force_state: Some("cancel_state".to_string()), forced_chatgpt_workspace_id: None, codex_streamlined_login: false, + auth_keyring_backend_kind: AuthKeyringBackendKind::Direct, }; let first_server = run_login_server(first_opts)?; @@ -565,6 +574,7 @@ async fn cancels_previous_login_server_when_port_is_in_use() -> Result<()> { force_state: Some("cancel_state_2".to_string()), forced_chatgpt_workspace_id: None, codex_streamlined_login: false, + auth_keyring_backend_kind: AuthKeyringBackendKind::Direct, }; let second_server = run_login_server(second_opts)?; diff --git a/codex-rs/login/tests/suite/logout.rs b/codex-rs/login/tests/suite/logout.rs index 30f0dd660..ace8b13b7 100644 --- a/codex-rs/login/tests/suite/logout.rs +++ b/codex-rs/login/tests/suite/logout.rs @@ -4,6 +4,7 @@ use base64::Engine; use codex_app_server_protocol::AuthMode; use codex_config::types::AuthCredentialsStoreMode; use codex_login::AuthDotJson; +use codex_login::AuthKeyringBackendKind; use codex_login::AuthManager; use codex_login::CLIENT_ID; use codex_login::CODEX_ACCESS_TOKEN_ENV_VAR; @@ -51,9 +52,15 @@ async fn logout_with_revoke_revokes_refresh_token_then_removes_auth() -> Result< codex_home.path(), &chatgpt_auth(), AuthCredentialsStoreMode::File, + AuthKeyringBackendKind::default(), )?; - let removed = logout_with_revoke(codex_home.path(), AuthCredentialsStoreMode::File).await?; + let removed = logout_with_revoke( + codex_home.path(), + AuthCredentialsStoreMode::File, + AuthKeyringBackendKind::default(), + ) + .await?; assert!(removed); assert!(!codex_home.path().join("auth.json").exists()); @@ -103,9 +110,15 @@ async fn logout_with_revoke_uses_stored_auth_when_access_token_env_is_set() -> R codex_home.path(), &chatgpt_auth(), AuthCredentialsStoreMode::File, + AuthKeyringBackendKind::default(), )?; - let removed = logout_with_revoke(codex_home.path(), AuthCredentialsStoreMode::File).await?; + let removed = logout_with_revoke( + codex_home.path(), + AuthCredentialsStoreMode::File, + AuthKeyringBackendKind::default(), + ) + .await?; assert!(removed); assert!(!codex_home.path().join("auth.json").exists()); @@ -139,9 +152,15 @@ async fn logout_with_revoke_removes_auth_when_revoke_fails() -> Result<()> { codex_home.path(), &chatgpt_auth(), AuthCredentialsStoreMode::File, + AuthKeyringBackendKind::default(), )?; - let removed = logout_with_revoke(codex_home.path(), AuthCredentialsStoreMode::File).await?; + let removed = logout_with_revoke( + codex_home.path(), + AuthCredentialsStoreMode::File, + AuthKeyringBackendKind::default(), + ) + .await?; assert!(removed); assert!(!codex_home.path().join("auth.json").exists()); @@ -174,18 +193,21 @@ async fn auth_manager_logout_with_revoke_uses_cached_auth() -> Result<()> { codex_home.path(), &chatgpt_auth_with_refresh_token(REFRESH_TOKEN), AuthCredentialsStoreMode::File, + AuthKeyringBackendKind::default(), )?; let manager = AuthManager::new( codex_home.path().to_path_buf(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, + AuthKeyringBackendKind::default(), ) .await; save_auth( codex_home.path(), &chatgpt_auth_with_refresh_token("newer-disk-refresh-token"), AuthCredentialsStoreMode::File, + AuthKeyringBackendKind::default(), )?; let removed = manager.logout_with_revoke().await?; diff --git a/codex-rs/models-manager/src/manager_tests.rs b/codex-rs/models-manager/src/manager_tests.rs index a1dcdd3d4..f7006ca90 100644 --- a/codex-rs/models-manager/src/manager_tests.rs +++ b/codex-rs/models-manager/src/manager_tests.rs @@ -3,6 +3,7 @@ use crate::ModelsManagerConfig; use chrono::Utc; use codex_app_server_protocol::AuthMode; use codex_login::AuthCredentialsStoreMode; +use codex_login::AuthKeyringBackendKind; use codex_login::AuthManager; use codex_login::CodexAuth; use codex_login::ExternalAuth; @@ -230,6 +231,7 @@ c2ln", codex_home, AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, + AuthKeyringBackendKind::default(), ) .await .expect("auth should load") diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index e662e257f..80259d892 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -7,7 +7,9 @@ use crate::legacy_core::check_execpolicy_for_warnings; use crate::legacy_core::config::Config; use crate::legacy_core::config::ConfigBuilder; use crate::legacy_core::config::ConfigOverrides; -use crate::legacy_core::config::load_config_as_toml_with_cli_and_load_options; +use crate::legacy_core::config::ConfigTomlLoadResult; +use crate::legacy_core::config::load_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; use crate::legacy_core::format_exec_policy_error_with_source; @@ -937,7 +939,7 @@ pub async fn run_main( loader_overrides.user_config_profile = Some(profile_v2.clone()); } - let bootstrap_config_toml = load_config_toml_or_exit( + let bootstrap_config = load_bootstrap_config_or_exit( &codex_home, config_cwd.as_ref(), cli_kv_overrides.clone(), @@ -946,6 +948,7 @@ pub async fn run_main( CloudConfigBundleLoader::default(), ) .await; + let bootstrap_config_toml = &bootstrap_config.config_toml; let chatgpt_base_url = bootstrap_config_toml .chatgpt_base_url @@ -957,6 +960,7 @@ pub async fn run_main( bootstrap_config_toml .cli_auth_credentials_store .unwrap_or_default(), + resolve_bootstrap_auth_keyring_backend_kind(&bootstrap_config)?, chatgpt_base_url, ) .await; @@ -969,12 +973,12 @@ pub async fn run_main( let mut manually_selected_oss_provider = None; let model_provider_override = if cli.oss { - let config_toml_with_cloud_config; + let bootstrap_config_with_cloud_config; let config_toml_for_oss = if cli.oss_provider.is_none() { // The first load intentionally skips cloud config so we can read // auth/base-url settings needed to fetch the bundle. If OSS mode // needs a default provider from config, reload with the bundle. - config_toml_with_cloud_config = load_config_toml_or_exit( + bootstrap_config_with_cloud_config = load_bootstrap_config_or_exit( &codex_home, config_cwd.as_ref(), cli_kv_overrides.clone(), @@ -983,9 +987,9 @@ pub async fn run_main( cloud_config_bundle.clone(), ) .await; - &config_toml_with_cloud_config + &bootstrap_config_with_cloud_config.config_toml } else { - &bootstrap_config_toml + bootstrap_config_toml }; let resolved = resolve_oss_provider(cli.oss_provider.as_deref(), config_toml_for_oss); @@ -1151,6 +1155,7 @@ pub async fn run_main( if let Err(err) = enforce_login_restrictions(&AuthConfig { codex_home: config.codex_home.to_path_buf(), auth_credentials_store_mode: config.cli_auth_credentials_store_mode, + keyring_backend_kind: config.auth_keyring_backend_kind(), forced_login_method: config.forced_login_method, forced_chatgpt_workspace_id: config.forced_chatgpt_workspace_id.clone(), chatgpt_base_url: Some(config.chatgpt_base_url.clone()), @@ -1419,6 +1424,7 @@ async fn run_ratatui_app( initial_config.codex_home.to_path_buf(), /*enable_codex_api_key_env*/ false, initial_config.cli_auth_credentials_store_mode, + initial_config.auth_keyring_backend_kind(), initial_config.chatgpt_base_url.clone(), ) .await; @@ -1917,15 +1923,15 @@ async fn load_config_or_exit_with_fallback_cwd( } #[allow(clippy::print_stderr)] -async fn load_config_toml_or_exit( +async fn load_bootstrap_config_or_exit( codex_home: &Path, cwd: Option<&AbsolutePathBuf>, cli_kv_overrides: Vec<(String, codex_config::TomlValue)>, loader_overrides: LoaderOverrides, strict_config: bool, cloud_config_bundle: CloudConfigBundleLoader, -) -> codex_config::config_toml::ConfigToml { - match load_config_as_toml_with_cli_and_load_options( +) -> ConfigTomlLoadResult { + match load_config_toml_with_layer_stack( codex_home, cwd, cli_kv_overrides, diff --git a/codex-rs/tui/src/local_chatgpt_auth.rs b/codex-rs/tui/src/local_chatgpt_auth.rs index 8a9ba58fc..13b88d3d5 100644 --- a/codex-rs/tui/src/local_chatgpt_auth.rs +++ b/codex-rs/tui/src/local_chatgpt_auth.rs @@ -4,6 +4,7 @@ use std::path::Path; use codex_app_server_protocol::AuthMode; use codex_config::types::AuthCredentialsStoreMode; +use codex_login::AuthKeyringBackendKind; use codex_login::load_auth_dot_json; #[derive(Debug, Clone, PartialEq, Eq)] @@ -18,9 +19,13 @@ pub(crate) fn load_local_chatgpt_auth( auth_credentials_store_mode: AuthCredentialsStoreMode, forced_chatgpt_workspace_id: Option<&[String]>, ) -> Result { - let auth = load_auth_dot_json(codex_home, auth_credentials_store_mode) - .map_err(|err| format!("failed to load local auth: {err}"))? - .ok_or_else(|| "no local auth available".to_string())?; + let auth = load_auth_dot_json( + codex_home, + auth_credentials_store_mode, + AuthKeyringBackendKind::default(), + ) + .map_err(|err| format!("failed to load local auth: {err}"))? + .ok_or_else(|| "no local auth available".to_string())?; if matches!(auth.auth_mode, Some(AuthMode::ApiKey)) || auth.openai_api_key.is_some() { return Err("local auth is not a ChatGPT login".to_string()); } @@ -112,8 +117,13 @@ mod tests { personal_access_token: None, bedrock_api_key: None, }; - save_auth(codex_home, &auth, AuthCredentialsStoreMode::File) - .expect("chatgpt auth should save"); + save_auth( + codex_home, + &auth, + AuthCredentialsStoreMode::File, + AuthKeyringBackendKind::default(), + ) + .expect("chatgpt auth should save"); } #[test] @@ -162,6 +172,7 @@ mod tests { bedrock_api_key: None, }, AuthCredentialsStoreMode::File, + AuthKeyringBackendKind::default(), ) .expect("api key auth should save"); diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index e10700ed8..fceb7fb05 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -1014,6 +1014,7 @@ mod tests { use codex_arg0::Arg0DispatchPaths; use codex_cloud_config::cloud_config_bundle_loader_for_storage; use codex_config::types::AuthCredentialsStoreMode; + use codex_login::AuthKeyringBackendKind; use pretty_assertions::assert_eq; use std::sync::Arc; @@ -1037,6 +1038,7 @@ mod tests { codex_home_path.clone(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, + AuthKeyringBackendKind::default(), "https://chatgpt.com/backend-api/".to_string(), ) .await, diff --git a/codex-rs/tui/src/session_archive_commands.rs b/codex-rs/tui/src/session_archive_commands.rs index 7de1e577c..2867d1933 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_as_toml_with_cli_and_load_options; +use crate::legacy_core::config::load_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; use codex_app_server_protocol::Thread as AppServerThread; @@ -310,7 +311,7 @@ async fn start_app_server_for_archive_command( loader_overrides.user_config_profile = Some(profile_v2.clone()); } - let config_toml = load_config_as_toml_with_cli_and_load_options( + let bootstrap_config = load_config_toml_with_layer_stack( codex_home.as_path(), config_cwd.as_ref(), cli_kv_overrides.clone(), @@ -322,6 +323,7 @@ async fn start_app_server_for_archive_command( ) .await .wrap_err("failed to load config.toml")?; + let config_toml = &bootstrap_config.config_toml; let chatgpt_base_url = config_toml .chatgpt_base_url .clone() @@ -330,12 +332,13 @@ async fn start_app_server_for_archive_command( codex_home.to_path_buf(), /*enable_codex_api_key_env*/ false, config_toml.cli_auth_credentials_store.unwrap_or_default(), + resolve_bootstrap_auth_keyring_backend_kind(&bootstrap_config)?, chatgpt_base_url, ) .await; let model_provider = if cli.oss { - resolve_oss_provider(cli.oss_provider.as_deref(), &config_toml) + resolve_oss_provider(cli.oss_provider.as_deref(), config_toml) } else { None };