diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d49625f96..8c8ded4659 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 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 b2233bdc9b..d2d5af4dc1 100644 --- a/rust/src/providers/claude/accounts.rs +++ b/rust/src/providers/claude/accounts.rs @@ -74,6 +74,18 @@ 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, + /// 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 { @@ -179,7 +191,63 @@ 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 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. 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 { + accounts, + installed_in_claude_code: installed, + isolated_accounts, + } = self.load()?; + let mut candidates = accounts + .into_iter() + .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!( + candidates = candidates.len(), + "No single standalone saved Claude account to use for usage" + ); + return Ok(None); + } + 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. + 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 @@ -248,10 +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()?; - upsert(&mut store, login)?; + import_into(&mut store, login, false)?; self.save(&store) } @@ -277,7 +345,7 @@ impl AccountManager { return Err(io::Error::other("Saved Claude account not found.")); } let oauth = login.oauth.clone(); - upsert(&mut store, login)?; + 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"); @@ -299,6 +367,10 @@ 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; + } + store.isolated_accounts.retain(|known| known != id); self.save(&store) } @@ -311,13 +383,21 @@ 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))?; 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)?; + } 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)?; @@ -357,6 +437,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(()) } } @@ -375,6 +459,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) @@ -687,6 +802,217 @@ 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()); + } + + #[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(); + 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(); + // 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, + }]}); + 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_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] fn token_refresh_updates_saved_account_without_creating_one() { 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 19056585fa..5c5456b388 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 db1a6b48d4..6b60bab73c 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); @@ -326,6 +354,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. 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 + { + return result; + } let saved_account_id = account_manager .as_ref() .and_then(|manager| manager.current_account_id().ok().flatten()); @@ -363,6 +401,44 @@ 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) => apply_standalone_summary(fetched, manager.saved_summary(&id)), + 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(), @@ -371,13 +447,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 @@ -393,7 +463,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)); diff --git a/rust/src/providers/claude/oauth/tests.rs b/rust/src/providers/claude/oauth/tests.rs index 9f47cc4529..1ecb0f96a3 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); +}