From 7370404280e83171679b8226fc779f42d194c4f9 Mon Sep 17 00:00:00 2001 From: tsouth89 Date: Sat, 1 Aug 2026 00:19:42 -0400 Subject: [PATCH 1/2] Harden reliability and account isolation --- .../src-tauri/src/capacity_events.rs | 2 +- .../src-tauri/src/commands/chart.rs | 81 +++++++++-- .../src-tauri/src/geometry_store.rs | 2 +- .../src-tauri/src/quota_run_history.rs | 43 ++++-- .../src-tauri/src/usage_history.rs | 94 ++++++++++--- .../desktop-tauri/src/components/MenuCard.tsx | 3 +- apps/desktop-tauri/src/lib/tauri.ts | 2 + .../src/surfaces/ChartsPanel.tsx | 3 +- .../src/surfaces/ProviderComparison.tsx | 2 +- .../src/surfaces/ProviderDetailView.tsx | 14 +- .../src/surfaces/TrayPanel.test.tsx | 6 +- .../settings/providers/ProviderDetailPane.tsx | 1 + .../sections/charts/ChartsSection.test.tsx | 26 ++++ .../sections/charts/ChartsSection.tsx | 32 +++-- rust/src/core/jsonl_scanner.rs | 2 +- rust/src/core/openai_dashboard.rs | 2 +- rust/src/core/widget_snapshot.rs | 4 +- .../claude/oauth/credentials_store.rs | 19 +-- rust/src/providers/claude/web_api.rs | 18 ++- rust/src/secure_file.rs | 133 +++++++++++++++++- 20 files changed, 395 insertions(+), 94 deletions(-) diff --git a/apps/desktop-tauri/src-tauri/src/capacity_events.rs b/apps/desktop-tauri/src-tauri/src/capacity_events.rs index e0315b54..3b471965 100644 --- a/apps/desktop-tauri/src-tauri/src/capacity_events.rs +++ b/apps/desktop-tauri/src-tauri/src/capacity_events.rs @@ -485,7 +485,7 @@ impl CapacityEventObserver { } match serde_json::to_vec_pretty(self) { Ok(bytes) => { - if let Err(error) = fs::write(path, bytes) { + if let Err(error) = codexbar::secure_file::atomic_write(path, &bytes) { tracing::warn!("failed to persist capacity-event history: {error}"); } } diff --git a/apps/desktop-tauri/src-tauri/src/commands/chart.rs b/apps/desktop-tauri/src-tauri/src/commands/chart.rs index 261859c3..6c0fe12e 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/chart.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/chart.rs @@ -27,7 +27,7 @@ const CHART_CACHE_TTL: Duration = Duration::from_secs(5 * 60); // Version 7: Codex priority/fast (2x) pricing for local cost. Entries cached at // version 6 still hold pre-2x standard dollars for reset windows, so the // Weekly window card could show ~half of the API-value ring after 1.5.13. -const CHART_CACHE_VERSION: u8 = 7; +const CHART_CACHE_VERSION: u8 = 8; /// A single (date, value) point for cost or credits history charts. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -362,8 +362,13 @@ struct PersistedChartCache { pub fn get_quota_run_history( provider_id: String, account_email: Option, + account_id: Option, ) -> Vec { - crate::quota_run_history::list_runs(&provider_id, account_email.as_deref()) + crate::quota_run_history::list_runs( + &provider_id, + account_email.as_deref(), + account_id.as_deref(), + ) } /// Latest quota-run efficiency cards per window (SOU-299). @@ -371,8 +376,13 @@ pub fn get_quota_run_history( pub fn get_quota_run_efficiency( provider_id: String, account_email: Option, + account_id: Option, ) -> Vec { - crate::quota_run_history::efficiency_for_provider(&provider_id, account_email.as_deref()) + crate::quota_run_history::efficiency_for_provider( + &provider_id, + account_email.as_deref(), + account_id.as_deref(), + ) } #[tauri::command] @@ -395,12 +405,16 @@ pub async fn get_provider_chart_data( let cache_key = chart_cache_key( &provider_id, account_email.as_deref(), + account_id.as_deref(), source_label.as_deref(), &usage_windows, ); if let Some(mut cached) = cached_chart_data(&cache_key) { - cached.data.quota_history = - crate::usage_history::provider_history(&provider_id, account_email.as_deref()); + cached.data.quota_history = crate::usage_history::provider_history( + &provider_id, + account_email.as_deref(), + account_id.as_deref(), + ); if current_unix_ms().saturating_sub(cached.refreshed_at_ms) > CHART_CACHE_TTL.as_millis() as i64 { @@ -408,6 +422,7 @@ pub async fn get_provider_chart_data( cache_key, provider_id, account_email, + account_id, scoped_home.clone(), usage_windows, ); @@ -415,8 +430,11 @@ pub async fn get_provider_chart_data( return cached.data; } - let quota_history = - crate::usage_history::provider_history(&provider_id, account_email.as_deref()); + let quota_history = crate::usage_history::provider_history( + &provider_id, + account_email.as_deref(), + account_id.as_deref(), + ); if !quota_history.is_empty() { let mut immediate = ProviderChartData::empty(provider_id.clone()); immediate.quota_history = quota_history; @@ -424,6 +442,7 @@ pub async fn get_provider_chart_data( cache_key, provider_id, account_email, + account_id, scoped_home.clone(), usage_windows, ); @@ -436,6 +455,7 @@ pub async fn get_provider_chart_data( build_provider_chart_data_with_cancel( provider_id, account_email, + account_id, scoped_home, usage_windows, Some(cancel), @@ -483,6 +503,7 @@ fn schedule_chart_cache_refresh( key: String, provider_id: String, account_email: Option, + account_id: Option, scoped_home: Option, usage_windows: Vec, ) { @@ -500,6 +521,7 @@ fn schedule_chart_cache_refresh( build_provider_chart_data_with_cancel( provider_id, account_email, + account_id, scoped_home, usage_windows, None, @@ -519,12 +541,18 @@ fn schedule_chart_cache_refresh( fn chart_cache_key( provider_id: &str, account_email: Option<&str>, + account_id: Option<&str>, source_label: Option<&str>, usage_windows: &[LocalUsageWindowRequest], ) -> String { - let identity = account_email + let identity = account_id .map(str::trim) .filter(|value| !value.is_empty()) + .or_else(|| { + account_email + .map(str::trim) + .filter(|value| !value.is_empty()) + }) .unwrap_or("anonymous") .to_ascii_lowercase(); let windows = usage_windows @@ -585,7 +613,7 @@ fn persist_chart_cache(cache: &PersistedChartCache) { } match serde_json::to_vec(cache) { Ok(bytes) => { - if let Err(error) = fs::write(path, bytes) { + if let Err(error) = codexbar::secure_file::atomic_write(&path, &bytes) { tracing::warn!("failed to persist chart cache: {error}"); } } @@ -1113,12 +1141,13 @@ pub(crate) fn build_provider_chart_data( provider_id: String, account_email: Option, ) -> ProviderChartData { - build_provider_chart_data_with_cancel(provider_id, account_email, None, Vec::new(), None) + build_provider_chart_data_with_cancel(provider_id, account_email, None, None, Vec::new(), None) } fn build_provider_chart_data_with_cancel( provider_id: String, account_email: Option, + account_id: Option, scoped_home: Option, usage_window_requests: Vec, cancel: Option>, @@ -1184,6 +1213,7 @@ fn build_provider_chart_data_with_cancel( quota_history: crate::usage_history::provider_history( &provider_id, account_email.as_deref(), + account_id.as_deref(), ), provider_id, cost_history, @@ -1793,11 +1823,12 @@ mod tests { use super::{ CostFetchFailure, LocalEffortCost, LocalModelCost, LocalPlanUsage, LocalProjectCost, LocalTokenBreakdown, LocalUsageWindowRequest, ProviderLocalUsageSummary, api_value_period, - comparison_period_specs, cost_fetch_failure_allows_early_retry, daily_series_from_report, - effort_breakdown, format_cost_csv, local_midnight_in_tz, local_usage_summary_from_report, - local_yesterday_window_utc, localized_estimate_note, model_breakdown, - parse_api_value_custom_range, period_from_daily_series, pricing_coverage_tokens, - project_breakdown, spend_budget_period_details, token_breakdown, token_cost_cache_is_fresh, + chart_cache_key, comparison_period_specs, cost_fetch_failure_allows_early_retry, + daily_series_from_report, effort_breakdown, format_cost_csv, local_midnight_in_tz, + local_usage_summary_from_report, local_yesterday_window_utc, localized_estimate_note, + model_breakdown, parse_api_value_custom_range, period_from_daily_series, + pricing_coverage_tokens, project_breakdown, spend_budget_period_details, token_breakdown, + token_cost_cache_is_fresh, }; use crate::commands::is_provider_cache_fresh; use chrono::{Local, LocalResult, NaiveDate, NaiveTime, TimeZone, Timelike, Utc}; @@ -1805,6 +1836,26 @@ mod tests { use codexbar::settings::Language; use std::time::{Duration, Instant}; + #[test] + fn chart_cache_separates_managed_accounts_that_share_an_email() { + let personal = chart_cache_key( + "codex", + Some("shared@example.com"), + Some("acct-personal"), + Some("oauth"), + &[], + ); + let work = chart_cache_key( + "codex", + Some("shared@example.com"), + Some("acct-work"), + Some("oauth"), + &[], + ); + + assert_ne!(personal, work); + } + #[test] fn token_cost_age_does_not_use_provider_quota_age() { let now = Instant::now(); diff --git a/apps/desktop-tauri/src-tauri/src/geometry_store.rs b/apps/desktop-tauri/src-tauri/src/geometry_store.rs index 1a60aea9..cf5dc2b6 100644 --- a/apps/desktop-tauri/src-tauri/src/geometry_store.rs +++ b/apps/desktop-tauri/src-tauri/src/geometry_store.rs @@ -117,7 +117,7 @@ fn save_file(file: &GeometryFile) -> Result<(), String> { fs::create_dir_all(parent).map_err(|e| e.to_string())?; } let json = serde_json::to_string_pretty(file).map_err(|e| e.to_string())?; - fs::write(&path, json).map_err(|e| e.to_string()) + codexbar::secure_file::atomic_write(&path, json.as_bytes()).map_err(|e| e.to_string()) } /// Look up remembered geometry for a surface mode. Returns `None` when the diff --git a/apps/desktop-tauri/src-tauri/src/quota_run_history.rs b/apps/desktop-tauri/src-tauri/src/quota_run_history.rs index 947c22d7..1d98c18a 100644 --- a/apps/desktop-tauri/src-tauri/src/quota_run_history.rs +++ b/apps/desktop-tauri/src-tauri/src/quota_run_history.rs @@ -15,7 +15,7 @@ use serde::{Deserialize, Serialize}; use crate::capacity_events::{CapacityEventKind, CapacityEventPayload}; use crate::commands::{ProviderUsageSnapshot, RateWindowSnapshot}; -const STORE_VERSION: u8 = 2; +const STORE_VERSION: u8 = 3; /// Keep enough history for run-over-run efficiency (SOU-299) without unbounded growth. const MAX_RUNS_PER_SCOPE: usize = 40; const RETENTION_DAYS: i64 = 120; @@ -309,14 +309,21 @@ pub fn record_capacity_events(events: &[CapacityEventPayload], snapshot: &Provid } /// Completed runs for a provider/account, chronological (oldest first). -pub fn list_runs(provider_id: &str, account_email: Option<&str>) -> Vec { +pub fn list_runs( + provider_id: &str, + account_email: Option<&str>, + account_id: Option<&str>, +) -> Vec { let Ok(guard) = store().lock() else { return Vec::new(); }; - let exact = scope_key_parts(provider_id, account_email, None, None); + let exact = scope_key_parts(provider_id, account_email, account_id, None); if let Some(runs) = guard.runs.get(&exact) { return runs.clone(); } + if account_id.is_some() { + return Vec::new(); + } // Fall back to anonymous series for this provider only (never another seat). if account_email.is_some() { let anonymous = scope_key_parts(provider_id, None, None, None); @@ -331,8 +338,9 @@ pub fn list_runs(provider_id: &str, account_email: Option<&str>) -> Vec, + account_id: Option<&str>, ) -> Vec { - let runs = list_runs(provider_id, account_email); + let runs = list_runs(provider_id, account_email, account_id); if runs.is_empty() { return Vec::new(); } @@ -714,12 +722,16 @@ fn scope_key_parts( account_id: Option<&str>, organization: Option<&str>, ) -> String { - // Prefer email, then account id, then org — same idea as capacity observation - // scope without requiring source_label so list_runs stays callable from UI. - let identity = account_email + // A Ceiling-managed account id is stable even when two seats share an + // email address. Ambient readings still fall back to provider identity. + let identity = account_id .map(str::trim) .filter(|value| !value.is_empty()) - .or_else(|| account_id.map(str::trim).filter(|value| !value.is_empty())) + .or_else(|| { + account_email + .map(str::trim) + .filter(|value| !value.is_empty()) + }) .or_else(|| { organization .map(str::trim) @@ -796,7 +808,7 @@ fn persist_store(store: &QuotaRunStore) { } match serde_json::to_vec(store) { Ok(bytes) => { - if let Err(error) = fs::write(path, bytes) { + if let Err(error) = codexbar::secure_file::atomic_write(&path, &bytes) { tracing::warn!("failed to persist quota run history: {error}"); } } @@ -924,7 +936,7 @@ mod tests { ); record_capacity_events(&[event], &snap); - let runs = list_runs(provider, Some(email)); + let runs = list_runs(provider, Some(email), Some("acct-work")); assert_eq!(runs.len(), 1); let run = &runs[0]; assert!(run.complete, "watched from low used through reset"); @@ -964,7 +976,7 @@ mod tests { ); record_capacity_events(&[event], &snap); - let runs = list_runs(provider, Some(email)); + let runs = list_runs(provider, Some(email), Some("acct-work")); assert_eq!(runs.len(), 1); assert!(!runs[0].complete); assert_eq!(runs[0].reset_kind, QuotaRunResetKind::Surprise); @@ -987,7 +999,7 @@ mod tests { true, ); record_capacity_events(&[event], &snap); - let runs = list_runs(provider, Some(email)); + let runs = list_runs(provider, Some(email), Some("acct-work")); assert_eq!(runs.len(), 1); assert!(!runs[0].complete); assert!(runs[0].while_away); @@ -1021,8 +1033,11 @@ mod tests { work.updated_at = (at + Duration::hours(1)).to_rfc3339(); record_capacity_events(&[event], &work); - assert_eq!(list_runs(provider, Some("me@job.test")).len(), 1); - assert!(list_runs(provider, Some("other@home.test")).is_empty()); + assert_eq!( + list_runs(provider, Some("me@job.test"), Some("acct-work")).len(), + 1 + ); + assert!(list_runs(provider, Some("me@job.test"), Some("acct-personal")).is_empty()); } #[test] diff --git a/apps/desktop-tauri/src-tauri/src/usage_history.rs b/apps/desktop-tauri/src-tauri/src/usage_history.rs index a80d1ad2..f9b94764 100644 --- a/apps/desktop-tauri/src-tauri/src/usage_history.rs +++ b/apps/desktop-tauri/src-tauri/src/usage_history.rs @@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize}; use crate::commands::{ProviderUsageSnapshot, RateWindowSnapshot}; -const STORE_VERSION: u8 = 1; +const STORE_VERSION: u8 = 2; const RETENTION_DAYS: i64 = 30; const MIN_SAMPLE_INTERVAL_MINUTES: i64 = 5; @@ -56,7 +56,12 @@ pub fn record_snapshot(snapshot: &ProviderUsageSnapshot) { recorded_at: recorded_at.to_rfc3339(), windows, }; - let key = scope_key(&snapshot.provider_id, snapshot.account_email.as_deref()); + let key = scope_key( + &snapshot.provider_id, + snapshot.account_email.as_deref(), + snapshot.account_id.as_deref(), + snapshot.account_organization.as_deref(), + ); let Ok(mut guard) = store().lock() else { return; @@ -88,11 +93,15 @@ pub fn record_snapshot(snapshot: &ProviderUsageSnapshot) { persist_store(&guard); } -pub fn provider_history(provider_id: &str, account_email: Option<&str>) -> Vec { +pub fn provider_history( + provider_id: &str, + account_email: Option<&str>, + account_id: Option<&str>, +) -> Vec { let Ok(guard) = store().lock() else { return Vec::new(); }; - select_series(&guard.series, provider_id, account_email) + select_series(&guard.series, provider_id, account_email, account_id) .map(|points| visible_history(provider_id, points)) .unwrap_or_default() } @@ -111,12 +120,19 @@ fn select_series<'a>( series: &'a HashMap>, provider_id: &str, account_email: Option<&str>, + account_id: Option<&str>, ) -> Option<&'a Vec> { - let exact = scope_key(provider_id, account_email); + let exact = scope_key(provider_id, account_email, account_id, None); if let Some(points) = series.get(&exact) { return Some(points); } - let anonymous = scope_key(provider_id, None); + // A managed account has a stable identity. Falling back to an email or + // anonymous series here can show another seat's history when accounts + // share a login address, so wait for this account's first sample instead. + if account_id.is_some() { + return None; + } + let anonymous = scope_key(provider_id, None, None, None); if anonymous == exact { return None; } @@ -199,8 +215,25 @@ fn normalize_id(value: &str) -> String { .join("-") } -fn scope_key(provider_id: &str, account_email: Option<&str>) -> String { - let identity = account_email +fn scope_key( + provider_id: &str, + account_email: Option<&str>, + account_id: Option<&str>, + organization: Option<&str>, +) -> String { + let identity = account_id + .map(str::trim) + .filter(|value| !value.is_empty()) + .or_else(|| { + account_email + .map(str::trim) + .filter(|value| !value.is_empty()) + }) + .or_else(|| { + organization + .map(str::trim) + .filter(|value| !value.is_empty()) + }) .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("anonymous") @@ -250,7 +283,7 @@ fn persist_store(store: &UsageHistoryStore) { } match serde_json::to_vec(store) { Ok(bytes) => { - if let Err(error) = fs::write(path, bytes) { + if let Err(error) = codexbar::secure_file::atomic_write(&path, &bytes) { tracing::warn!("failed to persist usage history: {error}"); } } @@ -274,7 +307,7 @@ mod tests { let mut series = HashMap::new(); for (provider, email, recorded_at) in entries { series.insert( - scope_key(provider, *email), + scope_key(provider, *email, None, None), vec![UsageHistoryPoint { recorded_at: recorded_at.to_string(), windows: Vec::new(), @@ -294,7 +327,7 @@ mod tests { "2026-07-21T00:00:00Z", )]); - let selected = select_series(&series, "codex", Some("work@example.com")); + let selected = select_series(&series, "codex", Some("work@example.com"), None); assert!(selected.is_none(), "got another account's series"); } @@ -305,7 +338,7 @@ mod tests { // the same person, so bridging to it is correct. let series = series_with(&[("codex", None, "2026-07-21T00:00:00Z")]); - let selected = select_series(&series, "codex", Some("person@example.com")); + let selected = select_series(&series, "codex", Some("person@example.com"), None); assert!(selected.is_some()); } @@ -317,7 +350,7 @@ mod tests { ("codex", Some("person@example.com"), "2026-07-20T00:00:00Z"), ]); - let selected = select_series(&series, "codex", Some("person@example.com")).unwrap(); + let selected = select_series(&series, "codex", Some("person@example.com"), None).unwrap(); // Freshness must not override identity. assert_eq!(selected[0].recorded_at, "2026-07-20T00:00:00Z"); @@ -327,16 +360,43 @@ mod tests { fn another_providers_series_is_never_selected() { let series = series_with(&[("claude", Some("person@example.com"), "2026-07-21T00:00:00Z")]); - assert!(select_series(&series, "codex", Some("person@example.com")).is_none()); - assert!(select_series(&series, "codex", None).is_none()); + assert!(select_series(&series, "codex", Some("person@example.com"), None).is_none()); + assert!(select_series(&series, "codex", None, None).is_none()); } #[test] fn account_scope_does_not_persist_the_email() { - let key = scope_key("cursor", Some("Person@Example.com")); + let key = scope_key("cursor", Some("Person@Example.com"), None, None); assert!(key.starts_with("cursor:")); assert!(!key.contains("person")); - assert_eq!(key, scope_key("cursor", Some("person@example.com"))); + assert_eq!( + key, + scope_key("cursor", Some("person@example.com"), None, None) + ); + } + + #[test] + fn stable_account_id_separates_seats_that_share_an_email() { + let personal = scope_key( + "codex", + Some("shared@example.com"), + Some("acct-personal"), + None, + ); + let work = scope_key("codex", Some("shared@example.com"), Some("acct-work"), None); + assert_ne!(personal, work); + + let mut series = HashMap::new(); + series.insert(personal, Vec::new()); + assert!( + select_series( + &series, + "codex", + Some("shared@example.com"), + Some("acct-work") + ) + .is_none() + ); } #[test] diff --git a/apps/desktop-tauri/src/components/MenuCard.tsx b/apps/desktop-tauri/src/components/MenuCard.tsx index 8406224a..344dad3b 100644 --- a/apps/desktop-tauri/src/components/MenuCard.tsx +++ b/apps/desktop-tauri/src/components/MenuCard.tsx @@ -427,6 +427,7 @@ export default function MenuCard({ getProviderChartData( provider.providerId, provider.accountEmail ?? undefined, + provider.accountId ?? undefined, ) .then((data) => { if (!cancelled) { @@ -440,7 +441,7 @@ export default function MenuCard({ return () => { cancelled = true; }; - }, [provider.providerId, provider.accountEmail, onLayoutChange]); + }, [provider.providerId, provider.accountEmail, provider.accountId, onLayoutChange]); const isWayfinder = provider.providerId === "wayfinder"; const rawEmail = !isWayfinder && provider.accountEmail diff --git a/apps/desktop-tauri/src/lib/tauri.ts b/apps/desktop-tauri/src/lib/tauri.ts index 94e671c3..a6d136d2 100644 --- a/apps/desktop-tauri/src/lib/tauri.ts +++ b/apps/desktop-tauri/src/lib/tauri.ts @@ -303,10 +303,12 @@ export function exportCostCsv(providerId: string): Promise { export function getQuotaRunEfficiency( providerId: string, accountEmail?: string | null, + accountId?: string | null, ): Promise { return invoke("get_quota_run_efficiency", { providerId, accountEmail: accountEmail ?? null, + accountId: accountId ?? null, }); } diff --git a/apps/desktop-tauri/src/surfaces/ChartsPanel.tsx b/apps/desktop-tauri/src/surfaces/ChartsPanel.tsx index 975f2e77..27784656 100644 --- a/apps/desktop-tauri/src/surfaces/ChartsPanel.tsx +++ b/apps/desktop-tauri/src/surfaces/ChartsPanel.tsx @@ -136,9 +136,10 @@ export default function ChartsPanel({ ) : ( diff --git a/apps/desktop-tauri/src/surfaces/ProviderComparison.tsx b/apps/desktop-tauri/src/surfaces/ProviderComparison.tsx index 118e5bd8..57f5ed68 100644 --- a/apps/desktop-tauri/src/surfaces/ProviderComparison.tsx +++ b/apps/desktop-tauri/src/surfaces/ProviderComparison.tsx @@ -126,7 +126,7 @@ export default function ProviderComparison({ providers }: { const result = await getProviderChartData( provider.providerId, provider.accountEmail ?? undefined, - undefined, + provider.accountId ?? undefined, providerLocalUsageWindows(provider), ); return [provider.providerId, result] as const; diff --git a/apps/desktop-tauri/src/surfaces/ProviderDetailView.tsx b/apps/desktop-tauri/src/surfaces/ProviderDetailView.tsx index 9a276400..791531b8 100644 --- a/apps/desktop-tauri/src/surfaces/ProviderDetailView.tsx +++ b/apps/desktop-tauri/src/surfaces/ProviderDetailView.tsx @@ -221,7 +221,11 @@ export default function ProviderDetailView({ } let cancelled = false; setChartData(null); - getProviderChartData(provider.providerId, provider.accountEmail ?? undefined) + getProviderChartData( + provider.providerId, + provider.accountEmail ?? undefined, + provider.accountId ?? undefined, + ) .then((data) => { if (!cancelled) setChartData(data); }) @@ -231,7 +235,13 @@ export default function ProviderDetailView({ return () => { cancelled = true; }; - }, [provider.accountEmail, provider.error, provider.providerId, supportsLocalActivity]); + }, [ + provider.accountEmail, + provider.accountId, + provider.error, + provider.providerId, + supportsLocalActivity, + ]); const primaryReset = useFormattedResetTime( provider.primary.resetsAt, diff --git a/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx index bd7447f0..9394a1bf 100644 --- a/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx +++ b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx @@ -465,7 +465,11 @@ describe("TrayPanel provider grid", () => { )!, ); await waitFor(() => { - expect(tauriMocks.getProviderChartData).toHaveBeenCalledWith("codex", undefined); + expect(tauriMocks.getProviderChartData).toHaveBeenCalledWith( + "codex", + undefined, + undefined, + ); }); }); diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx index 709de057..99b31b72 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx @@ -438,6 +438,7 @@ export function ProviderDetailPane({ diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/charts/ChartsSection.test.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/charts/ChartsSection.test.tsx index 990dcbeb..333d0628 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/charts/ChartsSection.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/charts/ChartsSection.test.tsx @@ -133,6 +133,32 @@ describe("ChartsSection local usage summary", () => { tauriMocks.exportCostCsv.mockResolvedValue("C:/Users/me/Downloads/ceiling-claude-spend.csv"); }); + it("scopes chart and efficiency reads to the stable account id", async () => { + render( + key} + />, + ); + + await waitFor(() => + expect(tauriMocks.getProviderChartData).toHaveBeenCalledWith( + "claude", + "shared@example.com", + "acct-work", + [], + undefined, + ), + ); + expect(tauriMocks.getQuotaRunEfficiency).toHaveBeenCalledWith( + "claude", + "shared@example.com", + "acct-work", + ); + }); + it("shows comparable processed totals and the seven-day token mix", async () => { const { getByText, getAllByText, getByLabelText } = render( key} />, diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/charts/ChartsSection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/charts/ChartsSection.tsx index 0886fa21..97a38c93 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/charts/ChartsSection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/charts/ChartsSection.tsx @@ -33,6 +33,7 @@ type T = ReturnType["t"]; interface Props { providerId: string; accountEmail: string | null; + accountId?: string | null; providerSnapshot?: ProviderUsageSnapshot; t: T; } @@ -48,10 +49,12 @@ const chartDataCache = new Map(); function chartDataCacheKey( providerId: string, accountEmail: string | null, + accountId: string | null | undefined, sourceLabel: string | undefined, usageWindowsKey = "", ): string { - return `${providerId.toLowerCase()}:${accountEmail?.trim().toLowerCase() ?? ""}:${sourceLabel?.trim().toLowerCase() ?? ""}:${usageWindowsKey}`; + const identity = accountId?.trim().toLowerCase() || accountEmail?.trim().toLowerCase() || ""; + return `${providerId.toLowerCase()}:${identity}:${sourceLabel?.trim().toLowerCase() ?? ""}:${usageWindowsKey}`; } function formatWindowStart(value: string): string { @@ -468,7 +471,7 @@ function ProjectBreakdown({ projects }: { projects: LocalProjectCost[] }) { * Phase 10: fetches the latest settings snapshot so the animation flag feeds * through to each chart component. */ -export function ChartsSection({ providerId, accountEmail, providerSnapshot, t }: Props) { +export function ChartsSection({ providerId, accountEmail, accountId, providerSnapshot, t }: Props) { const [data, setData] = useState(null); const [active, setActive] = useState(null); const [animations, setAnimations] = useState(true); @@ -492,7 +495,7 @@ export function ChartsSection({ providerId, accountEmail, providerSnapshot, t }: cancelled = true; }; } - getQuotaRunEfficiency(providerId, accountEmail) + getQuotaRunEfficiency(providerId, accountEmail, accountId) .then((rows) => { if (!cancelled) setEfficiency(rows); }) @@ -502,11 +505,17 @@ export function ChartsSection({ providerId, accountEmail, providerSnapshot, t }: return () => { cancelled = true; }; - }, [providerId, accountEmail]); + }, [providerId, accountEmail, accountId]); useEffect(() => { let cancelled = false; - const cacheKey = chartDataCacheKey(providerId, accountEmail, sourceLabel, usageWindowsKey); + const cacheKey = chartDataCacheKey( + providerId, + accountEmail, + accountId, + sourceLabel, + usageWindowsKey, + ); const cached = chartDataCache.get(cacheKey) ?? null; setData(cached); setActive(null); @@ -526,7 +535,7 @@ export function ChartsSection({ providerId, accountEmail, providerSnapshot, t }: getProviderChartData( providerId, accountEmail ?? undefined, - undefined, + accountId ?? undefined, usageWindows, sourceLabel, ) @@ -553,7 +562,7 @@ export function ChartsSection({ providerId, accountEmail, providerSnapshot, t }: return () => { cancelled = true; }; - }, [providerId, accountEmail, sourceLabel, usageWindowsKey]); + }, [providerId, accountEmail, accountId, sourceLabel, usageWindowsKey]); useEffect(() => { let cancelled = false; @@ -613,13 +622,16 @@ export function ChartsSection({ providerId, accountEmail, providerSnapshot, t }: const next = await getProviderChartData( providerId, accountEmail ?? undefined, - undefined, + accountId ?? undefined, usageWindows, sourceLabel, ); if (cancelled) return; if (next.localUsage) { - chartDataCache.set(chartDataCacheKey(providerId, accountEmail, sourceLabel, usageWindowsKey), next); + chartDataCache.set( + chartDataCacheKey(providerId, accountEmail, accountId, sourceLabel, usageWindowsKey), + next, + ); setData(next); setEnriching(false); return; @@ -644,7 +656,7 @@ export function ChartsSection({ providerId, accountEmail, providerSnapshot, t }: cancelled = true; if (timer !== undefined) window.clearTimeout(timer); }; - }, [data, providerId, accountEmail, sourceLabel, usageWindowsKey]); + }, [data, providerId, accountEmail, accountId, sourceLabel, usageWindowsKey]); // Cursor activity is independent of chart history and only belongs to the // Cursor provider. Guard on the current provider so a stale fetch from a diff --git a/rust/src/core/jsonl_scanner.rs b/rust/src/core/jsonl_scanner.rs index 9d76b0bd..ec52bc17 100755 --- a/rust/src/core/jsonl_scanner.rs +++ b/rust/src/core/jsonl_scanner.rs @@ -884,7 +884,7 @@ impl JsonlScanner { } if let Ok(json) = serde_json::to_string_pretty(cache) { - let _ = fs::write(&cache_path, json); + let _ = crate::secure_file::atomic_write(&cache_path, json.as_bytes()); } } diff --git a/rust/src/core/openai_dashboard.rs b/rust/src/core/openai_dashboard.rs index b382e8cb..a6c44e8c 100755 --- a/rust/src/core/openai_dashboard.rs +++ b/rust/src/core/openai_dashboard.rs @@ -250,7 +250,7 @@ impl OpenAIDashboardCacheStore { let _ = fs::create_dir_all(parent); } if let Ok(data) = serde_json::to_string_pretty(cache) { - let _ = fs::write(&url, data); + let _ = crate::secure_file::atomic_write(&url, data.as_bytes()); } } } diff --git a/rust/src/core/widget_snapshot.rs b/rust/src/core/widget_snapshot.rs index 432da6d4..590581e3 100755 --- a/rust/src/core/widget_snapshot.rs +++ b/rust/src/core/widget_snapshot.rs @@ -248,7 +248,7 @@ impl WidgetSnapshotStore { } let json = serde_json::to_string_pretty(snapshot)?; - fs::write(&path, json)?; + crate::secure_file::atomic_write(&path, json.as_bytes())?; tracing::debug!("Saved widget snapshot to {:?}", path); Ok(()) @@ -300,7 +300,7 @@ impl WidgetSelectionStore { } let json = serde_json::to_string(&Selection { provider })?; - fs::write(&path, json)?; + crate::secure_file::atomic_write(&path, json.as_bytes())?; Ok(()) } diff --git a/rust/src/providers/claude/oauth/credentials_store.rs b/rust/src/providers/claude/oauth/credentials_store.rs index a5a42fc8..99c43a07 100644 --- a/rust/src/providers/claude/oauth/credentials_store.rs +++ b/rust/src/providers/claude/oauth/credentials_store.rs @@ -20,11 +20,6 @@ const KEYRING_SERVICE: &str = "Claude Code-credentials"; const ENV_TOKEN_KEY: &str = "CODEXBAR_CLAUDE_OAUTH_TOKEN"; const ENV_SCOPES_KEY: &str = "CODEXBAR_CLAUDE_OAUTH_SCOPES"; -/// Monotonic counter to make the persist temp-file name unique per write, so -/// concurrent refreshes (multiple instances / overlapping polls) never share a -/// temp path. -static PERSIST_TMP_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); - /// Identifies where a set of OAuth credentials was loaded from, so the /// refreshed-credentials cache never mixes tokens across sources. #[derive(Debug, Clone, PartialEq, Eq, Hash)] @@ -360,7 +355,7 @@ fn credentials_path(config_dir: Option<&Path>) -> Result /// Persist refreshed tokens back to the config directory's `.credentials.json`, /// updating only the `claudeAiOauth` token fields and leaving everything else -/// (e.g. `mcpOAuth`) untouched. Written atomically via a temp file + rename. +/// (e.g. `mcpOAuth`) untouched. Written atomically beside the live file. /// /// `config_dir` must be the same account the credentials were loaded from, or a /// refresh would be written over a different seat's tokens. @@ -384,17 +379,7 @@ pub(super) fn persist_refreshed_credentials( let serialized = serde_json::to_string_pretty(&root) .map_err(|e| ProviderError::OAuth(format!("Failed to serialize credentials: {e}")))?; - let parent = path - .parent() - .ok_or_else(|| ProviderError::OAuth("Credentials path has no parent".to_string()))?; - let tmp = parent.join(format!( - ".credentials.json.codexbar-tmp.{}.{}", - std::process::id(), - PERSIST_TMP_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed) - )); - std::fs::write(&tmp, serialized.as_bytes()) - .map_err(|e| ProviderError::OAuth(format!("Failed to write credentials temp file: {e}")))?; - std::fs::rename(&tmp, &path) + crate::secure_file::atomic_write(&path, serialized.as_bytes()) .map_err(|e| ProviderError::OAuth(format!("Failed to replace credentials file: {e}")))?; Ok(()) } diff --git a/rust/src/providers/claude/web_api.rs b/rust/src/providers/claude/web_api.rs index e76e6821..66f26b4f 100755 --- a/rust/src/providers/claude/web_api.rs +++ b/rust/src/providers/claude/web_api.rs @@ -174,7 +174,7 @@ fn json_value_kind(value: &serde_json::Value) -> &'static str { /// Claude Web API fetcher pub struct ClaudeWebApiFetcher { - client: Client, + client: Result, } /// Organization info from Claude API @@ -356,10 +356,16 @@ impl ClaudeWebApiFetcher { client: crate::core::credentialed_http_client_builder() .timeout(std::time::Duration::from_secs(30)) .build() - .expect("Failed to create HTTP client"), + .map_err(|error| format!("Failed to create HTTP client: {error}")), } } + fn client(&self) -> Result<&Client, ProviderError> { + self.client + .as_ref() + .map_err(|error| ProviderError::Other(error.clone())) + } + /// Fetch usage using browser cookies or env-var session key pub async fn fetch_with_cookies(&self) -> Result { if let Some(session_key) = Self::resolve_session_key_from_env() { @@ -626,7 +632,7 @@ impl ClaudeWebApiFetcher { let url = format!("{}/organizations", Self::BASE_URL); let response = self - .client + .client()? .get(&url) .headers(headers.clone()) .send() @@ -656,7 +662,7 @@ impl ClaudeWebApiFetcher { let url = format!("{}/organizations/{}/usage", Self::BASE_URL, org_id); let response = self - .client + .client()? .get(&url) .headers(headers.clone()) .send() @@ -685,7 +691,7 @@ impl ClaudeWebApiFetcher { ); let response = self - .client + .client()? .get(&url) .headers(headers.clone()) .send() @@ -709,7 +715,7 @@ impl ClaudeWebApiFetcher { let url = format!("{}/account", Self::BASE_URL); let response = self - .client + .client()? .get(&url) .headers(headers.clone()) .send() diff --git a/rust/src/secure_file.rs b/rust/src/secure_file.rs index 9464c485..0ff3313d 100644 --- a/rust/src/secure_file.rs +++ b/rust/src/secure_file.rs @@ -1,7 +1,9 @@ //! Small helper for storing local secret-bearing JSON files. -use std::io; +use std::ffi::OsString; +use std::io::{self, Write}; use std::path::Path; +use std::sync::atomic::{AtomicU64, Ordering}; use base64::Engine; use serde::{Deserialize, Serialize}; @@ -10,6 +12,7 @@ const FORMAT: &str = "codexbar.secure-file"; const VERSION: u32 = 1; const WINDOWS_DPAPI_USER: &str = "windows-dpapi-user"; const WINDOWS_DPAPI_MACHINE: &str = "windows-dpapi-machine"; +static TEMP_FILE_COUNTER: AtomicU64 = AtomicU64::new(0); #[derive(Debug, Serialize, Deserialize)] struct ProtectedFile { @@ -95,8 +98,117 @@ pub fn read_string(path: &Path) -> io::Result { /// Write a UTF-8 file, protecting it with Windows DPAPI when available. pub fn write_string(path: &Path, contents: &str) -> io::Result<()> { let bytes = protected_file_bytes(contents)?; - std::fs::write(path, bytes)?; - restrict_file_permissions(path)?; + atomic_write(path, &bytes) +} + +/// Replace a local state file atomically with private permissions. +/// +/// The temporary file lives beside the destination so the final rename stays +/// on one filesystem. A crash can leave a harmless temp file, but it cannot +/// truncate the last known-good settings or credential file. +pub fn atomic_write(path: &Path, bytes: &[u8]) -> io::Result<()> { + let parent = path.parent().ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, "state file path has no parent") + })?; + let file_name = path.file_name().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "state file path has no file name", + ) + })?; + + let mut temp_path = None; + let mut temp_file = None; + for _ in 0..16 { + let sequence = TEMP_FILE_COUNTER.fetch_add(1, Ordering::Relaxed); + let mut temp_name = OsString::from("."); + temp_name.push(file_name); + temp_name.push(format!(".ceiling-tmp-{}-{sequence}", std::process::id())); + let candidate = parent.join(temp_name); + + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + match options.open(&candidate) { + Ok(file) => { + temp_path = Some(candidate); + temp_file = Some(file); + break; + } + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, + Err(error) => return Err(error), + } + } + + let temp_path = temp_path.ok_or_else(|| { + io::Error::new( + io::ErrorKind::AlreadyExists, + "could not allocate a unique state-file temp path", + ) + })?; + let mut temp_file = temp_file.expect("temp path and file are assigned together"); + + let result = (|| { + // Apply the private ACL/mode before secret bytes reach disk. + restrict_file_permissions(&temp_path)?; + temp_file.write_all(bytes)?; + temp_file.sync_all()?; + drop(temp_file); + atomic_replace(&temp_path, path)?; + sync_parent_directory(parent)?; + Ok(()) + })(); + + if result.is_err() { + let _ = std::fs::remove_file(&temp_path); + } + result +} + +#[cfg(windows)] +fn atomic_replace(from: &Path, to: &Path) -> io::Result<()> { + use std::os::windows::ffi::OsStrExt; + use windows::Win32::Storage::FileSystem::{ + MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, MoveFileExW, + }; + use windows::core::PCWSTR; + + let from: Vec = from + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + let to: Vec = to + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + unsafe { + MoveFileExW( + PCWSTR(from.as_ptr()), + PCWSTR(to.as_ptr()), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, + ) + .map_err(|error| io::Error::other(format!("atomic file replacement failed: {error}"))) + } +} + +#[cfg(not(windows))] +fn atomic_replace(from: &Path, to: &Path) -> io::Result<()> { + std::fs::rename(from, to) +} + +#[cfg(unix)] +fn sync_parent_directory(parent: &Path) -> io::Result<()> { + std::fs::File::open(parent)?.sync_all() +} + +#[cfg(not(unix))] +fn sync_parent_directory(_parent: &Path) -> io::Result<()> { Ok(()) } @@ -242,6 +354,21 @@ mod tests { assert_eq!(read_string(&path).unwrap(), r#"{"secret":"value"}"#); } + #[test] + fn write_atomically_replaces_an_existing_file_without_temp_artifacts() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("secure.json"); + write_string(&path, r#"{"secret":"first"}"#).unwrap(); + write_string(&path, r#"{"secret":"second"}"#).unwrap(); + + assert_eq!(read_string(&path).unwrap(), r#"{"secret":"second"}"#); + let names = std::fs::read_dir(dir.path()) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect::>(); + assert_eq!(names, vec![OsString::from("secure.json")]); + } + #[cfg(windows)] #[test] fn windows_write_uses_user_dpapi_and_a_protected_single_entry_dacl() { From 7553eaceb365e982aa411da9020f74af11afb48d Mon Sep 17 00:00:00 2001 From: tsouth89 Date: Sat, 1 Aug 2026 00:35:03 -0400 Subject: [PATCH 2/2] Fix account-scoped chart history --- .../src-tauri/src/commands/chart.rs | 29 ++++++++++- .../src-tauri/src/usage_history.rs | 50 +++++++++++++++---- .../desktop-tauri/src/components/MenuCard.tsx | 5 +- apps/desktop-tauri/src/lib/tauri.ts | 2 + .../src/surfaces/ChartsPanel.test.tsx | 14 +++++- .../src/surfaces/ChartsPanel.tsx | 11 +++- .../src/surfaces/ProviderComparison.tsx | 2 + .../src/surfaces/ProviderDetailView.tsx | 4 ++ .../src/surfaces/TrayPanel.test.tsx | 3 ++ .../sections/charts/ChartsSection.test.tsx | 4 +- .../sections/charts/ChartsSection.tsx | 24 +++++++-- 11 files changed, 128 insertions(+), 20 deletions(-) diff --git a/apps/desktop-tauri/src-tauri/src/commands/chart.rs b/apps/desktop-tauri/src-tauri/src/commands/chart.rs index 6c0fe12e..d1723e26 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/chart.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/chart.rs @@ -392,6 +392,7 @@ pub async fn get_provider_chart_data( account_id: Option, source_label: Option, usage_windows: Option>, + account_organization: Option, ) -> ProviderChartData { let usage_windows = usage_windows.unwrap_or_default(); // An account's local logs live under its own config directory. Scanning @@ -406,6 +407,7 @@ pub async fn get_provider_chart_data( &provider_id, account_email.as_deref(), account_id.as_deref(), + account_organization.as_deref(), source_label.as_deref(), &usage_windows, ); @@ -414,6 +416,7 @@ pub async fn get_provider_chart_data( &provider_id, account_email.as_deref(), account_id.as_deref(), + account_organization.as_deref(), ); if current_unix_ms().saturating_sub(cached.refreshed_at_ms) > CHART_CACHE_TTL.as_millis() as i64 @@ -423,6 +426,7 @@ pub async fn get_provider_chart_data( provider_id, account_email, account_id, + account_organization, scoped_home.clone(), usage_windows, ); @@ -434,6 +438,7 @@ pub async fn get_provider_chart_data( &provider_id, account_email.as_deref(), account_id.as_deref(), + account_organization.as_deref(), ); if !quota_history.is_empty() { let mut immediate = ProviderChartData::empty(provider_id.clone()); @@ -443,6 +448,7 @@ pub async fn get_provider_chart_data( provider_id, account_email, account_id, + account_organization, scoped_home.clone(), usage_windows, ); @@ -456,6 +462,7 @@ pub async fn get_provider_chart_data( provider_id, account_email, account_id, + account_organization, scoped_home, usage_windows, Some(cancel), @@ -504,6 +511,7 @@ fn schedule_chart_cache_refresh( provider_id: String, account_email: Option, account_id: Option, + account_organization: Option, scoped_home: Option, usage_windows: Vec, ) { @@ -522,6 +530,7 @@ fn schedule_chart_cache_refresh( provider_id, account_email, account_id, + account_organization, scoped_home, usage_windows, None, @@ -542,6 +551,7 @@ fn chart_cache_key( provider_id: &str, account_email: Option<&str>, account_id: Option<&str>, + account_organization: Option<&str>, source_label: Option<&str>, usage_windows: &[LocalUsageWindowRequest], ) -> String { @@ -553,6 +563,11 @@ fn chart_cache_key( .map(str::trim) .filter(|value| !value.is_empty()) }) + .or_else(|| { + account_organization + .map(str::trim) + .filter(|value| !value.is_empty()) + }) .unwrap_or("anonymous") .to_ascii_lowercase(); let windows = usage_windows @@ -1141,13 +1156,22 @@ pub(crate) fn build_provider_chart_data( provider_id: String, account_email: Option, ) -> ProviderChartData { - build_provider_chart_data_with_cancel(provider_id, account_email, None, None, Vec::new(), None) + build_provider_chart_data_with_cancel( + provider_id, + account_email, + None, + None, + None, + Vec::new(), + None, + ) } fn build_provider_chart_data_with_cancel( provider_id: String, account_email: Option, account_id: Option, + account_organization: Option, scoped_home: Option, usage_window_requests: Vec, cancel: Option>, @@ -1214,6 +1238,7 @@ fn build_provider_chart_data_with_cancel( &provider_id, account_email.as_deref(), account_id.as_deref(), + account_organization.as_deref(), ), provider_id, cost_history, @@ -1842,6 +1867,7 @@ mod tests { "codex", Some("shared@example.com"), Some("acct-personal"), + None, Some("oauth"), &[], ); @@ -1849,6 +1875,7 @@ mod tests { "codex", Some("shared@example.com"), Some("acct-work"), + None, Some("oauth"), &[], ); diff --git a/apps/desktop-tauri/src-tauri/src/usage_history.rs b/apps/desktop-tauri/src-tauri/src/usage_history.rs index f9b94764..621296c5 100644 --- a/apps/desktop-tauri/src-tauri/src/usage_history.rs +++ b/apps/desktop-tauri/src-tauri/src/usage_history.rs @@ -97,13 +97,20 @@ pub fn provider_history( provider_id: &str, account_email: Option<&str>, account_id: Option<&str>, + account_organization: Option<&str>, ) -> Vec { let Ok(guard) = store().lock() else { return Vec::new(); }; - select_series(&guard.series, provider_id, account_email, account_id) - .map(|points| visible_history(provider_id, points)) - .unwrap_or_default() + select_series( + &guard.series, + provider_id, + account_email, + account_id, + account_organization, + ) + .map(|points| visible_history(provider_id, points)) + .unwrap_or_default() } /// Pick the series to chart for a provider/account. @@ -121,15 +128,16 @@ fn select_series<'a>( provider_id: &str, account_email: Option<&str>, account_id: Option<&str>, + account_organization: Option<&str>, ) -> Option<&'a Vec> { - let exact = scope_key(provider_id, account_email, account_id, None); + let exact = scope_key(provider_id, account_email, account_id, account_organization); if let Some(points) = series.get(&exact) { return Some(points); } // A managed account has a stable identity. Falling back to an email or // anonymous series here can show another seat's history when accounts // share a login address, so wait for this account's first sample instead. - if account_id.is_some() { + if account_id.is_some() || account_organization.is_some() { return None; } let anonymous = scope_key(provider_id, None, None, None); @@ -327,7 +335,7 @@ mod tests { "2026-07-21T00:00:00Z", )]); - let selected = select_series(&series, "codex", Some("work@example.com"), None); + let selected = select_series(&series, "codex", Some("work@example.com"), None, None); assert!(selected.is_none(), "got another account's series"); } @@ -338,7 +346,7 @@ mod tests { // the same person, so bridging to it is correct. let series = series_with(&[("codex", None, "2026-07-21T00:00:00Z")]); - let selected = select_series(&series, "codex", Some("person@example.com"), None); + let selected = select_series(&series, "codex", Some("person@example.com"), None, None); assert!(selected.is_some()); } @@ -350,7 +358,8 @@ mod tests { ("codex", Some("person@example.com"), "2026-07-20T00:00:00Z"), ]); - let selected = select_series(&series, "codex", Some("person@example.com"), None).unwrap(); + let selected = + select_series(&series, "codex", Some("person@example.com"), None, None).unwrap(); // Freshness must not override identity. assert_eq!(selected[0].recorded_at, "2026-07-20T00:00:00Z"); @@ -360,8 +369,8 @@ mod tests { fn another_providers_series_is_never_selected() { let series = series_with(&[("claude", Some("person@example.com"), "2026-07-21T00:00:00Z")]); - assert!(select_series(&series, "codex", Some("person@example.com"), None).is_none()); - assert!(select_series(&series, "codex", None, None).is_none()); + assert!(select_series(&series, "codex", Some("person@example.com"), None, None).is_none()); + assert!(select_series(&series, "codex", None, None, None).is_none()); } #[test] @@ -393,12 +402,31 @@ mod tests { &series, "codex", Some("shared@example.com"), - Some("acct-work") + Some("acct-work"), + None, ) .is_none() ); } + #[test] + fn organization_only_history_is_read_from_its_recorded_scope() { + let mut series = HashMap::new(); + let organization_key = scope_key("openai", None, None, Some("org-work")); + series.insert( + organization_key, + vec![UsageHistoryPoint { + recorded_at: "2026-07-21T00:00:00Z".into(), + windows: Vec::new(), + }], + ); + + let selected = select_series(&series, "openai", None, None, Some("org-work")); + + assert!(selected.is_some()); + assert!(select_series(&series, "openai", None, None, Some("org-other")).is_none()); + } + #[test] fn cursor_history_hides_promotional_and_on_demand_pools() { let points = vec![UsageHistoryPoint { diff --git a/apps/desktop-tauri/src/components/MenuCard.tsx b/apps/desktop-tauri/src/components/MenuCard.tsx index 344dad3b..6b58cd26 100644 --- a/apps/desktop-tauri/src/components/MenuCard.tsx +++ b/apps/desktop-tauri/src/components/MenuCard.tsx @@ -428,6 +428,9 @@ export default function MenuCard({ provider.providerId, provider.accountEmail ?? undefined, provider.accountId ?? undefined, + undefined, + undefined, + provider.accountOrganization ?? undefined, ) .then((data) => { if (!cancelled) { @@ -441,7 +444,7 @@ export default function MenuCard({ return () => { cancelled = true; }; - }, [provider.providerId, provider.accountEmail, provider.accountId, onLayoutChange]); + }, [provider.providerId, provider.accountEmail, provider.accountId, provider.accountOrganization, onLayoutChange]); const isWayfinder = provider.providerId === "wayfinder"; const rawEmail = !isWayfinder && provider.accountEmail diff --git a/apps/desktop-tauri/src/lib/tauri.ts b/apps/desktop-tauri/src/lib/tauri.ts index a6d136d2..8431a100 100644 --- a/apps/desktop-tauri/src/lib/tauri.ts +++ b/apps/desktop-tauri/src/lib/tauri.ts @@ -256,6 +256,7 @@ export function getProviderChartData( accountId?: string, usageWindows?: import("../types/bridge").LocalUsageWindowRequest[], sourceLabel?: string, + accountOrganization?: string, ): Promise { return invoke("get_provider_chart_data", { providerId, @@ -263,6 +264,7 @@ export function getProviderChartData( accountId, usageWindows, sourceLabel, + accountOrganization, }); } diff --git a/apps/desktop-tauri/src/surfaces/ChartsPanel.test.tsx b/apps/desktop-tauri/src/surfaces/ChartsPanel.test.tsx index ec0eea6e..68e5bbe4 100644 --- a/apps/desktop-tauri/src/surfaces/ChartsPanel.test.tsx +++ b/apps/desktop-tauri/src/surfaces/ChartsPanel.test.tsx @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { fireEvent, render } from "@testing-library/react"; -import ChartsPanel from "./ChartsPanel"; +import ChartsPanel, { chartSectionKey } from "./ChartsPanel"; import type { ProviderUsageSnapshot } from "../types/bridge"; // Stub the async, backend-fetching ChartsSection so this test exercises only @@ -66,6 +66,18 @@ function provider( } describe("ChartsPanel", () => { + it("remounts charts when fallback account identity changes", () => { + const personal = provider({ accountEmail: "personal@example.com" }); + const work = provider({ accountEmail: "work@example.com" }); + const organization = provider({ + accountEmail: null, + accountOrganization: "org-work", + }); + + expect(chartSectionKey(personal)).not.toBe(chartSectionKey(work)); + expect(chartSectionKey(organization)).toBe("codex:org-work"); + }); + it("shows an empty state when no provider reports chart data", () => { const { container, getByText } = render( ) : ( { if (!cancelled) setChartData(data); @@ -238,6 +241,7 @@ export default function ProviderDetailView({ }, [ provider.accountEmail, provider.accountId, + provider.accountOrganization, provider.error, provider.providerId, supportsLocalActivity, diff --git a/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx index 9394a1bf..ad2591bd 100644 --- a/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx +++ b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx @@ -469,6 +469,9 @@ describe("TrayPanel provider grid", () => { "codex", undefined, undefined, + undefined, + undefined, + undefined, ); }); }); diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/charts/ChartsSection.test.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/charts/ChartsSection.test.tsx index 333d0628..1e147906 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/charts/ChartsSection.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/charts/ChartsSection.test.tsx @@ -150,6 +150,7 @@ describe("ChartsSection local usage summary", () => { "acct-work", [], undefined, + undefined, ), ); expect(tauriMocks.getQuotaRunEfficiency).toHaveBeenCalledWith( @@ -290,7 +291,7 @@ describe("ChartsSection local usage summary", () => { updatedAt: "2026-07-19T12:00:00.000Z", error: null, pace: null, - accountOrganization: null, + accountOrganization: "org-work", trayStatusLabel: null, }; @@ -312,6 +313,7 @@ describe("ChartsSection local usage summary", () => { undefined, [], "Claude CLI", + "org-work", ); }); diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/charts/ChartsSection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/charts/ChartsSection.tsx index 97a38c93..d411fe75 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/charts/ChartsSection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/charts/ChartsSection.tsx @@ -50,10 +50,15 @@ function chartDataCacheKey( providerId: string, accountEmail: string | null, accountId: string | null | undefined, + accountOrganization: string | null | undefined, sourceLabel: string | undefined, usageWindowsKey = "", ): string { - const identity = accountId?.trim().toLowerCase() || accountEmail?.trim().toLowerCase() || ""; + const identity = + accountId?.trim().toLowerCase() || + accountEmail?.trim().toLowerCase() || + accountOrganization?.trim().toLowerCase() || + ""; return `${providerId.toLowerCase()}:${identity}:${sourceLabel?.trim().toLowerCase() ?? ""}:${usageWindowsKey}`; } @@ -482,6 +487,7 @@ export function ChartsSection({ providerId, accountEmail, accountId, providerSna const [efficiency, setEfficiency] = useState([]); const usageWindows = providerLocalUsageWindows(providerSnapshot); const sourceLabel = providerSnapshot?.sourceLabel; + const accountOrganization = providerSnapshot?.accountOrganization; const resetBoundaryUnavailable = providerHasUnavailableResetBoundary(providerSnapshot); const usageWindowsKey = usageWindows .map((window) => `${window.id}:${window.startsAt}:${window.endsAt}`) @@ -513,6 +519,7 @@ export function ChartsSection({ providerId, accountEmail, accountId, providerSna providerId, accountEmail, accountId, + accountOrganization, sourceLabel, usageWindowsKey, ); @@ -538,6 +545,7 @@ export function ChartsSection({ providerId, accountEmail, accountId, providerSna accountId ?? undefined, usageWindows, sourceLabel, + accountOrganization ?? undefined, ) .then((d) => { if (!cancelled) { @@ -562,7 +570,7 @@ export function ChartsSection({ providerId, accountEmail, accountId, providerSna return () => { cancelled = true; }; - }, [providerId, accountEmail, accountId, sourceLabel, usageWindowsKey]); + }, [providerId, accountEmail, accountId, accountOrganization, sourceLabel, usageWindowsKey]); useEffect(() => { let cancelled = false; @@ -625,11 +633,19 @@ export function ChartsSection({ providerId, accountEmail, accountId, providerSna accountId ?? undefined, usageWindows, sourceLabel, + accountOrganization ?? undefined, ); if (cancelled) return; if (next.localUsage) { chartDataCache.set( - chartDataCacheKey(providerId, accountEmail, accountId, sourceLabel, usageWindowsKey), + chartDataCacheKey( + providerId, + accountEmail, + accountId, + accountOrganization, + sourceLabel, + usageWindowsKey, + ), next, ); setData(next); @@ -656,7 +672,7 @@ export function ChartsSection({ providerId, accountEmail, accountId, providerSna cancelled = true; if (timer !== undefined) window.clearTimeout(timer); }; - }, [data, providerId, accountEmail, accountId, sourceLabel, usageWindowsKey]); + }, [data, providerId, accountEmail, accountId, accountOrganization, sourceLabel, usageWindowsKey]); // Cursor activity is independent of chart history and only belongs to the // Cursor provider. Guard on the current provider so a stale fetch from a