From 65d5db3d44c5a20a9810fdb2a996c32cf4adb7f1 Mon Sep 17 00:00:00 2001 From: Cosmicdust-Origin <275912207+Cosmicdust-Origin@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:40:31 +0900 Subject: [PATCH 1/3] Use a standalone saved Claude account for usage when Claude Code credentials are off With "Allow reading Claude Code's credentials" off (the default), Claude usage only came from Claude Code's own login. If that login is missing or expired, Auto fell through to the CLI and the panel went blank even though a working saved account existed in CodexBar. When consent is off and exactly one saved account was never installed into Claude Code, fetch usage from it and renew its token in CodexBar's own store. Claude Code's credential files are never read or written on this path. Track which account CodexBar last installed into Claude Code (switch, save-current, reauthenticate of the live login) so a saved copy that shares Claude Code's refresh-token chain is never rotated. A fresh isolated sign-in or removal clears the marker. fetch_account now shares its body with the new path instead of duplicating it. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 1 + rust/src/providers/claude/accounts.rs | 129 ++++++++++++++++++++++++- rust/src/providers/claude/oauth/mod.rs | 71 ++++++++++++-- 3 files changed, 192 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ba152ddd38..8b667259a2e 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ## [Windows] Unreleased +- Keep Claude usage working when reading Claude Code's credentials is off and its own login is missing or expired: use the one saved Claude account that was never installed into Claude Code, renewing its token in CodexBar's own store without touching Claude Code. - Show independent session and weekly usage for native saved Claude Code accounts, with account-scoped OAuth renewal and safe authentication warnings. - Offer Refresh login beside Switch only for Claude and Codex accounts that need sign-in. Repair the selected saved login without switching to another account. - Use expanded, collapsible Claude and Codex account sections with Add account below the rows; keep the floating bar's usage footprint when authentication expires. diff --git a/rust/src/providers/claude/accounts.rs b/rust/src/providers/claude/accounts.rs index b2233bdc9b6..5ac3f5e3c60 100644 --- a/rust/src/providers/claude/accounts.rs +++ b/rust/src/providers/claude/accounts.rs @@ -74,6 +74,12 @@ impl SavedLogin { #[derive(Default, Serialize, Deserialize)] struct Store { accounts: Vec, + /// Account whose tokens CodexBar last installed into Claude Code. Its + /// saved copy shares a refresh-token chain with Claude Code, so CodexBar + /// must not rotate it without reading Claude Code's credentials. Older + /// builds ignore this field. + #[serde(default, skip_serializing_if = "Option::is_none")] + installed_in_claude_code: Option, } pub struct AccountManager { @@ -179,7 +185,48 @@ impl AccountManager { let current = read_login(&self.config_dir, &self.config_file)?.ok_or_else(|| { io::Error::other("No Claude Code subscription login found. Add an account first.") })?; - self.import(current) + let id = current.id()?; + self.import(current)?; + self.set_installed_in_claude_code(Some(&id)) + } + + fn set_installed_in_claude_code(&self, id: Option<&str>) -> io::Result<()> { + let mut store = self.load()?; + store.installed_in_claude_code = id.map(str::to_owned); + self.save(&store) + } + + /// The only saved account CodexBar may use for usage without reading + /// Claude Code's own credentials: one that was never installed into Claude + /// Code, so nothing else rotates its refresh token. `None` unless there + /// is exactly one, because without that consent the active login is unknown. + pub(super) fn standalone_usage_credentials(&self) -> io::Result> { + let store = self.load()?; + let installed = store.installed_in_claude_code; + let mut candidates = store + .accounts + .into_iter() + .filter(|account| account.id().ok() != installed) + .collect::>(); + if candidates.len() != 1 { + tracing::debug!( + candidates = candidates.len(), + "No single standalone saved Claude account to use for usage" + ); + return Ok(None); + } + let account = candidates.remove(0); + Ok(Some((account.id()?, account.oauth))) + } + + /// Display fields (never credentials) of one saved account. + pub(super) fn saved_summary(&self, id: &str) -> Option { + let store = self.load().ok()?; + let account = store + .accounts + .iter() + .find(|account| account.id().ok().as_deref() == Some(id))?; + account.summary(false, true).ok() } /// Resolve this account only. The active login may have newer credentials @@ -251,6 +298,10 @@ impl AccountManager { pub fn import(&self, login: SavedLogin) -> io::Result<()> { login.validate()?; let mut store = self.load()?; + if store.installed_in_claude_code == Some(login.id()?) { + // A new sign-in is a new refresh-token chain, not Claude Code's. + store.installed_in_claude_code = None; + } upsert(&mut store, login)?; self.save(&store) } @@ -278,6 +329,9 @@ impl AccountManager { } let oauth = login.oauth.clone(); upsert(&mut store, login)?; + if active { + store.installed_in_claude_code = Some(id.to_owned()); + } self.save(&store)?; if active && self.current_account_id()?.as_deref() == Some(id) { let path = self.config_dir.join(".credentials.json"); @@ -299,6 +353,9 @@ impl AccountManager { store .accounts .retain(|a| a.id().ok().as_deref() != Some(id)); + if store.installed_in_claude_code.as_deref() == Some(id) { + store.installed_in_claude_code = None; + } self.save(&store) } @@ -313,6 +370,7 @@ impl AccountManager { target.validate()?; if let Some(current) = read_login(&self.config_dir, &self.config_file)? { if current.id()? == id { + self.set_installed_in_claude_code(Some(id))?; return Ok(()); } // Preserve the latest refresh token before replacing the active login. @@ -357,6 +415,10 @@ impl AccountManager { })); } super::clear_account_caches(&credential_path); + // Best-effort: the switch itself already succeeded. + if let Err(error) = self.set_installed_in_claude_code(Some(id)) { + tracing::warn!(%error, "Claude account switched but its shared-login marker was not saved"); + } Ok(()) } } @@ -687,6 +749,71 @@ mod tests { ); } + #[test] + fn standalone_usage_account_is_the_single_saved_login_never_installed_in_claude_code() { + let dir = tempfile::tempdir().unwrap(); + let manager = manager(dir.path()); + assert!(manager.standalone_usage_credentials().unwrap().is_none()); + + manager.import(login("a", "one", "own-token")).unwrap(); + let (id, oauth) = manager.standalone_usage_credentials().unwrap().unwrap(); + assert_eq!(id, "a:one"); + assert_eq!(oauth["accessToken"], "own-token"); + + // Two candidates: without Claude Code's consent nothing says which is meant. + manager.import(login("b", "two", "other")).unwrap(); + assert!(manager.standalone_usage_credentials().unwrap().is_none()); + + // Installing one into Claude Code shares its refresh chain; the other + // account stays usable on its own. + manager.switch("b:two").unwrap(); + assert_eq!( + manager.standalone_usage_credentials().unwrap().unwrap().0, + "a:one" + ); + + // A saved account that is the only one left but is shared is not offered. + manager.remove("a:one").unwrap(); + assert!(manager.standalone_usage_credentials().unwrap().is_none()); + } + + #[test] + fn shared_login_marker_follows_save_current_import_and_remove() { + let dir = tempfile::tempdir().unwrap(); + let manager = manager(dir.path()); + activate(&manager, &login("a", "one", "live")); + manager.save_current().unwrap(); + assert!(manager.standalone_usage_credentials().unwrap().is_none()); + + // A fresh isolated sign-in of the same account is a new chain. + manager.import(login("a", "one", "fresh-signin")).unwrap(); + let (_, oauth) = manager.standalone_usage_credentials().unwrap().unwrap(); + assert_eq!(oauth["accessToken"], "fresh-signin"); + + manager.save_current().unwrap(); + manager.remove("a:one").unwrap(); + manager.import(login("a", "one", "again")).unwrap(); + assert!(manager.standalone_usage_credentials().unwrap().is_some()); + } + + #[test] + fn store_without_shared_login_marker_still_loads() { + let dir = tempfile::tempdir().unwrap(); + let manager = manager(dir.path()); + std::fs::create_dir_all(&manager.root).unwrap(); + let legacy = json!({"accounts": [{ + "oauth": login("a", "one", "t").oauth, + "identity": login("a", "one", "t").identity, + }]}); + secure_file::write_string(&manager.root.join("accounts.json"), &legacy.to_string()) + .unwrap(); + assert_eq!(manager.load().unwrap().accounts.len(), 1); + assert!(manager.standalone_usage_credentials().unwrap().is_some()); + let summary = manager.saved_summary("a:one").unwrap(); + assert_eq!(summary.email, "same@example.com"); + assert!(manager.saved_summary("missing:acct").is_none()); + } + #[test] fn token_refresh_updates_saved_account_without_creating_one() { let dir = tempfile::tempdir().unwrap(); diff --git a/rust/src/providers/claude/oauth/mod.rs b/rust/src/providers/claude/oauth/mod.rs index c4fb510ed85..e78b30106ae 100644 --- a/rust/src/providers/claude/oauth/mod.rs +++ b/rust/src/providers/claude/oauth/mod.rs @@ -304,6 +304,16 @@ impl ClaudeOAuthFetcher { pub async fn fetch(&self) -> Result { let _account_operation = super::accounts::CREDENTIAL_OPERATION.lock().await; let account_manager = super::accounts::AccountManager::new().ok(); + // Without consent Claude Code's credentials stay closed. A saved + // account that was never installed into Claude Code is CodexBar's own + // and can still supply usage, so a stale or absent Claude Code login + // does not blank the panel. + if !super::claude_code_consent() + && let Some(manager) = &account_manager + && let Some(result) = self.fetch_standalone_saved_account(manager).await + { + return result; + } let saved_account_id = account_manager .as_ref() .and_then(|manager| manager.current_account_id().ok().flatten()); @@ -341,6 +351,57 @@ impl ClaudeOAuthFetcher { let (oauth, active) = manager .usage_credentials(id) .map_err(|e| ProviderError::Other(e.to_string()))?; + self.fetch_saved_account(&manager, id, &oauth, active).await + } + + /// Usage for the one saved account CodexBar may use without Claude Code's + /// credentials. `None` when there is no such account, so the caller keeps + /// its normal path (and its own error) for that case. + async fn fetch_standalone_saved_account( + &self, + manager: &super::accounts::AccountManager, + ) -> Option> { + let (id, oauth) = match manager.standalone_usage_credentials() { + Ok(Some(account)) => account, + Ok(None) => return None, + Err(error) => { + tracing::warn!(%error, "Could not read CodexBar's saved Claude accounts"); + return None; + } + }; + tracing::debug!("Claude Code credentials are off; using the standalone saved account"); + let mut result = self.fetch_saved_account(manager, &id, &oauth, false).await; + match &mut result { + Ok(fetched) => { + // The token says nothing about which login Claude CLI uses. + fetched.account_identity = None; + if let Some(summary) = manager.saved_summary(&id) { + let usage = &mut fetched.usage; + usage.account_email = Some(summary.email); + if summary.organization.is_some() { + usage.account_organization = summary.organization; + } + if let Some(plan) = summary.plan { + usage.login_method = Some(super::claude_plan_label(&plan)); + } + } + } + Err(error) => { + tracing::warn!(%error, "Claude usage from the standalone saved account failed"); + } + } + Some(result) + } + + /// Resolve, refresh when needed, and query one saved account. `active` + /// means Claude Code holds the same login, so a refresh also updates it. + async fn fetch_saved_account( + &self, + manager: &super::accounts::AccountManager, + id: &str, + oauth: &serde_json::Value, + active: bool, + ) -> Result { let oauth = oauth.to_string(); let source = credentials_store::CredentialSource::saved_account( manager.usage_source_path(), @@ -349,13 +410,7 @@ impl ClaudeOAuthFetcher { ); let credentials = credentials_store::parse_credentials_json(&oauth)?; let (credentials, outcome) = self - .ensure_fresh_credentials( - credentials, - source.clone(), - Some(&manager), - Some(id), - active, - ) + .ensure_fresh_credentials(credentials, source.clone(), Some(manager), Some(id), active) .await; if credentials.is_expired() && let Some(error) = outcome @@ -371,7 +426,7 @@ impl ClaudeOAuthFetcher { expired.expires_at = Some(Utc::now()); credentials_store::store_refreshed(&source, &expired); let (refreshed, outcome) = self - .ensure_fresh_credentials(expired, source, Some(&manager), Some(id), active) + .ensure_fresh_credentials(expired, source, Some(manager), Some(id), active) .await; if let Some(error) = outcome { return Err(account_refresh_error(error)); From 5685a397dc15547c2a277ce58ed4fdd4b173c5ac Mon Sep 17 00:00:00 2001 From: "factory-droid[bot]" <138933559+factory-droid[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:21:53 +0000 Subject: [PATCH 2/3] Require recorded isolation for standalone Claude usage Stores saved before the installed marker existed do not say how an account was saved, so an unmarked account may share Claude Code's refresh-token chain. Keep an explicit isolated-accounts list, fed only by a fresh CodexBar sign-in, and offer only those accounts for standalone usage. Saving Claude Code's live login now records the account, its installed marker, and the isolation change in one store write. Installing an account into Claude Code clears its isolated mark. --- rust/src/providers/claude/accounts.rs | 219 +++++++++++++++++++++++--- 1 file changed, 194 insertions(+), 25 deletions(-) diff --git a/rust/src/providers/claude/accounts.rs b/rust/src/providers/claude/accounts.rs index 5ac3f5e3c60..e0166e8bca6 100644 --- a/rust/src/providers/claude/accounts.rs +++ b/rust/src/providers/claude/accounts.rs @@ -80,6 +80,12 @@ struct Store { /// builds ignore this field. #[serde(default, skip_serializing_if = "Option::is_none")] installed_in_claude_code: Option, + /// Accounts whose saved login came from a fresh sign-in CodexBar ran in + /// its own isolated config, so no Claude Code login shares its refresh + /// token chain. Stores from older builds have no list, which means their + /// provenance is unknown and they are never used for standalone usage. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + isolated_accounts: Vec, } pub struct AccountManager { @@ -185,28 +191,39 @@ impl AccountManager { let current = read_login(&self.config_dir, &self.config_file)?.ok_or_else(|| { io::Error::other("No Claude Code subscription login found. Add an account first.") })?; - let id = current.id()?; - self.import(current)?; - self.set_installed_in_claude_code(Some(&id)) + let mut store = self.load()?; + import_into(&mut store, current, true)?; + self.save(&store) } fn set_installed_in_claude_code(&self, id: Option<&str>) -> io::Result<()> { let mut store = self.load()?; + if let Some(id) = id { + // An installed login shares Claude Code's chain, so it is never isolated. + store.isolated_accounts.retain(|known| known != id); + } store.installed_in_claude_code = id.map(str::to_owned); self.save(&store) } /// The only saved account CodexBar may use for usage without reading - /// Claude Code's own credentials: one that was never installed into Claude - /// Code, so nothing else rotates its refresh token. `None` unless there - /// is exactly one, because without that consent the active login is unknown. + /// Claude Code's own credentials. It must have been saved from a fresh + /// isolated sign-in and never installed into Claude Code, so nothing else + /// rotates its refresh token. `None` unless there is exactly one + /// non-installed account, because without that consent the active login + /// is unknown. pub(super) fn standalone_usage_credentials(&self) -> io::Result> { - let store = self.load()?; - let installed = store.installed_in_claude_code; - let mut candidates = store - .accounts + let Store { + accounts, + installed_in_claude_code: installed, + isolated_accounts, + } = self.load()?; + let mut candidates = accounts .into_iter() - .filter(|account| account.id().ok() != installed) + .filter_map(|account| { + let id = account.id().ok()?; + (Some(id.as_str()) != installed.as_deref()).then_some((id, account)) + }) .collect::>(); if candidates.len() != 1 { tracing::debug!( @@ -215,8 +232,12 @@ impl AccountManager { ); return Ok(None); } - let account = candidates.remove(0); - Ok(Some((account.id()?, account.oauth))) + let (id, account) = candidates.remove(0); + if !isolated_accounts.contains(&id) { + tracing::debug!("Saved Claude account has unknown provenance; not used for usage"); + return Ok(None); + } + Ok(Some((id, account.oauth))) } /// Display fields (never credentials) of one saved account. @@ -295,14 +316,10 @@ impl AccountManager { self.save(&store) } + /// Save a fresh sign-in made by CodexBar in its own isolated config. pub fn import(&self, login: SavedLogin) -> io::Result<()> { - login.validate()?; let mut store = self.load()?; - if store.installed_in_claude_code == Some(login.id()?) { - // A new sign-in is a new refresh-token chain, not Claude Code's. - store.installed_in_claude_code = None; - } - upsert(&mut store, login)?; + import_into(&mut store, login, false)?; self.save(&store) } @@ -328,10 +345,7 @@ impl AccountManager { return Err(io::Error::other("Saved Claude account not found.")); } let oauth = login.oauth.clone(); - upsert(&mut store, login)?; - if active { - store.installed_in_claude_code = Some(id.to_owned()); - } + import_into(&mut store, login, active)?; self.save(&store)?; if active && self.current_account_id()?.as_deref() == Some(id) { let path = self.config_dir.join(".credentials.json"); @@ -356,6 +370,7 @@ impl AccountManager { if store.installed_in_claude_code.as_deref() == Some(id) { store.installed_in_claude_code = None; } + store.isolated_accounts.retain(|known| known != id); self.save(&store) } @@ -374,7 +389,8 @@ impl AccountManager { return Ok(()); } // Preserve the latest refresh token before replacing the active login. - upsert(&mut store, current)?; + // This copy is Claude Code's chain, so it loses isolated provenance. + upsert_shared(&mut store, current)?; self.save(&store)?; } let credential_path = self.config_dir.join(".credentials.json"); @@ -437,6 +453,37 @@ fn upsert(store: &mut Store, login: SavedLogin) -> io::Result<()> { Ok(()) } +/// Store a copy of Claude Code's live login. That copy shares its refresh +/// chain with Claude Code, so the account loses isolated provenance. +fn upsert_shared(store: &mut Store, login: SavedLogin) -> io::Result<()> { + let id = login.id()?; + upsert(store, login)?; + store.isolated_accounts.retain(|known| known != &id); + Ok(()) +} + +/// Record a login in `store` without writing it. `installed` means the login +/// is Claude Code's live login, which CodexBar copies; otherwise it is a fresh +/// sign-in made in CodexBar's isolated config. +fn import_into(store: &mut Store, login: SavedLogin, installed: bool) -> io::Result<()> { + login.validate()?; + let id = login.id()?; + if installed { + upsert_shared(store, login)?; + store.installed_in_claude_code = Some(id); + } else { + upsert(store, login)?; + // A new sign-in is a new refresh-token chain, not Claude Code's. + if store.installed_in_claude_code.as_deref() == Some(id.as_str()) { + store.installed_in_claude_code = None; + } + if !store.isolated_accounts.contains(&id) { + store.isolated_accounts.push(id); + } + } + Ok(()) +} + fn required_string<'a>(object: &'a Value, key: &str) -> io::Result<&'a str> { object .get(key) @@ -801,6 +848,8 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let manager = manager(dir.path()); std::fs::create_dir_all(&manager.root).unwrap(); + // Saved by a pre-marker build: provenance is unknown, so it may share + // Claude Code's refresh chain and must not be used for standalone usage. let legacy = json!({"accounts": [{ "oauth": login("a", "one", "t").oauth, "identity": login("a", "one", "t").identity, @@ -808,10 +857,130 @@ mod tests { secure_file::write_string(&manager.root.join("accounts.json"), &legacy.to_string()) .unwrap(); assert_eq!(manager.load().unwrap().accounts.len(), 1); - assert!(manager.standalone_usage_credentials().unwrap().is_some()); + assert!(manager.standalone_usage_credentials().unwrap().is_none()); let summary = manager.saved_summary("a:one").unwrap(); assert_eq!(summary.email, "same@example.com"); assert!(manager.saved_summary("missing:acct").is_none()); + let accounts = manager.list_with_consent(false).unwrap(); + assert_eq!(accounts.len(), 1); + assert!(accounts[0].is_saved); + + // Re-adding the account through a fresh isolated sign-in makes it eligible. + manager.import(login("a", "one", "fresh")).unwrap(); + let (id, oauth) = manager.standalone_usage_credentials().unwrap().unwrap(); + assert_eq!(id, "a:one"); + assert_eq!(oauth["accessToken"], "fresh"); + } + + #[test] + fn legacy_account_never_fills_the_standalone_slot() { + let dir = tempfile::tempdir().unwrap(); + let manager = manager(dir.path()); + std::fs::create_dir_all(&manager.root).unwrap(); + let legacy = json!({"accounts": [{ + "oauth": login("a", "one", "t").oauth, + "identity": login("a", "one", "t").identity, + }]}); + secure_file::write_string(&manager.root.join("accounts.json"), &legacy.to_string()) + .unwrap(); + manager.import(login("b", "two", "fresh")).unwrap(); + // Two non-installed accounts: the single-candidate rule still applies. + assert!(manager.standalone_usage_credentials().unwrap().is_none()); + manager.switch("b:two").unwrap(); + // Only the legacy account is left unassigned, and it has unknown provenance. + assert!(manager.standalone_usage_credentials().unwrap().is_none()); + } + + #[test] + fn import_marks_isolated_and_remove_clears_it() { + let dir = tempfile::tempdir().unwrap(); + let manager = manager(dir.path()); + manager.import(login("a", "one", "fresh")).unwrap(); + assert_eq!(manager.load().unwrap().isolated_accounts, ["a:one"]); + assert!(manager.standalone_usage_credentials().unwrap().is_some()); + + manager.remove("a:one").unwrap(); + assert!(manager.load().unwrap().isolated_accounts.is_empty()); + assert!(manager.load().unwrap().accounts.is_empty()); + + // Re-adding it from Claude Code's live login is a shared copy, not isolated. + activate(&manager, &login("a", "one", "live")); + manager.save_current().unwrap(); + assert!(manager.load().unwrap().isolated_accounts.is_empty()); + assert!(manager.standalone_usage_credentials().unwrap().is_none()); + } + + #[test] + fn saving_claude_code_login_as_shared_copy_is_not_eligible() { + let dir = tempfile::tempdir().unwrap(); + let manager = manager(dir.path()); + manager.import(login("a", "one", "isolated")).unwrap(); + assert!(manager.standalone_usage_credentials().unwrap().is_some()); + + // Copying Claude Code's live login overwrites the saved copy with the shared chain. + activate(&manager, &login("a", "one", "live")); + manager.save_current().unwrap(); + let store = manager.load().unwrap(); + assert_eq!(store.installed_in_claude_code.as_deref(), Some("a:one")); + assert!(store.isolated_accounts.is_empty()); + assert!(manager.standalone_usage_credentials().unwrap().is_none()); + // Once it is no longer installed, it still lacks isolated provenance. + manager.import(login("b", "two", "other")).unwrap(); + manager.switch("b:two").unwrap(); + assert!(manager.standalone_usage_credentials().unwrap().is_none()); + } + + #[test] + fn import_into_records_login_and_marker_in_one_store() { + let mut store = Store::default(); + import_into(&mut store, login("a", "one", "live"), true).unwrap(); + assert_eq!(store.accounts.len(), 1); + assert_eq!(store.installed_in_claude_code.as_deref(), Some("a:one")); + assert!(store.isolated_accounts.is_empty()); + + // An invalid login fails before anything changes, so the store is never half-updated. + let mut broken = login("b", "two", "t"); + broken.oauth = json!({"accessToken": "t"}); + assert!(import_into(&mut store, broken, true).is_err()); + assert_eq!(store.accounts.len(), 1); + assert_eq!(store.installed_in_claude_code.as_deref(), Some("a:one")); + } + + #[test] + fn ambient_login_copied_on_switch_loses_isolated_provenance() { + let dir = tempfile::tempdir().unwrap(); + let manager = manager(dir.path()); + manager.import(login("a", "one", "isolated")).unwrap(); + // Claude Code holds a login for the same account that was not CodexBar's. + activate(&manager, &login("a", "one", "ambient")); + manager.import(login("b", "two", "new")).unwrap(); + manager.switch("b:two").unwrap(); + // The outgoing copy now carries Claude Code's chain, so it is not offered. + // The account now installed in Claude Code is no longer isolated either. + assert!(manager.load().unwrap().isolated_accounts.is_empty()); + assert!(manager.standalone_usage_credentials().unwrap().is_none()); + } + + #[test] + fn standalone_skips_accounts_with_unreadable_identity() { + let dir = tempfile::tempdir().unwrap(); + let manager = manager(dir.path()); + std::fs::create_dir_all(&manager.root).unwrap(); + let broken = json!({ + "oauth": login("x", "y", "broken").oauth, + "identity": {"emailAddress": "same@example.com"}, + }); + let store = json!({ + "accounts": [broken, { + "oauth": login("a", "one", "t").oauth, + "identity": login("a", "one", "t").identity, + }], + "isolated_accounts": ["a:one"], + }); + secure_file::write_string(&manager.root.join("accounts.json"), &store.to_string()).unwrap(); + let (id, oauth) = manager.standalone_usage_credentials().unwrap().unwrap(); + assert_eq!(id, "a:one"); + assert_eq!(oauth["accessToken"], "t"); } #[test] From 799739a3b4d4f3f2aa8f9cbe6a924a025b9860d9 Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Sun, 18 Oct 2026 03:27:19 +0700 Subject: [PATCH 3/3] Harden standalone Claude usage fallback - Drop a switched-to account's isolated provenance before the credential swap, so a later marker write cannot leave Claude Code's login eligible. - Honor CODEXBAR_CLAUDE_OAUTH_TOKEN before the standalone fallback runs. - Name the real eligibility rule in the CHANGELOG entry. - Test the saved-label step of the standalone result. --- CHANGELOG.md | 2 +- rust/src/providers/claude/accounts.rs | 30 ++++++++ .../claude/oauth/credentials_store.rs | 6 ++ rust/src/providers/claude/oauth/mod.rs | 47 ++++++++----- rust/src/providers/claude/oauth/tests.rs | 68 +++++++++++++++++++ 5 files changed, 136 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c79e5d3ce68..8c8ded46598 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ ## [Windows] Unreleased -- Keep Claude usage working when reading Claude Code's credentials is off and its own login is missing or expired: use the one saved Claude account that was never installed into Claude Code, renewing its token in CodexBar's own store without touching Claude Code. +- 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). - **Claude**: in Auto mode, a Claude CLI probe timeout now falls back to the retained CLI usage from the last 15 minutes, as other CLI failures already did, instead of failing the refresh outright. Without a retained result the timeout is reported as before. diff --git a/rust/src/providers/claude/accounts.rs b/rust/src/providers/claude/accounts.rs index e0166e8bca6..d2d5af4dc1b 100644 --- a/rust/src/providers/claude/accounts.rs +++ b/rust/src/providers/claude/accounts.rs @@ -383,6 +383,10 @@ impl AccountManager { .cloned() .ok_or_else(|| io::Error::other("Saved Claude account not found."))?; target.validate()?; + // Once this login is Claude Code's, its refresh chain is shared and it is + // no longer a standalone candidate. Persist that with the save below, + // before the credentials change, so a later marker write cannot leave it isolated. + store.isolated_accounts.retain(|known| known != id); if let Some(current) = read_login(&self.config_dir, &self.config_file)? { if current.id()? == id { self.set_installed_in_claude_code(Some(id))?; @@ -392,6 +396,8 @@ impl AccountManager { // This copy is Claude Code's chain, so it loses isolated provenance. upsert_shared(&mut store, current)?; self.save(&store)?; + } else if let Err(error) = self.save(&store) { + tracing::warn!(%error, "Could not record the switch before changing Claude Code's login"); } let credential_path = self.config_dir.join(".credentials.json"); let old_credentials = read_object(&credential_path)?; @@ -824,6 +830,30 @@ mod tests { assert!(manager.standalone_usage_credentials().unwrap().is_none()); } + #[cfg(unix)] + #[test] + fn failed_switch_does_not_leave_the_target_isolated() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let manager = manager(dir.path()); + manager.import(login("a", "one", "isolated")).unwrap(); + activate(&manager, &login("c", "three", "live")); + // The outgoing login is saved before the credentials change. A read-only + // config directory makes that change fail after the save. + let writable = std::fs::metadata(&manager.config_dir) + .unwrap() + .permissions(); + std::fs::set_permissions(&manager.config_dir, std::fs::Permissions::from_mode(0o500)) + .unwrap(); + let result = manager.switch("a:one"); + std::fs::set_permissions(&manager.config_dir, writable).unwrap(); + + assert!(result.is_err()); + let store = manager.load().unwrap(); + assert!(!store.isolated_accounts.contains(&"a:one".to_string())); + assert_eq!(store.installed_in_claude_code, None); + } + #[test] fn shared_login_marker_follows_save_current_import_and_remove() { let dir = tempfile::tempdir().unwrap(); diff --git a/rust/src/providers/claude/oauth/credentials_store.rs b/rust/src/providers/claude/oauth/credentials_store.rs index 19056585faa..5c5456b3889 100644 --- a/rust/src/providers/claude/oauth/credentials_store.rs +++ b/rust/src/providers/claude/oauth/credentials_store.rs @@ -144,6 +144,12 @@ pub(super) fn load_credentials() -> Result<(ClaudeOAuthCredentials, CredentialSo Err(file_error) } +/// Whether a non-empty `CODEXBAR_CLAUDE_OAUTH_TOKEN` override is set. It is +/// honored before Claude Code's consent, so callers must check it first. +pub(super) fn environment_token_present() -> bool { + load_from_environment().is_some() +} + /// Load credentials from environment variables fn load_from_environment() -> Option { let token = std::env::var(ENV_TOKEN_KEY).ok()?; diff --git a/rust/src/providers/claude/oauth/mod.rs b/rust/src/providers/claude/oauth/mod.rs index 4c0e0a5a49a..6b60bab73ca 100644 --- a/rust/src/providers/claude/oauth/mod.rs +++ b/rust/src/providers/claude/oauth/mod.rs @@ -310,6 +310,34 @@ fn account_refresh_error(error: ProviderError) -> ProviderError { } } +/// Whether the standalone saved account may stand in for Claude Code's login. +/// An explicit OAuth token from the environment always wins, so it is checked +/// first; the standalone account never replaces it or runs ahead of it. +fn standalone_fallback_applies() -> bool { + !credentials_store::environment_token_present() && !super::claude_code_consent() +} + +/// Standalone results carry the saved account's own labels and never the +/// identity a token may hold, because the token says nothing about which +/// login Claude CLI uses. +fn apply_standalone_summary( + fetched: &mut ProviderFetchResult, + summary: Option, +) { + fetched.account_identity = None; + let Some(summary) = summary else { + return; + }; + let usage = &mut fetched.usage; + usage.account_email = Some(summary.email); + if summary.organization.is_some() { + usage.account_organization = summary.organization; + } + if let Some(plan) = summary.plan { + usage.login_method = Some(super::claude_plan_label(&plan)); + } +} + impl ClaudeOAuthFetcher { const USAGE_URL: &'static str = "https://api.anthropic.com/api/oauth/usage"; const DEFAULT_RATE_LIMIT_BACKOFF: Duration = Duration::from_secs(5 * 60); @@ -329,8 +357,8 @@ impl ClaudeOAuthFetcher { // Without consent Claude Code's credentials stay closed. A saved // account that was never installed into Claude Code is CodexBar's own // and can still supply usage, so a stale or absent Claude Code login - // does not blank the panel. - if !super::claude_code_consent() + // does not blank the panel. An environment token is checked first. + if standalone_fallback_applies() && let Some(manager) = &account_manager && let Some(result) = self.fetch_standalone_saved_account(manager).await { @@ -394,20 +422,7 @@ impl ClaudeOAuthFetcher { tracing::debug!("Claude Code credentials are off; using the standalone saved account"); let mut result = self.fetch_saved_account(manager, &id, &oauth, false).await; match &mut result { - Ok(fetched) => { - // The token says nothing about which login Claude CLI uses. - fetched.account_identity = None; - if let Some(summary) = manager.saved_summary(&id) { - let usage = &mut fetched.usage; - usage.account_email = Some(summary.email); - if summary.organization.is_some() { - usage.account_organization = summary.organization; - } - if let Some(plan) = summary.plan { - usage.login_method = Some(super::claude_plan_label(&plan)); - } - } - } + Ok(fetched) => apply_standalone_summary(fetched, manager.saved_summary(&id)), Err(error) => { tracing::warn!(%error, "Claude usage from the standalone saved account failed"); } diff --git a/rust/src/providers/claude/oauth/tests.rs b/rust/src/providers/claude/oauth/tests.rs index 9f47cc45290..1ecb0f96a3b 100644 --- a/rust/src/providers/claude/oauth/tests.rs +++ b/rust/src/providers/claude/oauth/tests.rs @@ -723,3 +723,71 @@ fn gate_survives_a_poisoned_mutex() { assert!(joined.is_err()); assert!(ClaudeOAuthFetcher::gate_precheck(&gate, 1_000_000_000_000, "fp").is_none()); } + +#[test] +fn standalone_result_takes_saved_labels_and_drops_token_identity() { + use crate::core::{ProviderFetchResult, RateWindow, UsageSnapshot}; + + let summary = super::super::accounts::ClaudeAccount { + id: "a:one".into(), + email: "saved@example.com".into(), + organization: Some("Saved Org".into()), + plan: Some("claude_max_5x".into()), + is_active: false, + is_saved: true, + }; + let fresh = || { + ProviderFetchResult::new(UsageSnapshot::new(RateWindow::new(10.0)), "oauth") + .with_account_identity("token-subject") + }; + + let mut fetched = fresh(); + super::apply_standalone_summary(&mut fetched, Some(summary)); + assert_eq!(fetched.account_identity, None); + assert_eq!( + fetched.usage.account_email.as_deref(), + Some("saved@example.com") + ); + assert_eq!( + fetched.usage.account_organization.as_deref(), + Some("Saved Org") + ); + assert_eq!(fetched.usage.login_method.as_deref(), Some("Claude Max 5x")); + + // Without a saved summary only the token identity is dropped. + let mut bare = fresh(); + super::apply_standalone_summary(&mut bare, None); + assert_eq!(bare.account_identity, None); + assert_eq!(bare.usage.account_email, None); + assert_eq!(bare.usage.login_method, None); +} + +static ENV_TOKEN_TESTS: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +#[test] +fn environment_token_is_honored_before_standalone_and_consent() { + use super::credentials_store::{CredentialSource, environment_token_present, load_credentials}; + const KEY: &str = "CODEXBAR_CLAUDE_OAUTH_TOKEN"; + let _guard = ENV_TOKEN_TESTS + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + + // SAFETY: every test that changes this variable holds ENV_TOKEN_TESTS. + unsafe { std::env::set_var(KEY, "env-token") }; + let present = environment_token_present(); + // The env check short-circuits, so Claude Code's consent is never read. + let standalone = super::standalone_fallback_applies(); + let loaded = load_credentials(); + // SAFETY: same guarded test-only mutation as above. + unsafe { std::env::set_var(KEY, " ") }; + let blank = environment_token_present(); + // SAFETY: same guarded test-only mutation as above. + unsafe { std::env::remove_var(KEY) }; + + assert!(present); + assert!(!standalone); + let (credentials, source) = loaded.expect("environment token loads without consent"); + assert_eq!(credentials.access_token, "env-token"); + assert!(matches!(source, CredentialSource::Environment)); + assert!(!blank); +}