diff --git a/codex-rs/app-server-protocol/schema/typescript/GetAuthStatusResponse.ts b/codex-rs/app-server-protocol/schema/typescript/GetAuthStatusResponse.ts index 9a050f4124..9313c056e3 100644 --- a/codex-rs/app-server-protocol/schema/typescript/GetAuthStatusResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/GetAuthStatusResponse.ts @@ -2,5 +2,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { AuthMode } from "./AuthMode"; +import type { RefreshTokenFailedReason } from "./RefreshTokenFailedReason"; -export type GetAuthStatusResponse = { authMethod: AuthMode | null, authToken: string | null, requiresOpenaiAuth: boolean | null, }; +export type GetAuthStatusResponse = { authMethod: AuthMode | null, authToken: string | null, refreshFailureReason: RefreshTokenFailedReason | null, requiresOpenaiAuth: boolean | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/RefreshTokenFailedReason.ts b/codex-rs/app-server-protocol/schema/typescript/RefreshTokenFailedReason.ts new file mode 100644 index 0000000000..1744f6b6b7 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/RefreshTokenFailedReason.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type RefreshTokenFailedReason = "expired" | "exhausted" | "revoked" | "other"; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index ad6376fa58..da4d492b8c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -61,6 +61,7 @@ export type { ReasoningEffort } from "./ReasoningEffort"; export type { ReasoningItemContent } from "./ReasoningItemContent"; export type { ReasoningItemReasoningSummary } from "./ReasoningItemReasoningSummary"; export type { ReasoningSummary } from "./ReasoningSummary"; +export type { RefreshTokenFailedReason } from "./RefreshTokenFailedReason"; export type { RequestId } from "./RequestId"; export type { Resource } from "./Resource"; export type { ResourceContent } from "./ResourceContent"; diff --git a/codex-rs/app-server-protocol/src/protocol/v1.rs b/codex-rs/app-server-protocol/src/protocol/v1.rs index f83674d4c3..e41f54c9d8 100644 --- a/codex-rs/app-server-protocol/src/protocol/v1.rs +++ b/codex-rs/app-server-protocol/src/protocol/v1.rs @@ -2,6 +2,7 @@ use std::collections::HashMap; use std::path::PathBuf; use codex_protocol::ThreadId; +use codex_protocol::auth::RefreshTokenFailedReason; use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::SandboxMode; @@ -193,6 +194,7 @@ pub struct ExecOneOffCommandParams { pub struct GetAuthStatusResponse { pub auth_method: Option, pub auth_token: Option, + pub refresh_failure_reason: Option, pub requires_openai_auth: Option, } diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 9fb283ffb2..ad1f7b5a65 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -371,6 +371,7 @@ use codex_memories_write::clear_memory_roots_contents; use codex_model_provider::create_model_provider; use codex_models_manager::collaboration_mode_presets::builtin_collaboration_mode_presets; use codex_protocol::ThreadId; +use codex_protocol::auth::RefreshTokenFailedReason; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::config_types::Personality; 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 debb02a39d..37ae5e072f 100644 --- a/codex-rs/app-server/src/request_processors/account_processor.rs +++ b/codex-rs/app-server/src/request_processors/account_processor.rs @@ -45,7 +45,16 @@ enum CancelLoginError { enum RefreshTokenRequestOutcome { NotAttemptedOrSucceeded, FailedTransiently, - FailedPermanently, + FailedPermanently(RefreshTokenFailedReason), +} + +impl RefreshTokenRequestOutcome { + fn failed_reason(&self) -> Option { + match self { + Self::NotAttemptedOrSucceeded | Self::FailedTransiently => None, + Self::FailedPermanently(reason) => Some(*reason), + } + } } impl Drop for ActiveLogin { @@ -744,12 +753,11 @@ impl AccountRequestProcessor { return RefreshTokenRequestOutcome::NotAttemptedOrSucceeded; } if do_refresh && let Err(err) = self.auth_manager.refresh_token().await { - let failed_reason = err.failed_reason(); - if failed_reason.is_none() { - tracing::warn!("failed to refresh token while getting account: {err}"); - return RefreshTokenRequestOutcome::FailedTransiently; + if let Some(reason) = err.failed_reason() { + return RefreshTokenRequestOutcome::FailedPermanently(reason); } - return RefreshTokenRequestOutcome::FailedPermanently; + tracing::warn!("failed to refresh token while getting account: {err}"); + return RefreshTokenRequestOutcome::FailedTransiently; } RefreshTokenRequestOutcome::NotAttemptedOrSucceeded } @@ -761,7 +769,7 @@ impl AccountRequestProcessor { let include_token = params.include_token.unwrap_or(false); let do_refresh = params.refresh_token.unwrap_or(false); - self.refresh_token_if_requested(do_refresh).await; + let refresh_outcome = self.refresh_token_if_requested(do_refresh).await; // Determine whether auth is required based on the active model provider. // If a custom provider is configured with `requires_openai_auth == false`, @@ -772,6 +780,7 @@ impl AccountRequestProcessor { GetAuthStatusResponse { auth_method: None, auth_token: None, + refresh_failure_reason: None, requires_openai_auth: Some(false), } } else { @@ -782,14 +791,17 @@ impl AccountRequestProcessor { }; match auth { Some(auth) => { - let permanent_refresh_failure = - self.auth_manager.refresh_failure_for_auth(&auth).is_some(); + let refresh_failure_reason = refresh_outcome.failed_reason().or_else(|| { + self.auth_manager + .refresh_failure_for_auth(&auth) + .map(|failure| failure.reason) + }); let auth_mode = auth.api_auth_mode(); let (reported_auth_method, token_opt) = if matches!( auth, CodexAuth::AgentIdentity(_) | CodexAuth::PersonalAccessToken(_) ) || include_token - && permanent_refresh_failure + && refresh_failure_reason.is_some() { // This response cannot represent the metadata needed to reuse these // credentials. @@ -810,12 +822,14 @@ impl AccountRequestProcessor { GetAuthStatusResponse { auth_method: reported_auth_method, auth_token: token_opt, + refresh_failure_reason, requires_openai_auth: Some(true), } } None => GetAuthStatusResponse { auth_method: None, auth_token: None, + refresh_failure_reason: None, requires_openai_auth: Some(true), }, } diff --git a/codex-rs/app-server/tests/suite/auth.rs b/codex-rs/app-server/tests/suite/auth.rs index 6e88866c7d..87a5f755ea 100644 --- a/codex-rs/app-server/tests/suite/auth.rs +++ b/codex-rs/app-server/tests/suite/auth.rs @@ -14,6 +14,7 @@ use codex_app_server_protocol::LoginAccountResponse; use codex_app_server_protocol::RequestId; use codex_config::types::AuthCredentialsStoreMode; use codex_login::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; +use codex_protocol::auth::RefreshTokenFailedReason; use pretty_assertions::assert_eq; use std::path::Path; use tempfile::TempDir; @@ -211,6 +212,7 @@ async fn get_auth_status_with_personal_access_token_omits_token() -> Result<()> GetAuthStatusResponse { auth_method: Some(AuthMode::PersonalAccessToken), auth_token: None, + refresh_failure_reason: None, requires_openai_auth: Some(true), } ); @@ -308,6 +310,7 @@ async fn get_auth_status_with_api_key_refresh_requested() -> Result<()> { GetAuthStatusResponse { auth_method: Some(AuthMode::ApiKey), auth_token: Some("sk-test-key".to_string()), + refresh_failure_reason: None, requires_openai_auth: Some(true), } ); @@ -372,6 +375,7 @@ async fn get_auth_status_omits_token_after_permanent_refresh_failure() -> Result GetAuthStatusResponse { auth_method: Some(AuthMode::Chatgpt), auth_token: None, + refresh_failure_reason: Some(RefreshTokenFailedReason::Exhausted), requires_openai_auth: Some(true), } ); @@ -454,6 +458,7 @@ async fn get_auth_status_omits_token_after_proactive_refresh_failure() -> Result GetAuthStatusResponse { auth_method: Some(AuthMode::Chatgpt), auth_token: None, + refresh_failure_reason: Some(RefreshTokenFailedReason::Exhausted), requires_openai_auth: Some(true), } ); @@ -521,6 +526,7 @@ async fn get_auth_status_returns_token_after_proactive_refresh_recovery() -> Res GetAuthStatusResponse { auth_method: Some(AuthMode::Chatgpt), auth_token: None, + refresh_failure_reason: Some(RefreshTokenFailedReason::Exhausted), requires_openai_auth: Some(true), } ); @@ -554,6 +560,7 @@ async fn get_auth_status_returns_token_after_proactive_refresh_recovery() -> Res GetAuthStatusResponse { auth_method: Some(AuthMode::Chatgpt), auth_token: Some("recovered-access-token".to_string()), + refresh_failure_reason: None, requires_openai_auth: Some(true), } ); diff --git a/codex-rs/app-server/tests/suite/v2/account.rs b/codex-rs/app-server/tests/suite/v2/account.rs index 901182e1d9..c6494937f9 100644 --- a/codex-rs/app-server/tests/suite/v2/account.rs +++ b/codex-rs/app-server/tests/suite/v2/account.rs @@ -19,8 +19,6 @@ use codex_app_server_protocol::ChatgptAuthTokensRefreshReason; use codex_app_server_protocol::ChatgptAuthTokensRefreshResponse; use codex_app_server_protocol::GetAccountParams; use codex_app_server_protocol::GetAccountResponse; -use codex_app_server_protocol::GetAuthStatusParams; -use codex_app_server_protocol::GetAuthStatusResponse; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::JSONRPCNotification; @@ -1771,22 +1769,9 @@ async fn get_account_omits_chatgpt_after_permanent_refresh_failure() -> Result<( .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; - let auth_status_request_id = mcp - .send_get_auth_status_request(GetAuthStatusParams { - include_token: Some(true), - refresh_token: Some(true), - }) - .await?; - let auth_status_resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(auth_status_request_id)), - ) - .await??; - let _: GetAuthStatusResponse = to_response(auth_status_resp)?; - let request_id = mcp .send_get_account_request(GetAccountParams { - refresh_token: false, + refresh_token: true, }) .await?; diff --git a/codex-rs/protocol/src/auth.rs b/codex-rs/protocol/src/auth.rs index 495f138f42..084af97bfc 100644 --- a/codex-rs/protocol/src/auth.rs +++ b/codex-rs/protocol/src/auth.rs @@ -1,6 +1,8 @@ +use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; use thiserror::Error; +use ts_rs::TS; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(untagged)] @@ -112,7 +114,8 @@ impl RefreshTokenFailedError { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] pub enum RefreshTokenFailedReason { Expired, Exhausted,