From d17c381d3f93412f832fed2d013e9a60ca803dfa Mon Sep 17 00:00:00 2001 From: Cooper Gamble Date: Tue, 19 May 2026 20:13:55 +0000 Subject: [PATCH] [codex-cli] eagerly refresh ChatGPT access token on startup [ci changed_files] --- codex-rs/exec/src/lib.rs | 15 ++++-- codex-rs/login/src/auth/manager.rs | 13 ++++++ codex-rs/login/tests/suite/auth_refresh.rs | 54 ++++++++++++++++++++++ codex-rs/tui/src/lib.rs | 14 +++++- 4 files changed, 91 insertions(+), 5 deletions(-) diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 8ca5ba8a9a..247e0cf3d2 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -51,7 +51,7 @@ use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::TurnStartedNotification; use codex_arg0::Arg0DispatchPaths; -use codex_cloud_requirements::cloud_requirements_loader_for_storage; +use codex_cloud_requirements::cloud_requirements_loader; use codex_config::ConfigLoadError; use codex_config::ConfigLoadOptions; use codex_config::LoaderOverrides; @@ -71,6 +71,7 @@ use codex_core::path_utils; use codex_feedback::CodexFeedback; use codex_git_utils::get_git_repo_root; use codex_login::AuthConfig; +use codex_login::AuthManager; use codex_login::default_client::set_default_client_residency_requirement; use codex_login::default_client::set_default_originator; use codex_login::enforce_login_restrictions; @@ -363,13 +364,21 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result .clone() .unwrap_or_else(|| "https://chatgpt.com/backend-api/".to_string()); // TODO(gt): Make cloud requirements failures blocking once we can fail-closed. - let cloud_requirements = cloud_requirements_loader_for_storage( + let cloud_auth_manager = AuthManager::shared( codex_home.to_path_buf(), /*enable_codex_api_key_env*/ false, config_toml.cli_auth_credentials_store.unwrap_or_default(), - chatgpt_base_url, + Some(chatgpt_base_url.clone()), ) .await; + if let Err(err) = cloud_auth_manager.refresh_managed_chatgpt_token().await { + warn!("failed to proactively refresh ChatGPT access token during CLI startup: {err}"); + } + let cloud_requirements = cloud_requirements_loader( + cloud_auth_manager, + chatgpt_base_url, + codex_home.to_path_buf(), + ); let run_cli_overrides = cli_kv_overrides.clone(); let run_loader_overrides = loader_overrides.clone(); let run_cloud_requirements = cloud_requirements.clone(); diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index d94c48165d..6c08d02f0b 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -1715,6 +1715,19 @@ impl AuthManager { } } + /// Refresh managed ChatGPT auth even when its access token has not expired yet. + /// + /// CLI startup uses this to begin sessions with a freshly issued access token. + /// Other auth modes either cannot be refreshed locally or have separate refresh + /// ownership, so they intentionally no-op here. + pub async fn refresh_managed_chatgpt_token(&self) -> Result<(), RefreshTokenError> { + if !matches!(self.auth_cached(), Some(CodexAuth::Chatgpt(_))) { + return Ok(()); + } + + self.refresh_token().await + } + /// Attempt to refresh the current auth token from the authority that issued /// the token. On success, reloads the auth state from disk so other components /// observe refreshed token. If the token refresh fails, returns the error to diff --git a/codex-rs/login/tests/suite/auth_refresh.rs b/codex-rs/login/tests/suite/auth_refresh.rs index afdf466d09..f4a68b0fa0 100644 --- a/codex-rs/login/tests/suite/auth_refresh.rs +++ b/codex-rs/login/tests/suite/auth_refresh.rs @@ -158,6 +158,60 @@ async fn refresh_token_refreshes_when_auth_is_unchanged() -> Result<()> { Ok(()) } +#[serial_test::serial(auth_refresh)] +#[tokio::test] +async fn refresh_managed_chatgpt_token_refreshes_even_when_auth_is_fresh() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/oauth/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "access_token": "new-access-token", + "refresh_token": "new-refresh-token" + }))) + .expect(1) + .mount(&server) + .await; + + let ctx = RefreshTokenTestContext::new(&server).await?; + let initial_last_refresh = Utc::now(); + let initial_tokens = build_tokens(INITIAL_ACCESS_TOKEN, INITIAL_REFRESH_TOKEN); + let initial_auth = AuthDotJson { + auth_mode: Some(AuthMode::Chatgpt), + openai_api_key: None, + tokens: Some(initial_tokens.clone()), + last_refresh: Some(initial_last_refresh), + agent_identity: None, + }; + ctx.write_auth(&initial_auth).await?; + + ctx.auth_manager + .refresh_managed_chatgpt_token() + .await + .context("managed ChatGPT refresh should succeed")?; + + let refreshed_tokens = TokenData { + access_token: "new-access-token".to_string(), + refresh_token: "new-refresh-token".to_string(), + ..initial_tokens.clone() + }; + let stored = ctx.load_auth()?; + let tokens = stored.tokens.as_ref().context("tokens should exist")?; + assert_eq!(tokens, &refreshed_tokens); + let refreshed_at = stored + .last_refresh + .as_ref() + .context("last_refresh should be recorded")?; + assert!( + *refreshed_at >= initial_last_refresh, + "last_refresh should advance" + ); + + server.verify().await; + Ok(()) +} + #[serial_test::serial(auth_refresh)] #[tokio::test] async fn refresh_token_skips_refresh_when_auth_changed() -> Result<()> { diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 92f4e99712..3ae0ca5d83 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -37,6 +37,7 @@ use codex_app_server_protocol::ThreadListCwdFilter; use codex_app_server_protocol::ThreadListParams; use codex_app_server_protocol::ThreadSortKey as AppServerThreadSortKey; use codex_app_server_protocol::ThreadSourceKind; +use codex_cloud_requirements::cloud_requirements_loader; use codex_cloud_requirements::cloud_requirements_loader_for_storage; use codex_config::CloudRequirementsLoader; use codex_config::ConfigLoadError; @@ -45,6 +46,7 @@ use codex_config::format_config_error_with_source; use codex_exec_server::EnvironmentManager; use codex_exec_server::ExecServerRuntimePaths; use codex_login::AuthConfig; +use codex_login::AuthManager; use codex_login::default_client::originator; use codex_login::default_client::set_default_client_residency_requirement; use codex_login::enforce_login_restrictions; @@ -985,13 +987,21 @@ pub async fn run_main( .chatgpt_base_url .clone() .unwrap_or_else(|| "https://chatgpt.com/backend-api/".to_string()); - let cloud_requirements = cloud_requirements_loader_for_storage( + let cloud_auth_manager = AuthManager::shared( codex_home.to_path_buf(), /*enable_codex_api_key_env*/ false, config_toml.cli_auth_credentials_store.unwrap_or_default(), - chatgpt_base_url, + Some(chatgpt_base_url.clone()), ) .await; + if let Err(err) = cloud_auth_manager.refresh_managed_chatgpt_token().await { + warn!("failed to proactively refresh ChatGPT access token during CLI startup: {err}"); + } + let cloud_requirements = cloud_requirements_loader( + cloud_auth_manager, + chatgpt_base_url, + codex_home.to_path_buf(), + ); let model_provider_override = if cli.oss { let resolved = resolve_oss_provider(