Surface refresh-token rejection errors during workspace routing (#57304)

## Why

When workspace discovery receives a 401 and token refresh is permanently rejected, the generic discovery error hides the guidance to sign in again.

## What changed

Propagate cached permanent refresh-token failures for the current credentials through workspace routing and model-provider resolution as `CodexErr::RefreshTokenFailed`. Preserve account-change checks and only surface the cached rejection when external authentication is absent and the auth generation is unchanged.

## Testing

Add an app-server regression test that simulates `refresh_token_reused` during workspace discovery and verifies that the turn fails with `Unauthorized` and tells the user to sign in again.

GitOrigin-RevId: cf89c71fd6a2103e56d637838b0cb34d58b3cc32
This commit is contained in:
smurching-oai
2026-10-26 23:21:18 +00:00
committed by copyberry
parent 3e9264fb18
commit fa4c0607d4
3 changed files with 111 additions and 5 deletions
@@ -8,9 +8,11 @@ use codex_app_server_protocol::AccountRoutingOverride;
use codex_backend_client::AccountEntry;
use codex_config::ConfigLoadError;
use codex_http_client::NetworkPolicyDenied;
use codex_login::RefreshTokenError;
use codex_login::WorkspaceRouting;
use codex_login::WorkspaceRoutingRequest;
use codex_login::WorkspaceRoutingResolver;
use codex_login::auth::RefreshTokenFailedError;
use codex_model_provider::ProviderAccount;
use codex_model_provider::ProviderAccountError;
use codex_model_provider::ProviderAccountState;
@@ -70,6 +72,8 @@ pub(super) enum WorkspaceRoutingError {
AccountChanged,
#[error("workspace routing discovery unauthorized (401)")]
DiscoveryUnauthorized,
#[error(transparent)]
RefreshTokenRejected(RefreshTokenFailedError),
#[error("workspace routing discovery failed")]
DiscoveryFailed,
#[error("selected workspace missing from routing discovery")]
@@ -188,6 +192,9 @@ impl WorkspaceRoutingResolver for AccountRequestProcessor {
.map(|account| account.workspace_routing)
.map_err(|error| match error {
AccountReadError::InvalidAccount(error) => io::Error::other(error),
AccountReadError::Routing(WorkspaceRoutingError::RefreshTokenRejected(
error,
)) => io::Error::new(io::ErrorKind::PermissionDenied, error),
AccountReadError::Routing(error) => io::Error::new(
if error == WorkspaceRoutingError::DiscoveryUnauthorized {
io::ErrorKind::PermissionDenied
@@ -398,14 +405,31 @@ impl AccountRequestProcessor {
Ok(response) => break response,
Err(error) => {
let unauthorized = error.is_unauthorized();
if unauthorized
&& recovery.has_next()
&& recovery.next().await.is_ok()
{
if unauthorized && recovery.has_next() {
let result = recovery.next().await;
let refreshed = *current_auth_changes.borrow();
if refreshed.owner_generation != auth_state.owner_generation {
return Err(WorkspaceRoutingError::AccountChanged.into());
}
if let Err(error) = result
&& refreshed.generation == discovery_generation
{
let cached_failure = self
.auth_manager
.refresh_failure_for_auth(&discovery_auth);
if let RefreshTokenError::Permanent(failed) = error
&& !self.auth_manager.has_external_auth()
&& cached_failure.as_ref() == Some(&failed)
{
return Err(
WorkspaceRoutingError::RefreshTokenRejected(failed)
.into(),
);
}
return Err(
WorkspaceRoutingError::DiscoveryUnauthorized.into()
);
}
discovery_generation = refreshed.generation;
discovery_auth = self
.auth_manager