fix(remote-control): retry after auth recovery (#23775)

## Why

When remote control hits an auth failure such as a revoked or reused
refresh token, the websocket loop falls into reconnect backoff. If the
user fixes auth while that loop is sleeping, remote control can stay
offline until the old retry timer expires because nothing wakes the loop
or resets its exhausted auth recovery state.

## What Changed

Added an auth-change watch on `AuthManager` for refresh-relevant cached
auth updates.

The remote-control websocket loop now subscribes to that signal, resets
`UnauthorizedRecovery` and reconnect backoff when auth changes, and
retries immediately instead of waiting for the previous delay.

Updated the remote-control transport test to verify that reloading auth
with the now-available account id wakes enrollment before the prior
retry delay.

## Verification

`cargo test -p codex-app-server-transport
remote_control_waits_for_account_id_before_enrolling`
This commit is contained in:
Anton Panasenko authored and GitHub committed 2026-05-21 14:38:30 -07:00
1 parent 05cf2fc4ce
commit 58be470d15
4 files changed
+153 -21

No files matched your search

+18
View File
@@ -15,6 +15,7 @@ use std::sync::RwLock;
use std::sync::atomic::AtomicU64;
use std::sync::atomic::Ordering;
use tokio::sync::Semaphore;
use tokio::sync::watch;
use codex_agent_identity::decode_agent_identity_jwt;
use codex_agent_identity::fetch_agent_identity_jwks;
@@ -1252,6 +1253,7 @@ impl UnauthorizedRecovery {
pub struct AuthManager {
codex_home: PathBuf,
inner: RwLock<CachedAuth>,
auth_change_tx: watch::Sender<u64>,
enable_codex_api_key_env: bool,
auth_credentials_store_mode: AuthCredentialsStoreMode,
forced_chatgpt_workspace_id: RwLock<Option<Vec<String>>>,
@@ -1320,12 +1322,14 @@ impl AuthManager {
.await
.ok()
.flatten();
let (auth_change_tx, _auth_change_rx) = watch::channel(0);
Self {
codex_home,
inner: RwLock::new(CachedAuth {
auth: managed_auth,
permanent_refresh_failure: None,
}),
auth_change_tx,
enable_codex_api_key_env,
auth_credentials_store_mode,
forced_chatgpt_workspace_id: RwLock::new(None),
@@ -1341,10 +1345,12 @@ impl AuthManager {
auth: Some(auth),
permanent_refresh_failure: None,
};
let (auth_change_tx, _auth_change_rx) = watch::channel(0);
Arc::new(Self {
codex_home: PathBuf::from("non-existent"),
inner: RwLock::new(cached),
auth_change_tx,
enable_codex_api_key_env: false,
auth_credentials_store_mode: AuthCredentialsStoreMode::File,
forced_chatgpt_workspace_id: RwLock::new(None),
@@ -1360,9 +1366,11 @@ impl AuthManager {
auth: Some(auth),
permanent_refresh_failure: None,
};
let (auth_change_tx, _auth_change_rx) = watch::channel(0);
Arc::new(Self {
codex_home,
inner: RwLock::new(cached),
auth_change_tx,
enable_codex_api_key_env: false,
auth_credentials_store_mode: AuthCredentialsStoreMode::File,
forced_chatgpt_workspace_id: RwLock::new(None),
@@ -1373,12 +1381,14 @@ impl AuthManager {
}
pub fn external_bearer_only(config: ModelProviderAuthInfo) -> Arc<Self> {
let (auth_change_tx, _auth_change_rx) = watch::channel(0);
Arc::new(Self {
codex_home: PathBuf::from("non-existent"),
inner: RwLock::new(CachedAuth {
auth: None,
permanent_refresh_failure: None,
}),
auth_change_tx,
enable_codex_api_key_env: false,
auth_credentials_store_mode: AuthCredentialsStoreMode::File,
forced_chatgpt_workspace_id: RwLock::new(None),
@@ -1395,6 +1405,11 @@ impl AuthManager {
self.inner.read().ok().and_then(|c| c.auth.clone())
}
/// Subscribes to cached auth changes that can affect request recovery.
pub fn auth_change_receiver(&self) -> watch::Receiver<u64> {
self.auth_change_tx.subscribe()
}
pub fn refresh_failure_for_auth(&self, auth: &CodexAuth) -> Option<RefreshTokenFailedError> {
self.inner.read().ok().and_then(|cached| {
cached
@@ -1537,6 +1552,9 @@ impl AuthManager {
}
tracing::info!("Reloaded auth, changed: {changed}");
guard.auth = new_auth;
if auth_changed_for_refresh {
self.auth_change_tx.send_modify(|revision| *revision += 1);
}
changed
} else {
false