From 13f2fffb1fed79e19f3a925e52e38bd91c5d5056 Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Mon, 19 Oct 2026 01:37:19 +0700 Subject: [PATCH 1/2] Look up the Claude OAuth email in the background --- CHANGELOG.md | 1 + rust/src/providers/claude/oauth/mod.rs | 14 +- rust/src/providers/claude/oauth/profile.rs | 269 ++++++++++++++++----- 3 files changed, 223 insertions(+), 61 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c8ded46598..2f8eb78c3d9 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ## [Windows] Unreleased +- **Claude**: the OAuth account email no longer delays a usage refresh. The profile lookup runs in the background, once per access token, with a 2-second budget, and later refreshes reuse its result. A failed lookup is not retried until the token changes or CodexBar restarts (previously every 15 minutes), and the first refresh after launch or a token change may show no email until the next refresh (upstream v0.76.0, #4538). - Keep Claude usage working when reading Claude Code's credentials is off and Claude Code's login is missing or expired: use the one saved Claude account that was added through a fresh CodexBar sign-in and never installed into Claude Code, renewing its token only in CodexBar's own store. Accounts saved by older versions or copied from Claude Code are not used; add them again to qualify. - Browser cookies: automatic import reads browsers one at a time and stops at the first session the site accepts. Typesafe, HelmCode, Hyper, Claude (web), OpenCode Go (web), Zed, Notion, and Cursor now move on to the next browser when a signed-out or expired session is rejected, instead of failing on the first browser that had any cookies, and later browsers are not decrypted once a session works. Cookie source Off still never reads a browser (upstream v0.75.0). - Ollama: the account email is read only from the settings page header (`id="header-email"`) instead of the first address anywhere on the page, so a footer or support address is no longer shown as the account; a page without that header shows no email (upstream v0.75.0). diff --git a/rust/src/providers/claude/oauth/mod.rs b/rust/src/providers/claude/oauth/mod.rs index 99279abed2c..fd65510edc6 100644 --- a/rust/src/providers/claude/oauth/mod.rs +++ b/rust/src/providers/claude/oauth/mod.rs @@ -508,17 +508,17 @@ impl ClaudeOAuthFetcher { credentials: ClaudeOAuthCredentials, gate_key: &str, ) -> Result { + let fingerprint = usage_gate::token_fingerprint(&credentials.access_token); + // Started before the usage request so a quick profile answer can still + // label this refresh; the usage request never waits for it. + let _ = profile::display_email(&self.client, &credentials.access_token, &fingerprint); let usage_response = self.fetch_usage(&credentials, gate_key).await?; let mut usage = self.build_usage_snapshot(&usage_response, &credentials); let spend_cost = apply_spend_only_extra_usage(&mut usage, &usage_response); - // Header email only; a profile failure leaves the usage result intact. + // Header email only; a missing or failed profile leaves usage intact. if usage.account_email.is_none() - && let Some(email) = profile::fetch_email( - &self.client, - &credentials.access_token, - &usage_gate::token_fingerprint(&credentials.access_token), - ) - .await + && let Some(email) = + profile::display_email(&self.client, &credentials.access_token, &fingerprint) { usage = usage.with_email(email); } diff --git a/rust/src/providers/claude/oauth/profile.rs b/rust/src/providers/claude/oauth/profile.rs index 1075e2a1acb..3b15fb43f8d 100644 --- a/rust/src/providers/claude/oauth/profile.rs +++ b/rust/src/providers/claude/oauth/profile.rs @@ -1,73 +1,98 @@ //! Best-effort Claude OAuth profile lookup for the card header email. //! -//! Upstream reads the account email from `GET /api/oauth/profile` next to the -//! usage call. The email is display metadata only: a failed, slow or -//! malformed profile response never fails the usage fetch, and the result is -//! remembered per token fingerprint so a refresh loop does not re-ask. +//! Upstream (#4538) reads the account email from `GET /api/oauth/profile` +//! off the usage refresh's critical path: one background lookup per access +//! token, bounded to [`LOOKUP_BUDGET`]. The usage refresh never waits for it, +//! so a refresh that starts the lookup may publish without an email; later +//! refreshes of the same token reuse the answer. Both outcomes are cached in +//! memory: a found email for the life of the process, a failure until the +//! token changes or CodexBar restarts. The email is display metadata for the +//! Claude card only. use std::collections::HashMap; use std::sync::{LazyLock, Mutex}; -use std::time::{Duration, Instant}; +use std::time::Duration; use reqwest::Client; -pub(super) const PROFILE_URL: &str = "https://api.anthropic.com/api/oauth/profile"; -const PROFILE_TIMEOUT: Duration = Duration::from_secs(5); -/// A failed lookup is retried after this long; a found email is kept for the -/// life of the token. -const FAILURE_RETRY: Duration = Duration::from_secs(15 * 60); +const PROFILE_URL: &str = "https://api.anthropic.com/api/oauth/profile"; +/// Whole-lookup budget: connect, response and body. +const LOOKUP_BUDGET: Duration = Duration::from_secs(2); enum CachedProfile { + Pending, Found(String), - Failed(Instant), + Failed, } +/// Keyed by token fingerprint, so a new token never borrows another token's email. static PROFILE_CACHE: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); -/// The cached answer for `fingerprint`: `Some(Some(email))` when known, -/// `Some(None)` while a recent failure is cooling down, `None` to ask again. -fn cached(fingerprint: &str, now: Instant) -> Option> { - let cache = PROFILE_CACHE.lock().unwrap_or_else(|e| e.into_inner()); - match cache.get(fingerprint)? { - CachedProfile::Found(email) => Some(Some(email.clone())), - CachedProfile::Failed(at) if now.duration_since(*at) < FAILURE_RETRY => Some(None), - CachedProfile::Failed(_) => None, - } -} - -fn remember(fingerprint: &str, email: Option<&str>, now: Instant) { - let entry = match email { - Some(email) => CachedProfile::Found(email.to_string()), - None => CachedProfile::Failed(now), - }; - PROFILE_CACHE - .lock() - .unwrap_or_else(|e| e.into_inner()) - .insert(fingerprint.to_string(), entry); +/// The known email for `access_token`, without waiting. The first call for a +/// token starts the background lookup and returns `None`, as do later calls +/// while it is pending or after it failed. +pub(super) fn display_email( + client: &Client, + access_token: &str, + fingerprint: &str, +) -> Option { + lookup( + client, + PROFILE_URL, + access_token, + fingerprint, + LOOKUP_BUDGET, + ) + .0 } -/// The account email for `access_token`, or `None` on any failure. -pub(super) async fn fetch_email( +/// [`display_email`] against `url`, also returning the started lookup task. +fn lookup( client: &Client, + url: &str, access_token: &str, fingerprint: &str, -) -> Option { - if let Some(answer) = cached(fingerprint, Instant::now()) { - return answer; + budget: Duration, +) -> (Option, Option>) { + let mut cache = PROFILE_CACHE.lock().unwrap_or_else(|e| e.into_inner()); + match cache.get(fingerprint) { + Some(CachedProfile::Found(email)) => return (Some(email.clone()), None), + Some(CachedProfile::Pending | CachedProfile::Failed) => return (None, None), + None => {} } - let email = request_email(client, access_token).await; - remember(fingerprint, email.as_deref(), Instant::now()); - email + let Ok(runtime) = tokio::runtime::Handle::try_current() else { + return (None, None); + }; + cache.insert(fingerprint.to_string(), CachedProfile::Pending); + drop(cache); + + let client = client.clone(); + let url = url.to_string(); + let access_token = access_token.to_string(); + let fingerprint = fingerprint.to_string(); + let task = runtime.spawn(async move { + let email = tokio::time::timeout(budget, request_email(&client, &url, &access_token)) + .await + .unwrap_or_else(|_| { + tracing::debug!("Claude OAuth profile lookup timed out"); + None + }); + let entry = email.map_or(CachedProfile::Failed, CachedProfile::Found); + PROFILE_CACHE + .lock() + .unwrap_or_else(|e| e.into_inner()) + .insert(fingerprint, entry); + }); + (None, Some(task)) } -async fn request_email(client: &Client, access_token: &str) -> Option { +async fn request_email(client: &Client, url: &str, access_token: &str) -> Option { let response = client - .get(PROFILE_URL) + .get(url) .header("Authorization", format!("Bearer {access_token}")) .header("Accept", "application/json") .header("anthropic-beta", "oauth-2025-04-20") - .timeout(PROFILE_TIMEOUT) .send() .await; let response = match response { @@ -88,7 +113,6 @@ async fn request_email(client: &Client, access_token: &str) -> Option { } email } - /// `account.email_address` (also `emailAddress` / `email`), falling back to /// the same keys at the top level. Blank or non-email values are ignored. pub(super) fn parse_email(body: &str) -> Option { @@ -156,18 +180,155 @@ mod tests { assert!(parse_email(r#"{"organization":{"name":"Parity Labs"}}"#).is_none()); } - #[test] - fn cache_keeps_found_emails_and_cools_down_failures() { - let now = Instant::now(); - remember("fp-found-test", Some("parity.user@example.com"), now); - assert_eq!( - cached("fp-found-test", now + Duration::from_secs(86_400)), - Some(Some("parity.user@example.com".to_string())) + // Wire tests mirror upstream `ClaudeOAuthProfileIdentityTests` (#4538). + // Every test uses its own tokens: the cache is process-wide. + + use mockito::{Matcher, Mock, Server, ServerGuard}; + use std::time::Instant; + + const TEST_BUDGET: Duration = Duration::from_secs(2); + + fn fingerprint(token: &str) -> String { + super::super::usage_gate::token_fingerprint(token) + } + + async fn profile_mock( + server: &mut ServerGuard, + token: &str, + status: usize, + calls: usize, + ) -> Mock { + server + .mock("GET", "/api/oauth/profile") + .match_header("authorization", format!("Bearer {token}").as_str()) + .match_header("anthropic-beta", "oauth-2025-04-20") + .match_query(Matcher::Any) + .with_status(status) + .with_body(format!( + r#"{{"account":{{"email_address":"{token}@example.com"}}}}"# + )) + .expect(calls) + .create_async() + .await + } + + /// One "refresh": read the cache, starting a lookup when the token is new. + async fn refresh(url: &str, token: &str, budget: Duration) -> Option { + let (email, task) = lookup(&Client::new(), url, token, &fingerprint(token), budget); + if let Some(task) = task { + task.await.unwrap(); + } + email + } + + #[tokio::test] + async fn first_refresh_starts_the_lookup_and_later_refreshes_reuse_its_email() { + let mut server = Server::new_async().await; + let token = "fixture-profile-found"; + let mock = profile_mock(&mut server, token, 200, 1).await; + let url = format!("{}/api/oauth/profile", server.url()); + + assert_eq!(refresh(&url, token, TEST_BUDGET).await, None); + for _ in 0..2 { + assert_eq!( + refresh(&url, token, TEST_BUDGET).await.as_deref(), + Some("fixture-profile-found@example.com") + ); + } + mock.assert_async().await; + } + + #[tokio::test] + async fn credential_changes_cannot_borrow_another_tokens_email() { + let mut server = Server::new_async().await; + let first = profile_mock(&mut server, "fixture-profile-first", 200, 1).await; + let second = profile_mock(&mut server, "fixture-profile-second", 200, 1).await; + let url = format!("{}/api/oauth/profile", server.url()); + + for token in [ + "fixture-profile-first", + "fixture-profile-second", + "fixture-profile-first", + ] { + refresh(&url, token, TEST_BUDGET).await; + assert_eq!( + refresh(&url, token, TEST_BUDGET).await, + Some(format!("{token}@example.com")) + ); + } + first.assert_async().await; + second.assert_async().await; + } + + #[tokio::test] + async fn failed_lookup_is_not_retried_for_the_same_token() { + let mut server = Server::new_async().await; + let token = "fixture-profile-failed"; + let mock = profile_mock(&mut server, token, 500, 1).await; + let url = format!("{}/api/oauth/profile", server.url()); + + assert_eq!(refresh(&url, token, TEST_BUDGET).await, None); + assert_eq!(refresh(&url, token, TEST_BUDGET).await, None); + assert_eq!(refresh(&url, token, TEST_BUDGET).await, None); + mock.assert_async().await; + } + + #[tokio::test] + async fn stalled_lookup_never_delays_the_caller_and_fails_soft_at_its_budget() { + // Accepts connections and never answers. + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!( + "http://{}/api/oauth/profile", + listener.local_addr().unwrap() ); + let hold = tokio::spawn(async move { + let mut held = Vec::new(); + while let Ok((socket, _)) = listener.accept().await { + held.push(socket); + } + }); + let token = "fixture-profile-stalled"; - remember("fp-failed-test", None, now); - assert_eq!(cached("fp-failed-test", now), Some(None)); - assert_eq!(cached("fp-failed-test", now + FAILURE_RETRY), None); - assert_eq!(cached("fp-unknown-test", now), None); + let started = Instant::now(); + let (email, task) = lookup( + &Client::new(), + &url, + token, + &fingerprint(token), + Duration::from_millis(200), + ); + assert_eq!(email, None); + assert!(started.elapsed() < Duration::from_millis(200)); + + task.expect("lookup started").await.unwrap(); + let (email, task) = lookup( + &Client::new(), + &url, + token, + &fingerprint(token), + Duration::from_millis(200), + ); + assert_eq!(email, None); + assert!(task.is_none(), "a timed-out token must not be retried"); + hold.abort(); + } + + #[test] + fn without_a_runtime_no_lookup_starts_and_nothing_is_cached() { + let token = "fixture-profile-no-runtime"; + let (email, task) = lookup( + &Client::new(), + "http://127.0.0.1:9/api/oauth/profile", + token, + &fingerprint(token), + TEST_BUDGET, + ); + assert!(email.is_none() && task.is_none()); + assert!( + !PROFILE_CACHE + .lock() + .unwrap() + .contains_key(&fingerprint(token)) + ); } } From 1c2a7340e1efe137847c2aebaaa6f0525b9dedc0 Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Mon, 19 Oct 2026 02:22:34 +0700 Subject: [PATCH 2/2] Look up the Claude OAuth email only for valid tokens The profile lookup now starts after the token's expiry and user:profile scope checks, as upstream validates before its lookup, so an expired or scope-less token sends no /profile request. The checks move into check_usage_credentials, which fetch_usage still runs. Tests assert no profile request for those tokens and that a valid token's lookup reaches the endpoint. --- CHANGELOG.md | 2 +- rust/src/providers/claude/oauth/mod.rs | 22 ++++-- rust/src/providers/claude/oauth/profile.rs | 91 +++++++++++++++++++++- 3 files changed, 107 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index db0d6b9a471..a27bd172ea1 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ ## [Windows] Unreleased -- **Claude**: the OAuth account email no longer delays a usage refresh. The profile lookup runs in the background, once per access token, with a 2-second budget, and later refreshes reuse its result. A failed lookup is not retried until the token changes or CodexBar restarts (previously every 15 minutes), and the first refresh after launch or a token change may show no email until the next refresh (upstream v0.76.0, #4538). +- **Claude**: the OAuth account email no longer delays a usage refresh. The profile lookup runs in the background, once per access token that passes the expiry and `user:profile` scope checks, with a 2-second budget, and later refreshes reuse its result. A failed lookup is not retried until the token changes or CodexBar restarts (previously every 15 minutes), and the first refresh after launch or a token change may show no email until the next refresh (upstream v0.76.0, #4538). - Usage cards: plan renewal dates now read "Plan renews" (in every locale) so they are not mistaken for quota resets (upstream v0.77.0 #4540). - Settings > Menu: offer a six-day (Monday–Saturday) work week for weekly pace and the session forecast, alongside 4, 5 and 7 days (upstream v0.77.0 #4548). - Qwen Cloud: treat a Team summary with an empty `Data` object as no Team subscription, so Individual-only accounts no longer show "Team usage unavailable" (upstream v0.77.0 #4527, fixes #4525). diff --git a/rust/src/providers/claude/oauth/mod.rs b/rust/src/providers/claude/oauth/mod.rs index fd65510edc6..cf3796cf50a 100644 --- a/rust/src/providers/claude/oauth/mod.rs +++ b/rust/src/providers/claude/oauth/mod.rs @@ -509,6 +509,9 @@ impl ClaudeOAuthFetcher { gate_key: &str, ) -> Result { let fingerprint = usage_gate::token_fingerprint(&credentials.access_token); + // Upstream validates the token before its profile lookup, so an expired + // or scope-less token sends no /profile request. + Self::check_usage_credentials(&credentials)?; // Started before the usage request so a quick profile answer can still // label this refresh; the usage request never waits for it. let _ = profile::display_email(&self.client, &credentials.access_token, &fingerprint); @@ -656,12 +659,9 @@ impl ClaudeOAuthFetcher { } } - /// Fetch usage data using OAuth credentials - pub async fn fetch_usage( - &self, - credentials: &ClaudeOAuthCredentials, - gate_key: &str, - ) -> Result { + /// Expiry and `user:profile` scope checks that run before any request for + /// the token, including the profile lookup. + fn check_usage_credentials(credentials: &ClaudeOAuthCredentials) -> Result<(), ProviderError> { if credentials.is_expired() { return Err(ProviderError::OAuthExpired( "OAuth token expired. Run `claude` to refresh.".to_string(), @@ -676,6 +676,16 @@ impl ClaudeOAuthFetcher { Self::scope_recovery_message() ))); } + Ok(()) + } + + /// Fetch usage data using OAuth credentials + pub async fn fetch_usage( + &self, + credentials: &ClaudeOAuthCredentials, + gate_key: &str, + ) -> Result { + Self::check_usage_credentials(credentials)?; if let Some(result) = Self::gate_precheck(&USAGE_GATE, usage_gate::now_ms(), gate_key) { return result; diff --git a/rust/src/providers/claude/oauth/profile.rs b/rust/src/providers/claude/oauth/profile.rs index 3b15fb43f8d..adec2e016a4 100644 --- a/rust/src/providers/claude/oauth/profile.rs +++ b/rust/src/providers/claude/oauth/profile.rs @@ -29,6 +29,24 @@ enum CachedProfile { static PROFILE_CACHE: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); +// Test-only profile URL, so fetch-level tests can observe lookups on a mock +// server. Thread-local because each test runs on its own thread. +#[cfg(test)] +thread_local! { + static TEST_PROFILE_URL: std::cell::RefCell> = + const { std::cell::RefCell::new(None) }; +} + +fn profile_url() -> String { + #[cfg(test)] + { + if let Some(url) = TEST_PROFILE_URL.with(|url| url.borrow().clone()) { + return url; + } + } + PROFILE_URL.to_string() +} + /// The known email for `access_token`, without waiting. The first call for a /// token starts the background lookup and returns `None`, as do later calls /// while it is pending or after it failed. @@ -39,7 +57,7 @@ pub(super) fn display_email( ) -> Option { lookup( client, - PROFILE_URL, + &profile_url(), access_token, fingerprint, LOOKUP_BUDGET, @@ -113,6 +131,7 @@ async fn request_email(client: &Client, url: &str, access_token: &str) -> Option } email } + /// `account.email_address` (also `emailAddress` / `email`), falling back to /// the same keys at the top level. Blank or non-email values are ignored. pub(super) fn parse_email(body: &str) -> Option { @@ -331,4 +350,74 @@ mod tests { .contains_key(&fingerprint(token)) ); } + + #[tokio::test] + async fn expired_or_scope_less_tokens_send_no_profile_request() { + let mut server = Server::new_async().await; + let profile = server + .mock("GET", "/api/oauth/profile") + .with_status(200) + .with_body(PROFILE_FIXTURE) + .expect(0) + .create_async() + .await; + TEST_PROFILE_URL.with(|url| { + *url.borrow_mut() = Some(format!("{}/api/oauth/profile", server.url())); + }); + let expired = super::super::ClaudeOAuthCredentials { + access_token: "fixture-profile-expired".to_string(), + refresh_token: None, + expires_at: Some(chrono::Utc::now() - chrono::Duration::minutes(10)), + scopes: vec!["user:profile".to_string()], + rate_limit_tier: None, + }; + let scope_less = super::super::ClaudeOAuthCredentials { + access_token: "fixture-profile-scope-less".to_string(), + refresh_token: None, + expires_at: None, + scopes: vec!["user:inference".to_string()], + rate_limit_tier: None, + }; + let fetcher = super::super::ClaudeOAuthFetcher::new(); + + let expired_error = fetcher + .fetch_with_credentials(expired, "fixture-gate") + .await + .err() + .unwrap(); + let scope_error = fetcher + .fetch_with_credentials(scope_less, "fixture-gate") + .await + .err() + .unwrap(); + + assert!(matches!( + expired_error, + crate::core::ProviderError::OAuthExpired(_) + )); + assert!(matches!( + scope_error, + crate::core::ProviderError::OAuth(ref message) if message.contains("user:profile") + )); + // A lookup that had started would answer the mock within milliseconds. + tokio::time::sleep(Duration::from_millis(200)).await; + profile.assert_async().await; + } + + /// Control for the test above: the mock does see a /profile request when a + /// lookup runs for a valid token. + #[tokio::test] + async fn a_valid_token_reaches_the_profile_endpoint() { + let mut server = Server::new_async().await; + let token = "fixture-profile-valid"; + let mock = profile_mock(&mut server, token, 200, 1).await; + let url = format!("{}/api/oauth/profile", server.url()); + + assert_eq!( + refresh(&url, token, TEST_BUDGET).await, + None, + "the first refresh starts the lookup" + ); + mock.assert_async().await; + } }