diff --git a/CHANGELOG.md b/CHANGELOG.md index 732c4c39375..4db8864e9f0 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ## [Windows] Unreleased +- **Claude**: OAuth usage now shows saved limit-reset credits, as the Web source already did. The usage request opts in with `cedar_ember=1` and identifies as the Claude CLI (`claude-cli/ (external, cli)`); when Claude rejects that optional query with HTTP 400 or a 403 that does not name the missing `user:profile` scope, it retries once without it, as `claude-code/`, so quota and Extra usage still appear. The version is read from `claude --version`, or 2.1.0 when it cannot be read. Missing-scope 403, 401 and 429 keep their normal handling. The cached usage body keeps reset counts and dates but never the grant identifiers (upstream 0.72.0, #3895, #4232). - Usage forecasts: an older session reset time that later moved backward no longer hides the estimated session quotas left and windows until reset for Codex and Claude. The estimator starts a new history segment at the correction and still needs three recent samples (upstream #4540). - OpenCode Go: include OpenCode v2 session history in local cost and token totals, without counting migrated messages twice (upstream v0.77.0 #4526). - Codex: session titles read from the Codex state database are capped at 256 characters when read, so a whole first prompt stored as the thread title is no longer carried into Usage & Spend session lists. Names from `session_index.jsonl` keep precedence and are not capped (upstream #4550). diff --git a/apps/desktop-tauri/scripts/proof-scenarios.json b/apps/desktop-tauri/scripts/proof-scenarios.json index 5581933dddb..633504c3c34 100644 --- a/apps/desktop-tauri/scripts/proof-scenarios.json +++ b/apps/desktop-tauri/scripts/proof-scenarios.json @@ -35,6 +35,38 @@ { "screenshot": "tray-claude-plan-hidden", "clip": "panel" } ] }, + { + "name": "tray-claude-reset-credits", + "query": "window=main&fixture=claude-reset-credits", + "size": [310, 1032], + "steps": [ + { "click": { "selector": ".provider-grid__item", "label": "Claude" } }, + { "expectText": ["claude.user@example.com", "Limit Reset Credits", "2 available"] }, + { "screenshot": "tray-claude-reset-credits", "clip": "panel" } + ] + }, + { + "name": "tray-claude-reset-credits-hidden", + "query": "window=main&fixture=claude-reset-credits-hidden", + "size": [310, 1032], + "steps": [ + { "click": { "selector": ".provider-grid__item", "label": "Claude" } }, + { "expectText": ["claude.user@example.com", "Plan renews: Dec 1, 2026"] }, + { "expectNoText": ["Limit Reset Credits", "2 available"] }, + { "screenshot": "tray-claude-reset-credits-hidden", "clip": "panel" } + ] + }, + { + "name": "tray-claude-reset-credits-absent", + "query": "window=main&fixture=default", + "size": [310, 1032], + "steps": [ + { "click": { "selector": ".provider-grid__item", "label": "Claude" } }, + { "expectText": "claude.user@example.com" }, + { "expectNoText": ["Limit Reset Credits", "2 available"] }, + { "screenshot": "tray-claude-reset-credits-absent", "clip": "panel" } + ] + }, { "name": "tray-errors", "query": "window=main&fixture=errors", diff --git a/apps/desktop-tauri/src/proof/fixtures/scenarios.ts b/apps/desktop-tauri/src/proof/fixtures/scenarios.ts index 8e5c03a358d..546cf3daf75 100644 --- a/apps/desktop-tauri/src/proof/fixtures/scenarios.ts +++ b/apps/desktop-tauri/src/proof/fixtures/scenarios.ts @@ -22,6 +22,8 @@ export const PROOF_SCENARIO_NAMES = [ "errors", "empty", "claude-plan-hidden", + "claude-reset-credits", + "claude-reset-credits-hidden", ] as const; export type ProofScenarioName = (typeof PROOF_SCENARIO_NAMES)[number]; @@ -344,12 +346,43 @@ function claudePlanHiddenScenario(now: Date): ProofScenario { }; } +/** + * Default data with a saved Claude limit-reset credit (`reset-credits` inventory + * item, as the OAuth and Web sources produce). `hidden` adds the Claude + * "Limit reset credits" section to `hiddenUsageItemIds`. + */ +function claudeResetCreditsScenario(now: Date, hidden: boolean): ProofScenario { + const base = defaultScenario(now); + return { + ...base, + name: hidden ? "claude-reset-credits-hidden" : "claude-reset-credits", + providers: base.providers.map((snapshot) => + snapshot.providerId === "claude" + ? { + ...snapshot, + inventory: [ + { + id: "reset-credits", + title: "Limit Reset Credits", + availableCount: 2, + nextExpiresAt: isoAfter(now, 3 * DAY_MINUTES), + }, + ], + hiddenUsageItemIds: hidden ? ["section:claude-reset-credits"] : [], + } + : snapshot, + ), + }; +} + const BUILDERS: Record ProofScenario> = { default: defaultScenario, "two-codex-accounts": twoCodexAccountsScenario, errors: errorsScenario, empty: emptyScenario, "claude-plan-hidden": claudePlanHiddenScenario, + "claude-reset-credits": (now) => claudeResetCreditsScenario(now, false), + "claude-reset-credits-hidden": (now) => claudeResetCreditsScenario(now, true), }; export function isProofScenarioName(value: string): value is ProofScenarioName { diff --git a/rust/src/providers/claude/oauth/mod.rs b/rust/src/providers/claude/oauth/mod.rs index 99279abed2c..5b054408c89 100644 --- a/rust/src/providers/claude/oauth/mod.rs +++ b/rust/src/providers/claude/oauth/mod.rs @@ -131,10 +131,40 @@ pub struct OAuthUsageResponse { #[serde(default)] pub iguana_necktie: Option, + /// Saved limit-reset block, present when the request opted in. Decoded + /// leniently by `reset_credits` so a malformed block never hides usage. + #[serde(default)] + cedar_ember: Option, + #[serde(default)] limits: Vec, } +/// Display-only limit-reset inventory, when the block is present and valid. +fn reset_credits_item( + response: &OAuthUsageResponse, + now: DateTime, +) -> Option { + response + .cedar_ember + .as_ref() + .and_then(|block| super::reset_credits::inventory_from_block(block, now)) +} + +/// Upstream retries without the reset opt-in on 400, and on 403 unless the +/// rejection names the missing `user:profile` scope. +fn rejects_reset_opt_in(status: reqwest::StatusCode, body: &str) -> bool { + status == reqwest::StatusCode::BAD_REQUEST + || (status == reqwest::StatusCode::FORBIDDEN && !body.contains("user:profile")) +} + +/// Status, `Retry-After` and body of one usage response. +struct UsageReply { + status: reqwest::StatusCode, + retry_after: Option, + body: String, +} + /// Display row for the promotional cloud-credits block, when present and valid. fn cloud_credits_row( response: &OAuthUsageResponse, @@ -185,6 +215,76 @@ pub(super) fn is_rate_limited_error(error: &ProviderError) -> bool { /// Claude OAuth fetcher pub struct ClaudeOAuthFetcher { client: Client, + api_base: String, + usage_gate: &'static Mutex, + version_source: ClaudeVersionSource, +} + +/// Where the Claude CLI version in the usage User-Agent comes from. +#[derive(Clone, Copy)] +enum ClaudeVersionSource { + /// Run `claude --version`, cached for `CLAUDE_VERSION_CACHE_TTL`. + Detect, + /// A fixed answer for tests. `None` exercises the fallback version. + #[cfg(test)] + Fixed(Option<&'static str>), +} + +/// Upstream `ClaudeOAuthUsageFetcher.fallbackClaudeCodeVersion`, used when the +/// CLI version cannot be detected. +const FALLBACK_CLAUDE_CODE_VERSION: &str = "2.1.0"; +/// Upstream caches the detected CLI version for 30 minutes. +const CLAUDE_VERSION_CACHE_TTL: Duration = Duration::from_secs(30 * 60); +/// Last successful `claude --version` detection, as (when, version). +static CLAUDE_VERSION_CACHE: Mutex> = Mutex::new(None); +#[cfg(test)] +const TEST_CLAUDE_CODE_VERSION: &str = "2.3.4"; + +/// Version used in the User-Agent: the detected CLI version, or the fallback +/// when detection failed or returned nothing usable. +fn claude_agent_version(detected: Option<&str>) -> &str { + detected + .map(str::trim) + .filter(|version| !version.is_empty()) + .unwrap_or(FALLBACK_CLAUDE_CODE_VERSION) +} + +/// Legacy agent string (`claude-code/`). The fallback retry sends it. +fn claude_code_user_agent(detected: Option<&str>) -> String { + format!("claude-code/{}", claude_agent_version(detected)) +} + +/// Opt-in agent string (`claude-cli/ (external, cli)`). Upstream +/// derives it from the legacy string by renaming the product. +fn claude_cli_user_agent(detected: Option<&str>) -> String { + format!( + "claude-cli/{} (external, cli)", + claude_agent_version(detected) + ) +} + +/// The Claude CLI version from `claude --version`, cached for the TTL. +/// Failed detections are not cached, so a later poll can still succeed. +async fn detected_claude_version() -> Option { + { + let cache = CLAUDE_VERSION_CACHE + .lock() + .unwrap_or_else(|e| e.into_inner()); + if let Some((cached_at, version)) = cache.as_ref() + && cached_at.elapsed() < CLAUDE_VERSION_CACHE_TTL + { + return Some(version.clone()); + } + } + // The detector blocks on a child process, so keep it off the async runtime. + let version = tokio::task::spawn_blocking(super::cli_binary::detect_claude_version) + .await + .ok() + .flatten()?; + *CLAUDE_VERSION_CACHE + .lock() + .unwrap_or_else(|e| e.into_inner()) = Some((Instant::now(), version.clone())); + Some(version) } /// Gate shared with other CodexBar processes through a small file (#775). @@ -339,12 +439,37 @@ fn apply_standalone_summary( } impl ClaudeOAuthFetcher { - const USAGE_URL: &'static str = "https://api.anthropic.com/api/oauth/usage"; + const API_BASE: &'static str = "https://api.anthropic.com"; + const USAGE_PATH: &'static str = "/api/oauth/usage"; const DEFAULT_RATE_LIMIT_BACKOFF: Duration = Duration::from_secs(5 * 60); pub fn new() -> Self { Self { client: Client::new(), + api_base: Self::API_BASE.to_string(), + usage_gate: &USAGE_GATE, + version_source: ClaudeVersionSource::Detect, + } + } + + /// A fetcher against a local server with its own in-memory usage gate, so + /// tests never touch the shared gate file. The CLI version is pinned so + /// the User-Agent is deterministic. + #[cfg(test)] + fn for_test_server(api_base: impl Into) -> Self { + Self { + client: Client::new(), + api_base: api_base.into(), + usage_gate: Box::leak(Box::new(Mutex::new(usage_gate::UsageGate::new(None)))), + version_source: ClaudeVersionSource::Fixed(Some(TEST_CLAUDE_CODE_VERSION)), + } + } + + async fn claude_code_version(&self) -> Option { + match self.version_source { + ClaudeVersionSource::Detect => detected_claude_version().await, + #[cfg(test)] + ClaudeVersionSource::Fixed(version) => version.map(str::to_string), } } @@ -524,6 +649,9 @@ impl ClaudeOAuthFetcher { } let mut result = ProviderFetchResult::new(usage, "oauth") .with_display_detail(cloud_credits_row(&usage_response, Utc::now())); + if let Some(item) = reset_credits_item(&usage_response, Utc::now()) { + result = result.with_inventory_item(item); + } if let Some(cost) = spend_cost { result = result.with_cost(cost); } @@ -677,27 +805,41 @@ impl ClaudeOAuthFetcher { ))); } - if let Some(result) = Self::gate_precheck(&USAGE_GATE, usage_gate::now_ms(), gate_key) { + if let Some(result) = Self::gate_precheck(self.usage_gate, usage_gate::now_ms(), gate_key) { return result; } - let response = self - .client - .get(Self::USAGE_URL) - .header( - "Authorization", - format!("Bearer {}", credentials.access_token), + // Opt in to the saved limit-reset block (upstream 0.72.0). Rejecting + // the optional query must not hide quota or spend, so one plain + // request follows; its outcome gets the normal handling below. + // Upstream 0.77.0: the opt-in request identifies as the Claude CLI, + // and the fallback keeps the legacy `claude-code/` string. + let usage_url = format!("{}{}", self.api_base, Self::USAGE_PATH); + let claude_version = self.claude_code_version().await; + let mut reply = self + .request_usage( + &format!("{usage_url}?{}", super::reset_credits::RESET_OPT_IN_QUERY), + &credentials.access_token, + &claude_cli_user_agent(claude_version.as_deref()), ) - .header("Accept", "application/json") - .header("anthropic-beta", "oauth-2025-04-20") - .timeout(std::time::Duration::from_secs(10)) - .send() .await?; + if rejects_reset_opt_in(reply.status, &reply.body) { + reply = self + .request_usage( + &usage_url, + &credentials.access_token, + &claude_code_user_agent(claude_version.as_deref()), + ) + .await?; + } + let UsageReply { + status, + retry_after, + body, + } = reply; - if !response.status().is_success() { - let status = response.status(); - let retry_after = Self::retry_after_duration(response.headers().get(RETRY_AFTER)); - let body = response.text().await.unwrap_or_default(); + if !status.is_success() { + let retry_after = Self::retry_after_duration(retry_after.as_ref()); // Upstream 0.50.1 #2516: distinguish revoked tokens (keyring ACL // revocation, token rotation) from merely expired/invalid ones. @@ -729,7 +871,8 @@ impl ClaudeOAuthFetcher { } if status.as_u16() == 429 { - let backoff = USAGE_GATE + let backoff = self + .usage_gate .lock() .unwrap_or_else(|e| e.into_inner()) .record_rate_limit(usage_gate::now_ms(), gate_key, retry_after); @@ -743,19 +886,54 @@ impl ClaudeOAuthFetcher { ))); } - let body = response - .text() - .await - .map_err(|e| ProviderError::Parse(format!("Failed to read OAuth response: {}", e)))?; let usage = Self::parse_usage(&body)?; - USAGE_GATE + self.usage_gate .lock() .unwrap_or_else(|e| e.into_inner()) - .record_success(usage_gate::now_ms(), gate_key, body); + .record_success( + usage_gate::now_ms(), + gate_key, + super::reset_credits::redact_usage_body(&body), + ); Ok(usage) } + async fn request_usage( + &self, + url: &str, + access_token: &str, + user_agent: &str, + ) -> Result { + let response = self + .client + .get(url) + .header("Authorization", format!("Bearer {access_token}")) + .header("Accept", "application/json") + .header("anthropic-beta", "oauth-2025-04-20") + .header("User-Agent", user_agent) + .timeout(std::time::Duration::from_secs(10)) + .send() + .await?; + let status = response.status(); + let retry_after = response.headers().get(RETRY_AFTER).cloned(); + let body = match response.text().await { + Ok(body) => body, + Err(e) if status.is_success() => { + return Err(ProviderError::Parse(format!( + "Failed to read OAuth response: {}", + e + ))); + } + Err(_) => String::new(), + }; + Ok(UsageReply { + status, + retry_after, + body, + }) + } + fn parse_usage(body: &str) -> Result { serde_json::from_str(body) .map_err(|e| ProviderError::Parse(format!("Failed to parse OAuth response: {}", e))) @@ -982,3 +1160,6 @@ fn format_reset_date(date: DateTime) -> String { #[cfg(test)] #[path = "tests.rs"] mod tests; + +#[cfg(test)] +mod reset_opt_in_tests; diff --git a/rust/src/providers/claude/oauth/reset_opt_in_tests.rs b/rust/src/providers/claude/oauth/reset_opt_in_tests.rs new file mode 100644 index 00000000000..041bcce02a0 --- /dev/null +++ b/rust/src/providers/claude/oauth/reset_opt_in_tests.rs @@ -0,0 +1,300 @@ +//! Wire tests for the OAuth usage request's `cedar_ember` opt-in (upstream +//! `ClaudeOAuthResetCreditsTests`). + +use super::{ + ClaudeOAuthCredentials, ClaudeOAuthFetcher, ClaudeVersionSource, claude_cli_user_agent, + claude_code_user_agent, is_rate_limited_error, reset_credits_item, +}; +use crate::core::ProviderError; +use chrono::Utc; +use mockito::{Matcher, Mock, Server, ServerGuard}; + +const USAGE_PATH: &str = "/api/oauth/usage"; +const GATE_KEY: &str = "gate-key-fixture"; +/// Opt-in request agent for the pinned test CLI version (upstream format). +const CLI_USER_AGENT: &str = "claude-cli/2.3.4 (external, cli)"; +/// Legacy fallback agent for the pinned test CLI version. +const LEGACY_USER_AGENT: &str = "claude-code/2.3.4"; + +const ELIGIBLE_BLOCK: &str = r#"{"eligible":true,"grants":[ + {"id":"redemption-handle","label":"private grant","resets_left":2,"resets_total":2, + "starts_at":"2020-01-01T00:00:00Z","ends_at":"2099-01-01T00:00:00Z", + "paused":false,"usable_now":false}, + {"resets_left":1,"paused":true}, + {"resets_left":1,"paused":false,"ends_at":"2020-01-01T00:00:00Z"}, + {"resets_left":1,"paused":false,"starts_at":"2099-01-01T00:00:00Z"} +]}"#; + +fn payload(block: &str) -> String { + format!( + r#"{{"five_hour":{{"utilization":12}}, + "extra_usage":{{"is_enabled":true,"monthly_limit":1000,"used_credits":300}}, + "cedar_ember":{block}}}"# + ) +} + +fn credentials(token: &str) -> ClaudeOAuthCredentials { + ClaudeOAuthCredentials { + access_token: token.to_string(), + refresh_token: None, + expires_at: None, + scopes: vec!["user:profile".to_string()], + rate_limit_tier: None, + } +} + +async fn opted_in(server: &mut ServerGuard, status: usize, body: &str, calls: usize) -> Mock { + server + .mock("GET", USAGE_PATH) + .match_query(Matcher::UrlEncoded("cedar_ember".into(), "1".into())) + .match_header("authorization", "Bearer synthetic-reset-token") + .match_header("anthropic-beta", "oauth-2025-04-20") + .match_header("user-agent", CLI_USER_AGENT) + .with_status(status) + .with_header("retry-after", "300") + .with_body(body) + .expect(calls) + .create_async() + .await +} + +async fn plain(server: &mut ServerGuard, status: usize, body: &str, calls: usize) -> Mock { + server + .mock("GET", USAGE_PATH) + .match_query(Matcher::Missing) + .match_header("authorization", "Bearer synthetic-reset-token") + .match_header("anthropic-beta", "oauth-2025-04-20") + .match_header("user-agent", LEGACY_USER_AGENT) + .with_status(status) + .with_header("retry-after", "300") + .with_body(body) + .expect(calls) + .create_async() + .await +} + +#[tokio::test] +async fn opt_in_reads_reset_inventory_and_keeps_spend_without_a_retry() { + let mut server = Server::new_async().await; + let first = opted_in(&mut server, 200, &payload(ELIGIBLE_BLOCK), 1).await; + let retry = plain(&mut server, 200, &payload("null"), 0).await; + let fetcher = ClaudeOAuthFetcher::for_test_server(server.url()); + + let usage = fetcher + .fetch_usage(&credentials("synthetic-reset-token"), GATE_KEY) + .await + .unwrap(); + + assert_eq!( + usage.extra_usage.as_ref().unwrap().used_credits, + Some(300.0) + ); + let item = reset_credits_item(&usage, Utc::now()).expect("reset inventory"); + assert_eq!(item.available_count, 2); + first.assert_async().await; + retry.assert_async().await; +} + +#[tokio::test] +async fn rejected_opt_in_retries_once_without_it_and_keeps_ordinary_usage() { + for status in [400, 403] { + let mut server = Server::new_async().await; + let first = opted_in(&mut server, status, "{}", 1).await; + let retry = plain(&mut server, 200, &payload("null"), 1).await; + let fetcher = ClaudeOAuthFetcher::for_test_server(server.url()); + + let usage = fetcher + .fetch_usage(&credentials("synthetic-reset-token"), GATE_KEY) + .await + .unwrap(); + + assert_eq!( + usage.five_hour.as_ref().unwrap().utilization, + Some(12.0), + "status {status}" + ); + assert!(usage.extra_usage.is_some(), "status {status}"); + assert!(reset_credits_item(&usage, Utc::now()).is_none()); + first.assert_async().await; + retry.assert_async().await; + } +} + +#[tokio::test] +async fn authentication_scope_rate_limit_and_server_errors_do_not_retry() { + let body = r#"{"error":"Missing required scope user:profile"}"#; + for status in [401, 403, 429, 500] { + let mut server = Server::new_async().await; + let first = opted_in(&mut server, status, body, 1).await; + let retry = plain(&mut server, 200, &payload("null"), 0).await; + let fetcher = ClaudeOAuthFetcher::for_test_server(server.url()); + + let error = fetcher + .fetch_usage(&credentials("synthetic-reset-token"), GATE_KEY) + .await + .err() + .unwrap(); + + match status { + 401 => assert!(matches!(error, ProviderError::OAuthExpired(_))), + 403 => assert!( + matches!(&error, ProviderError::OAuth(m) if m.contains("scope requirement 'user:profile'")) + ), + 429 => assert!(is_rate_limited_error(&error)), + _ => assert!(matches!(&error, ProviderError::OAuth(m) if m.contains("500"))), + } + first.assert_async().await; + retry.assert_async().await; + } +} + +#[tokio::test] +async fn ordinary_usage_rejection_ends_the_fallback_after_two_requests() { + for status in [400, 403] { + let mut server = Server::new_async().await; + let first = opted_in(&mut server, status, "{}", 1).await; + let retry = plain(&mut server, status, "{}", 1).await; + let fetcher = ClaudeOAuthFetcher::for_test_server(server.url()); + + let error = fetcher + .fetch_usage(&credentials("synthetic-reset-token"), GATE_KEY) + .await + .err() + .unwrap(); + + assert!( + matches!(&error, ProviderError::OAuth(m) if m.contains(&status.to_string())), + "status {status}: {error:?}" + ); + first.assert_async().await; + retry.assert_async().await; + } +} + +#[tokio::test] +async fn fallback_preserves_terminal_errors_and_the_rate_limit_block() { + for status in [401, 429, 500] { + let mut server = Server::new_async().await; + let first = opted_in(&mut server, 400, "{}", 1).await; + let retry = plain(&mut server, status, "{}", 1).await; + let fetcher = ClaudeOAuthFetcher::for_test_server(server.url()); + let creds = credentials("synthetic-reset-token"); + + let error = fetcher.fetch_usage(&creds, GATE_KEY).await.err().unwrap(); + + match status { + 401 => assert!(matches!(error, ProviderError::OAuthExpired(_))), + 429 => { + assert!(is_rate_limited_error(&error)); + // The block holds: no further request goes out. + let again = fetcher.fetch_usage(&creds, GATE_KEY).await.err().unwrap(); + assert!(is_rate_limited_error(&again)); + } + _ => assert!(matches!(&error, ProviderError::OAuth(m) if m.contains("500"))), + } + first.assert_async().await; + retry.assert_async().await; + } +} + +#[tokio::test] +async fn the_gate_keeps_reset_counts_but_never_redemption_handles() { + let mut server = Server::new_async().await; + let first = opted_in(&mut server, 200, &payload(ELIGIBLE_BLOCK), 1).await; + let fetcher = ClaudeOAuthFetcher::for_test_server(server.url()); + let creds = credentials("synthetic-reset-token"); + let live = fetcher.fetch_usage(&creds, GATE_KEY).await.unwrap(); + + let decision = fetcher + .usage_gate + .lock() + .unwrap() + .check(super::usage_gate::now_ms(), GATE_KEY); + let super::usage_gate::Decision::Cached(stored) = decision else { + panic!("expected a cached body, got {decision:?}"); + }; + assert!(!stored.contains("redemption-handle")); + assert!(!stored.contains("private grant")); + assert!(!stored.contains("usable_now")); + + // A cached answer is served without a request and keeps the inventory. + let cached = fetcher.fetch_usage(&creds, GATE_KEY).await.unwrap(); + assert_eq!( + reset_credits_item(&cached, Utc::now()).map(|item| item.available_count), + Some(2) + ); + assert_eq!( + cached.five_hour.as_ref().unwrap().utilization, + live.five_hour.as_ref().unwrap().utilization + ); + let (cached_extra, live_extra) = ( + cached.extra_usage.as_ref().unwrap(), + live.extra_usage.as_ref().unwrap(), + ); + assert_eq!(cached_extra.is_enabled, live_extra.is_enabled); + assert_eq!(cached_extra.used_credits, live_extra.used_credits); + assert_eq!(cached_extra.monthly_limit, live_extra.monthly_limit); + first.assert_async().await; +} + +#[tokio::test] +async fn user_agents_fall_back_to_the_default_version_when_detection_fails() { + let mut server = Server::new_async().await; + let first = server + .mock("GET", USAGE_PATH) + .match_query(Matcher::UrlEncoded("cedar_ember".into(), "1".into())) + .match_header("user-agent", "claude-cli/2.1.0 (external, cli)") + .with_status(400) + .with_body("{}") + .expect(1) + .create_async() + .await; + let retry = server + .mock("GET", USAGE_PATH) + .match_query(Matcher::Missing) + .match_header("user-agent", "claude-code/2.1.0") + .with_status(200) + .with_body(payload("null")) + .expect(1) + .create_async() + .await; + let mut fetcher = ClaudeOAuthFetcher::for_test_server(server.url()); + fetcher.version_source = ClaudeVersionSource::Fixed(None); + + fetcher + .fetch_usage(&credentials("synthetic-reset-token"), GATE_KEY) + .await + .unwrap(); + + first.assert_async().await; + retry.assert_async().await; +} + +#[test] +fn user_agent_strings_follow_upstream_format() { + assert_eq!(claude_code_user_agent(Some(" 2.3.4 ")), "claude-code/2.3.4"); + assert_eq!( + claude_cli_user_agent(Some("2.3.4")), + "claude-cli/2.3.4 (external, cli)" + ); + assert_eq!( + claude_cli_user_agent(None), + "claude-cli/2.1.0 (external, cli)" + ); + assert_eq!(claude_code_user_agent(Some(" ")), "claude-code/2.1.0"); +} + +#[test] +fn absent_ineligible_or_malformed_inventory_preserves_quota_and_spend() { + for block in [ + "null", + "{}", + r#"{"eligible":false,"grants":[]}"#, + r#"{"eligible":true,"grants":"invalid"}"#, + ] { + let usage = ClaudeOAuthFetcher::parse_usage(&payload(block)).expect("usage parses"); + assert_eq!(usage.five_hour.as_ref().unwrap().utilization, Some(12.0)); + assert!(usage.extra_usage.is_some()); + assert!(reset_credits_item(&usage, Utc::now()).is_none(), "{block}"); + } +} diff --git a/rust/src/providers/claude/oauth/usage_gate.rs b/rust/src/providers/claude/oauth/usage_gate.rs index 4eda226f732..270fa8ce00d 100644 --- a/rust/src/providers/claude/oauth/usage_gate.rs +++ b/rust/src/providers/claude/oauth/usage_gate.rs @@ -18,6 +18,8 @@ //! and served again for 3 minutes to that same account. The response holds //! only utilization percentages, reset timestamps and credit amounts (see //! `OAuthUsageResponse` in `oauth/mod.rs`), no token or account identity. +//! Saved limit-reset grants are stored as counts and dates only; their +//! redemption handles are removed first (`reset_credits::redact_usage_body`). //! Precedent: the CLI probe screen is persisted across processes the same way. //! The token itself is never written; keys are truncated SHA-256 digests. //! * Blocks and cached bodies are bounded to `MAX_CACHED_SIGN_INS` accounts. diff --git a/rust/src/providers/claude/reset_credits.rs b/rust/src/providers/claude/reset_credits.rs index c8431c60e80..1ac1b6dba7a 100644 --- a/rust/src/providers/claude/reset_credits.rs +++ b/rust/src/providers/claude/reset_credits.rs @@ -1,18 +1,31 @@ //! Claude usage-limit resets ("Reset for free" in Claude Settings > Usage), -//! read from the `cedar_ember` block of the Claude Web usage response. +//! read from the `cedar_ember` block of the Claude Web and OAuth usage +//! responses. //! //! The result is a display-only [`ProviderInventoryItem`]: a count plus the //! soonest expiry. Grant identifiers are redemption handles and are never //! deserialized, so they cannot reach the bridge, CLI output, or any -//! persisted snapshot. Only the Web source reads this block. +//! persisted snapshot. A raw OAuth body that is kept on disk goes through +//! [`redact_usage_body`] first. use chrono::{DateTime, Utc}; use serde::{Deserialize, Deserializer}; use crate::core::ProviderInventoryItem; +/// Query flag that opts a usage request in to the `cedar_ember` block. +pub(super) const RESET_OPT_IN_QUERY: &str = "cedar_ember=1"; + const INVENTORY_ID: &str = "reset-credits"; const INVENTORY_TITLE: &str = "Limit Reset Credits"; +/// Grant fields [`Grant`] reads; everything else is dropped before a body is stored. +const GRANT_FIELDS: [&str; 5] = [ + "resets_left", + "resets_total", + "starts_at", + "ends_at", + "paused", +]; /// Observed grants hold one reset each; a larger total is treated as /// malformed instead of allocated. @@ -30,6 +43,49 @@ pub(super) fn inventory_from_block( ResetStatus::deserialize(block).ok()?.inventory(now) } +/// A usage body safe to persist. The `cedar_ember` block keeps only +/// `eligible` and the grant fields this module reads, so redemption handles +/// never reach disk while a stored body still yields the same inventory. A +/// non-object grant record becomes `null`, which keeps the record count that +/// [`MAX_GRANT_RECORDS`] checks. A body without the block is returned as is. +pub(super) fn redact_usage_body(body: &str) -> String { + let Ok(serde_json::Value::Object(mut root)) = serde_json::from_str(body) else { + return body.to_string(); + }; + let Some(block) = root.get_mut("cedar_ember") else { + return body.to_string(); + }; + *block = redact_block(block); + serde_json::Value::Object(root).to_string() +} + +fn redact_block(block: &serde_json::Value) -> serde_json::Value { + let serde_json::Value::Object(block) = block else { + return serde_json::Value::Null; + }; + let mut redacted = serde_json::Map::new(); + if let Some(eligible) = block.get("eligible") { + redacted.insert("eligible".to_string(), eligible.clone()); + } + if let Some(serde_json::Value::Array(grants)) = block.get("grants") { + let grants = grants + .iter() + .map(|grant| match grant { + serde_json::Value::Object(fields) => serde_json::Value::Object( + fields + .iter() + .filter(|(key, _)| GRANT_FIELDS.contains(&key.as_str())) + .map(|(key, value)| (key.clone(), value.clone())) + .collect(), + ), + _ => serde_json::Value::Null, + }) + .collect(); + redacted.insert("grants".to_string(), serde_json::Value::Array(grants)); + } + serde_json::Value::Object(redacted) +} + /// Raw `cedar_ember` block. Only `eligible: true` yields an inventory. #[derive(Debug, Deserialize)] struct ResetStatus { diff --git a/rust/src/providers/claude/reset_credits/tests.rs b/rust/src/providers/claude/reset_credits/tests.rs index de3628c8515..0874e7ca94d 100644 --- a/rust/src/providers/claude/reset_credits/tests.rs +++ b/rust/src/providers/claude/reset_credits/tests.rs @@ -1,4 +1,4 @@ -use super::{MAX_GRANT_RECORDS, MAX_RESETS, inventory_from_block}; +use super::{MAX_GRANT_RECORDS, MAX_RESETS, inventory_from_block, redact_usage_body}; use chrono::{DateTime, Duration, TimeZone, Utc}; use serde_json::{Value, json}; @@ -185,3 +185,60 @@ fn ineligible_absent_or_unreadable_block_shows_nothing() { assert_eq!(inventory_from_block(&block, now()), None, "{block}"); } } + +/// Inventory read back from a stored (redacted) usage body. +fn stored_count(block: &Value) -> Option<(u32, Option>)> { + let body = json!({ "five_hour": { "utilization": 12 }, "cedar_ember": block }).to_string(); + let stored: Value = serde_json::from_str(&redact_usage_body(&body)).unwrap(); + count_and_expiry(&stored["cedar_ember"]) +} + +#[test] +fn redacted_body_drops_grant_handles_and_keeps_the_inventory() { + let block = eligible(vec![ + grant(2, Some(DAY)), + with(grant(1, None), "paused", json!(true)), + json!("not a grant"), + ]); + let body = json!({ "five_hour": { "utilization": 12 }, "cedar_ember": block }).to_string(); + + let stored = redact_usage_body(&body); + + for hidden in ["grant_secret", "Fixture reset", "clears", "usable_now"] { + assert!(!stored.contains(hidden), "{hidden} reached the stored body"); + } + let stored: Value = serde_json::from_str(&stored).unwrap(); + assert_eq!(stored["five_hour"]["utilization"], json!(12)); + assert_eq!(stored_count(&block), count_and_expiry(&block)); + assert_eq!( + count_and_expiry(&stored["cedar_ember"]).map(|(n, _)| n), + Some(2) + ); +} + +#[test] +fn redaction_keeps_the_grant_record_cap_and_unreadable_blocks() { + for records in [MAX_GRANT_RECORDS, MAX_GRANT_RECORDS + 1] { + let block = eligible( + std::iter::once(grant(1, Some(DAY))) + .chain(std::iter::repeat_n(json!({}), records - 1)) + .collect(), + ); + assert_eq!(stored_count(&block), count_and_expiry(&block), "{records}"); + } + for block in [ + json!(null), + json!("eligible"), + json!({ "eligible": true, "grants": "invalid" }), + json!({ "eligible": false, "grants": [grant(1, Some(DAY))] }), + ] { + assert_eq!(stored_count(&block), None, "{block}"); + } +} + +#[test] +fn bodies_without_the_block_are_stored_unchanged() { + for body in [r#"{"five_hour":{"utilization":12}}"#, "not json", "[1,2]"] { + assert_eq!(redact_usage_body(body), body); + } +} diff --git a/rust/src/providers/claude/web_api.rs b/rust/src/providers/claude/web_api.rs index a0e6331ec3a..c09c959bfba 100755 --- a/rust/src/providers/claude/web_api.rs +++ b/rust/src/providers/claude/web_api.rs @@ -11,15 +11,12 @@ use crate::core::{ use super::CLOUDFLARE_CHALLENGE_MESSAGE; use super::cloud_credits; -use super::reset_credits; +use super::reset_credits::{self, RESET_OPT_IN_QUERY}; use super::spend_window; use super::subscription_details; const CLOUDFLARE_BODY_PREFIX_BYTES: usize = 64 * 1024; -/// Query flag that opts the usage request in to the `cedar_ember` block. -const RESET_OPT_IN_QUERY: &str = "cedar_ember=1"; - fn is_cloudflare_challenge_response( status: StatusCode, headers: &header::HeaderMap, @@ -448,7 +445,7 @@ impl ClaudeWebApiFetcher { let mut result = ProviderFetchResult::new(snapshot, "web") .with_display_detail(cloud_credits_row(&usage, Utc::now())); - // Web-only, display-only limit-reset inventory (never persisted). + // Display-only limit-reset inventory (never persisted). if let Some(item) = usage .cedar_ember .as_ref()