diff --git a/apps/desktop-tauri/src-tauri/src/commands/claude_accounts.rs b/apps/desktop-tauri/src-tauri/src/commands/claude_accounts.rs new file mode 100644 index 0000000000..0186abeebb --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/commands/claude_accounts.rs @@ -0,0 +1,131 @@ +use super::invalidate_account_usage; +use crate::state::AppState; +use codexbar::core::ProviderId; +use codexbar::providers::claude::accounts::{self, AccountManager, ClaudeAccount}; +use std::sync::Mutex; +use tauri::Emitter; +use tauri::Manager; + +static MUTATION: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + +#[tauri::command] +pub fn claude_accounts_list() -> Result, String> { + AccountManager::new() + .and_then(|m| m.list()) + .map_err(|e| e.to_string()) +} + +fn changed(app: &tauri::AppHandle) { + let _emit = app.emit("claude-accounts-updated", ()); +} + +#[tauri::command] +pub async fn claude_account_add(app: tauri::AppHandle) -> Result<(), String> { + let _mutation = MUTATION + .try_lock() + .map_err(|_| "A Claude account operation is already in progress.")?; + accounts::begin_login(); + let login = tauri::async_runtime::spawn_blocking(accounts::login) + .await + .map_err(|e| e.to_string())? + .map_err(|e| e.to_string())?; + let _credentials = accounts::CREDENTIAL_OPERATION.lock().await; + AccountManager::new() + .and_then(|m| m.import(login)) + .map_err(|e| e.to_string())?; + changed(&app); + Ok(()) +} + +#[tauri::command] +pub fn claude_account_cancel_login() { + accounts::cancel_login(); +} + +#[tauri::command] +pub async fn claude_account_save_current(app: tauri::AppHandle) -> Result<(), String> { + let _mutation = MUTATION + .try_lock() + .map_err(|_| "A Claude account operation is already in progress.")?; + let _credentials = accounts::CREDENTIAL_OPERATION.lock().await; + AccountManager::new() + .and_then(|m| m.save_current()) + .map_err(|e| e.to_string())?; + changed(&app); + Ok(()) +} + +#[tauri::command] +pub async fn claude_account_remove(app: tauri::AppHandle, id: String) -> Result<(), String> { + let _mutation = MUTATION + .try_lock() + .map_err(|_| "A Claude account operation is already in progress.")?; + let _credentials = accounts::CREDENTIAL_OPERATION.lock().await; + AccountManager::new() + .and_then(|m| m.remove(&id)) + .map_err(|e| e.to_string())?; + changed(&app); + Ok(()) +} + +#[tauri::command] +pub async fn claude_account_switch(app: tauri::AppHandle, id: String) -> Result<(), String> { + let _mutation = MUTATION + .try_lock() + .map_err(|_| "A Claude account operation is already in progress.")?; + let _credentials = accounts::CREDENTIAL_OPERATION.lock().await; + tauri::async_runtime::spawn_blocking(move || { + accounts::require_cli_closed()?; + AccountManager::new()?.switch(&id) + }) + .await + .map_err(|e| e.to_string())? + .map_err(|e| e.to_string())?; + let pending = { + let state = app.state::>(); + let mut state = state.lock().map_err(|e| e.to_string())?; + invalidate_account_usage(&mut state, ProviderId::Claude) + }; + crate::events::emit_provider_updated(&app, &pending); + drop(_credentials); + changed(&app); + tauri::async_runtime::spawn(async move { + let _refresh = super::refresh_providers(app).await; + }); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn switching_invalidates_old_identity_usage_and_inflight_results() { + let mut state = AppState::new(); + let mut old = invalidate_account_usage(&mut state, ProviderId::Claude); + old.account_email = Some("old@example.com".into()); + old.plan_name = Some("old-plan".into()); + old.error = None; + old.primary.used_percent = 80.0; + state.provider_cache = vec![old]; + state.is_refreshing = true; + state + .transient_provider_failure_counts + .insert(ProviderId::Claude, 1); + let generation = state.provider_refresh_generation; + let pending = invalidate_account_usage(&mut state, ProviderId::Claude); + assert!(pending.account_email.is_none()); + assert!(pending.plan_name.is_none()); + assert!(pending.error.is_some()); + assert_eq!(pending.primary.used_percent, 0.0); + assert_eq!(state.provider_cache.len(), 1); + assert!(state.provider_cache[0].error.is_some()); + assert_ne!(state.provider_refresh_generation, generation); + assert!(!state.is_refreshing); + assert!( + !state + .transient_provider_failure_counts + .contains_key(&ProviderId::Claude) + ); + } +} diff --git a/apps/desktop-tauri/src-tauri/src/commands/mod.rs b/apps/desktop-tauri/src-tauri/src/commands/mod.rs index cd5420ef51..c9584a7ad7 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/mod.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/mod.rs @@ -32,6 +32,7 @@ mod usage_spend; mod agent_sessions; mod bridge; mod browser_import; +mod claude_accounts; mod codex_accounts; mod codex_workspaces; mod credential_detection; @@ -49,6 +50,7 @@ mod system; pub use agent_sessions::*; pub(crate) use bridge::*; pub use browser_import::*; +pub use claude_accounts::*; pub use codex_accounts::*; pub use codex_workspaces::*; pub use credential_detection::*; diff --git a/apps/desktop-tauri/src-tauri/src/commands/providers.rs b/apps/desktop-tauri/src-tauri/src/commands/providers.rs index d213998512..ba84cd26e8 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/providers.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/providers.rs @@ -5,6 +5,28 @@ use std::sync::Arc; const MAX_CONCURRENT_PROVIDER_FETCHES: usize = 8; +/// Account changes supersede the old identity's cache and any in-flight batch. +pub(crate) fn invalidate_account_usage( + state: &mut AppState, + id: ProviderId, +) -> ProviderUsageSnapshot { + state.provider_refresh_generation = state.provider_refresh_generation.wrapping_add(1); + state.is_refreshing = false; + state.provider_refresh_started_at = None; + state.transient_provider_failure_counts.remove(&id); + state + .provider_cache + .retain(|snapshot| snapshot.provider_id != id.cli_name()); + let pending = ProviderUsageSnapshot::from_error( + id, + instantiate_provider(id).metadata(), + format!("Account changed. Refreshing {} usage…", id.display_name()), + codexbar::core::ProviderStateKind::Unknown, + ); + state.provider_cache.push(pending.clone()); + pending +} + // ── Provider refresh commands ──────────────────────────────────────── /// Build a `FetchContext` for a provider using persisted cookies/keys. diff --git a/apps/desktop-tauri/src-tauri/src/main.rs b/apps/desktop-tauri/src-tauri/src/main.rs index e7e1cfebda..dc139423cd 100644 --- a/apps/desktop-tauri/src-tauri/src/main.rs +++ b/apps/desktop-tauri/src-tauri/src/main.rs @@ -176,6 +176,12 @@ fn main() { commands::get_cached_providers, commands::get_deepseek_pricing_status, commands::codex_accounts_list, + commands::claude_accounts_list, + commands::claude_account_add, + commands::claude_account_cancel_login, + commands::claude_account_save_current, + commands::claude_account_remove, + commands::claude_account_switch, commands::codex_account_add, commands::codex_account_remove, commands::codex_account_switch, @@ -254,6 +260,9 @@ fn main() { floatbar::set_float_bar_orientation, ]) .setup(move |app| { + if let Err(error) = codexbar::providers::claude::accounts::cleanup_abandoned_logins() { + tracing::warn!("failed to clean abandoned Claude sign-in directories: {error}"); + } if let Some(window) = app.get_webview_window("main") { shell::dwm::force_dark_caption(&window); window.hide()?; diff --git a/apps/desktop-tauri/src/i18n/keys.ts b/apps/desktop-tauri/src/i18n/keys.ts index 145961fac0..dfccba100c 100644 --- a/apps/desktop-tauri/src/i18n/keys.ts +++ b/apps/desktop-tauri/src/i18n/keys.ts @@ -307,6 +307,14 @@ export const ALL_LOCALE_KEYS = [ "ProviderCodexSparkUsage", "ProviderCodexSparkUsageHelp", "CodexAccountsTitle", + "ClaudeAccountsTitle", + "ClaudeAccountsHint", + "ClaudeAccountsEmpty", + "ClaudeAccountsSaveCurrent", + "ClaudeAccountsCancelLogin", + "ClaudeAccountsSigningIn", + "ClaudeAccountsSwitched", + "ClaudeAccountsAdded", "CodexAccountsHint", "CodexAccountsAddButton", "CodexAccountsSwitchButton", diff --git a/apps/desktop-tauri/src/lib/tauri.ts b/apps/desktop-tauri/src/lib/tauri.ts index d255a3815e..971e17b4da 100644 --- a/apps/desktop-tauri/src/lib/tauri.ts +++ b/apps/desktop-tauri/src/lib/tauri.ts @@ -1,5 +1,6 @@ import { invoke } from "@tauri-apps/api/core"; import type { + ClaudeAccount, ApiKeyInfoBridge, ApiKeyProviderInfoBridge, AppInfoBridge, @@ -42,6 +43,13 @@ import type { DeepSeekPricingStatus, } from "../types/bridge"; +export const claudeAccountsList = () => invoke("claude_accounts_list"); +export const claudeAccountAdd = () => invoke("claude_account_add"); +export const claudeAccountCancelLogin = () => invoke("claude_account_cancel_login"); +export const claudeAccountSaveCurrent = () => invoke("claude_account_save_current"); +export const claudeAccountRemove = (id: string) => invoke("claude_account_remove", { id }); +export const claudeAccountSwitch = (id: string) => invoke("claude_account_switch", { id }); + export function getBootstrapState(): Promise { return invoke("get_bootstrap_state"); } diff --git a/apps/desktop-tauri/src/styles.css b/apps/desktop-tauri/src/styles.css index 80e96b4f23..9001e47dcd 100644 --- a/apps/desktop-tauri/src/styles.css +++ b/apps/desktop-tauri/src/styles.css @@ -6028,3 +6028,16 @@ html:has(.menu-surface--tray) { margin-top: 4px; font-weight: 500; } + +.codex-accounts .credential-card__header { + flex-direction: column; + align-items: stretch; + gap: 8px; +} +.codex-accounts .credential-card__actions { + justify-content: flex-end; + flex-wrap: wrap; +} +.codex-accounts .credential-card__badge { + align-self: flex-start; +} diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx index 0be85b878f..fed2273fa2 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx @@ -33,6 +33,7 @@ import { UsageSourceSection } from "./sections/UsageSourceSection"; import { shouldShowCookieSource } from "./sections/usageSourcePolicy"; import { RegionSection } from "./sections/RegionSection"; import { CodexUsageOptions } from "./sections/credentials/CodexUsageOptions"; +import { ClaudeAccountsSection } from "./sections/credentials/ClaudeAccountsSection"; import { CodexAccountsSection } from "./sections/credentials/CodexAccountsSection"; import { TokenAccountsPanel } from "../tokens/TokenAccountsPanel"; import { ApiKeySection } from "./ApiKeySection"; @@ -336,6 +337,7 @@ export function ProviderDetailPane({ {detail.id === "codex" && } {detail.id === "codex" && } + {detail.id === "claude" && } ({ + claudeAccountsList: vi.fn(), claudeAccountAdd: vi.fn(), claudeAccountCancelLogin: vi.fn(), + claudeAccountSaveCurrent: vi.fn(), claudeAccountRemove: vi.fn(), claudeAccountSwitch: vi.fn(), +})); +const events = vi.hoisted(() => ({ listen: vi.fn<(event: string, listener: () => void) => Promise<() => void>>() })); +vi.mock("../../../../../lib/tauri", () => mocks); +vi.mock("@tauri-apps/api/event", () => events); +import { ClaudeAccountsSection } from "./ClaudeAccountsSection"; + +const t = (key: string) => key; +const current: ClaudeAccount = { id: "one:org", email: "one@example.com", organization: "Work", plan: "max", isActive: true, isSaved: false }; +const other: ClaudeAccount = { ...current, id: "two:org", email: "two@example.com", isActive: false, isSaved: true }; + +describe("ClaudeAccountsSection", () => { + beforeEach(() => { + vi.resetAllMocks(); + events.listen.mockResolvedValue(() => {}); + mocks.claudeAccountsList.mockResolvedValue([current, other]); + }); + + it("offers to save the discovered account and switches only saved inactive accounts", async () => { + render(); + await screen.findByText(current.email); + expect(screen.getAllByText("CodexAccountsSwitchButton")).toHaveLength(1); + await act(async () => fireEvent.click(screen.getByText("ClaudeAccountsSaveCurrent"))); + expect(mocks.claudeAccountSaveCurrent).toHaveBeenCalledOnce(); + await act(async () => fireEvent.click(screen.getByText("CodexAccountsSwitchButton"))); + expect(mocks.claudeAccountSwitch).toHaveBeenCalledWith(other.id); + expect(screen.getByRole("status").textContent).toBe("ClaudeAccountsSwitched"); + }); + + it("keeps mutations disabled across an account update during browser login and supports cancel", async () => { + let finish!: () => void; + mocks.claudeAccountAdd.mockImplementation(() => new Promise(resolve => { finish = resolve; })); + mocks.claudeAccountCancelLogin.mockResolvedValue(undefined); + render(); + await screen.findByText(current.email); + fireEvent.click(screen.getByText("CodexAccountsAddButton")); + const eventCallback = events.listen.mock.calls[0][1] as unknown as () => void; + await act(async () => eventCallback()); + expect((screen.getByText("CodexAccountsSwitchButton") as HTMLButtonElement).disabled).toBe(true); + await act(async () => fireEvent.click(screen.getByText("ClaudeAccountsCancelLogin"))); + expect(mocks.claudeAccountCancelLogin).toHaveBeenCalledOnce(); + await act(async () => finish()); + expect(screen.queryByText("ClaudeAccountsCancelLogin")).toBeNull(); + expect((screen.getByText("CodexAccountsAddButton") as HTMLButtonElement).disabled).toBe(false); + }); + + it("shows a switch error without claiming success and allows retry", async () => { + mocks.claudeAccountSwitch.mockRejectedValue("Close Claude Code first."); + render(); + await screen.findByText(other.email); + await act(async () => fireEvent.click(screen.getByText("CodexAccountsSwitchButton"))); + expect(screen.getByRole("alert").textContent).toContain("Close Claude Code first."); + expect(screen.queryByText("ClaudeAccountsSwitched")).toBeNull(); + expect((screen.getByText("CodexAccountsSwitchButton") as HTMLButtonElement).disabled).toBe(false); + }); + + it("shows initial loading errors while keeping sign-in accessible", async () => { + mocks.claudeAccountsList.mockRejectedValue("Could not read saved accounts."); + render(); + await waitFor(() => expect(screen.getByRole("alert").textContent).toContain("Could not read")); + expect((screen.getByText("CodexAccountsAddButton") as HTMLButtonElement).disabled).toBe(false); + }); +}); diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeAccountsSection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeAccountsSection.tsx new file mode 100644 index 0000000000..385da3ed49 --- /dev/null +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeAccountsSection.tsx @@ -0,0 +1,135 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { listen } from "@tauri-apps/api/event"; +import type { ClaudeAccount } from "../../../../../types/bridge"; +import type { LocaleKey } from "../../../../../i18n/keys"; +import { + claudeAccountsList, + claudeAccountAdd, + claudeAccountCancelLogin, + claudeAccountSaveCurrent, + claudeAccountRemove, + claudeAccountSwitch, +} from "../../../../../lib/tauri"; + +export function ClaudeAccountsSection({ t }: { t: (key: LocaleKey) => string }) { + const [accounts, setAccounts] = useState([]); + const [busy, setBusy] = useState(false); + const [loggingIn, setLoggingIn] = useState(false); + const [error, setError] = useState(null); + const [message, setMessage] = useState(null); + const mounted = useRef(false); + const load = useCallback(async () => { + const next = await claudeAccountsList(); + if (mounted.current) setAccounts(next); + }, []); + useEffect(() => { + mounted.current = true; + const reload = () => { + void load().catch(e => { + if (mounted.current) setError(String(e)); + }); + }; + reload(); + const unlisten = listen("claude-accounts-updated", reload); + return () => { + mounted.current = false; + void unlisten.then(fn => fn()); + }; + }, [load]); + const run = async (operation: () => Promise, success?: LocaleKey) => { + setBusy(true); + setError(null); + setMessage(null); + try { + await operation(); + await load(); + if (mounted.current && success) setMessage(t(success)); + } catch (e) { + if (mounted.current) setError(String(e)); + } finally { + if (mounted.current) { + setBusy(false); + setLoggingIn(false); + } + } + }; + return ( +
+

{t("ClaudeAccountsTitle")}

+

{t("ClaudeAccountsHint")}

+ {error &&
{error}
} + {message &&
{message}
} + {loggingIn &&

{t("ClaudeAccountsSigningIn")}

} + {accounts.length === 0 &&

{t("ClaudeAccountsEmpty")}

} +
    + {accounts.map(account => ( +
  • +
    +
    + {account.email} + + {[ + account.organization?.includes(account.email) ? null : account.organization, + account.plan, + ].filter(Boolean).join(" · ")} + + {account.isActive && ( + + {t("TokenAccountActive")} + + )} +
    +
    + {!account.isActive && account.isSaved && ( + + )} + {!account.isSaved && ( + + )} + {account.isSaved && ( + + )} +
    +
    +
  • + ))} +
+ + {loggingIn && ( + + )} +
+ ); +} diff --git a/apps/desktop-tauri/src/types/bridge.ts b/apps/desktop-tauri/src/types/bridge.ts index 4c6ca7175c..b606f65c7a 100644 --- a/apps/desktop-tauri/src/types/bridge.ts +++ b/apps/desktop-tauri/src/types/bridge.ts @@ -974,3 +974,11 @@ export interface CodexAccountsStateBridge { displayNames?: Record; snapshots: Record; } +export interface ClaudeAccount { + id: string; + email: string; + organization: string | null; + plan: string | null; + isActive: boolean; + isSaved: boolean; +} diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 3425d62216..c184deb70f 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -72,6 +72,39 @@ $env:CODEXBAR_PROOF_MODE = "settings:menu" # then launch the desktop binary ``` +## Claude Code accounts + +In **Settings → Providers → Claude → Claude Code accounts**, use **Save current +account** to retain an existing CLI login, or **Add account** to sign in to another +Claude subscription in your browser. Adding an account leaves the current CLI +login active. The native Claude Code executable must be installed. + +Close running Claude Code CLI sessions, select **Switch**, then reopen the CLI. +The tray's **Claude Code accounts** submenu provides the same actions. Win-CodexBar +saves the outgoing login before switching, including its latest refresh token. +**Remove** forgets the saved copy; it does not log out an active CLI session. + +Saved logins are protected with the existing Windows DPAPI storage helper under +`%APPDATA%\CodexBar\claude-accounts\accounts.json`. Sign-in uses a temporary +`CLAUDE_CONFIG_DIR`; successful, failed, cancelled, and timed-out attempts clean up +that directory. Switching updates `claudeAiOauth` in the CLI credentials file and +`oauthAccount` in the CLI configuration, preserving other settings and MCP secrets. +An absolute `CLAUDE_CONFIG_DIR` inherited by Win-CodexBar selects a custom CLI home. +This account feature follows the [documented Windows Claude Code credential +file](https://code.claude.com/docs/en/authentication), `.claude\.credentials.json`. +It does not manage macOS Keychain logins or custom keyring integrations. +On Windows, the isolated sign-in process belongs to a job that terminates it if +Win-CodexBar exits. Startup also removes abandoned UUID sign-in directories; +cleanup skips links and reparse points. + +These controls switch **Claude Code CLI**, not Claude Desktop or browser sessions. +Usage monitoring still follows the provider's source settings and the +**Allow reading Claude Code's credentials** toggle. API-key or OAuth-token +environment overrides must be removed before using saved subscription logins. +When credential reading is disabled, active-account status is unknown and every +saved account remains switchable. The list does not open ambient credential or +identity files. Explicitly selecting the already-current account is a no-op. + ## Source mode CLI `--source` values on this port (see `codexbar usage --help`): `auto`, `web`, `cli`, `oauth`. diff --git a/rust/Cargo.toml b/rust/Cargo.toml index f4e22431f1..62c7449b3b 100755 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -90,6 +90,8 @@ windows = { version = "0.58", features = [ "Win32_Security_Cryptography", "Win32_Media_Audio", "Win32_System_LibraryLoader", + "Win32_System_JobObjects", + "Win32_System_Threading", "Win32_UI_WindowsAndMessaging", "Win32_Storage_FileSystem", ] } diff --git a/rust/src/locale.rs b/rust/src/locale.rs index ccd1057761..2dc09ecb7b 100644 --- a/rust/src/locale.rs +++ b/rust/src/locale.rs @@ -549,6 +549,14 @@ locale_keys! { ProviderCodexSparkUsage, ProviderCodexSparkUsageHelp, CodexAccountsTitle, + ClaudeAccountsTitle, + ClaudeAccountsHint, + ClaudeAccountsEmpty, + ClaudeAccountsSaveCurrent, + ClaudeAccountsCancelLogin, + ClaudeAccountsSigningIn, + ClaudeAccountsSwitched, + ClaudeAccountsAdded, CodexAccountsHint, CodexAccountsAddButton, CodexAccountsSwitchButton, diff --git a/rust/src/locale/en-US.ftl b/rust/src/locale/en-US.ftl index 77583d05b7..317e2a583e 100644 --- a/rust/src/locale/en-US.ftl +++ b/rust/src/locale/en-US.ftl @@ -827,3 +827,11 @@ OpenRouterManagementKeyTitle = OpenRouter spend history OpenRouterManagementKeyLabel = Management API key OpenRouterManagementKeyHelp = Optional. Used only for exact 30-day Activity spend from openrouter.ai. The primary OpenRouter API key remains separate. You can also set OPENROUTER_MANAGEMENT_API_KEY. OpenRouterManagementKeyConfigured = Management key configured +ClaudeAccountsTitle = Claude Code accounts +ClaudeAccountsHint = Close Claude Code before switching, then reopen it. Claude Desktop uses a separate login. +ClaudeAccountsEmpty = No Claude Code accounts found. +ClaudeAccountsSaveCurrent = Save current account +ClaudeAccountsCancelLogin = Cancel sign-in +ClaudeAccountsSigningIn = Complete Claude sign-in in your browser. Your current CLI account stays active until you switch. +ClaudeAccountsSwitched = Account switched. Reopen Claude Code CLI to use it. +ClaudeAccountsAdded = Account saved. Select Switch to use it in Claude Code. diff --git a/rust/src/providers/claude/accounts.rs b/rust/src/providers/claude/accounts.rs new file mode 100644 index 0000000000..c937f81840 --- /dev/null +++ b/rust/src/providers/claude/accounts.rs @@ -0,0 +1,584 @@ +//! Saved Claude Code logins. Only OAuth credentials and account identity move; +//! projects, MCP secrets, preferences, and Claude Desktop data stay in place. + +mod login; + +use std::io; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use uuid::Uuid; + +use crate::secure_file; + +pub use login::{begin_login, cancel_login, cleanup_abandoned_logins, login, require_cli_closed}; + +/// Serializes account changes with our own OAuth and CLI token refreshes. +pub static CREDENTIAL_OPERATION: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ClaudeAccount { + pub id: String, + pub email: String, + pub organization: Option, + pub plan: Option, + pub is_active: bool, + pub is_saved: bool, +} + +// Never serialize these objects across the invoke bridge or log them. +#[derive(Clone, Serialize, Deserialize)] +pub struct SavedLogin { + oauth: Value, + identity: Value, +} + +impl SavedLogin { + fn id(&self) -> io::Result { + identity_id(&self.identity) + } + + fn validate(&self) -> io::Result<()> { + self.id()?; + required_string(&self.identity, "emailAddress")?; + required_string(&self.oauth, "accessToken")?; + required_string(&self.oauth, "refreshToken")?; + Ok(()) + } + + fn summary(&self, active: bool, saved: bool) -> io::Result { + Ok(ClaudeAccount { + id: self.id()?, + email: required_string(&self.identity, "emailAddress")?.to_owned(), + organization: self.identity["organizationName"] + .as_str() + .map(str::to_owned), + plan: self.oauth["subscriptionType"].as_str().map(str::to_owned), + is_active: active, + is_saved: saved, + }) + } +} + +#[derive(Default, Serialize, Deserialize)] +struct Store { + accounts: Vec, +} + +pub struct AccountManager { + root: PathBuf, + config_dir: PathBuf, + config_file: PathBuf, +} + +pub fn config_dir() -> io::Result { + if let Some(dir) = std::env::var_os("CLAUDE_CONFIG_DIR").filter(|v| !v.is_empty()) { + let path = PathBuf::from(dir); + if !path.is_absolute() { + return Err(io::Error::other( + "CLAUDE_CONFIG_DIR must be an absolute path.", + )); + } + return Ok(path); + } + dirs::home_dir() + .map(|p| p.join(".claude")) + .ok_or_else(|| io::Error::other("Home directory not found.")) +} + +impl AccountManager { + pub fn new() -> io::Result { + let config_dir = config_dir()?; + let config_file = if std::env::var_os("CLAUDE_CONFIG_DIR").is_some_and(|v| !v.is_empty()) { + config_dir.join(".claude.json") + } else { + dirs::home_dir() + .ok_or_else(|| io::Error::other("Home directory not found."))? + .join(".claude.json") + }; + let root = dirs::config_dir() + .ok_or_else(|| io::Error::other("Configuration directory not found."))? + .join("CodexBar/claude-accounts"); + Ok(Self { + root, + config_dir, + config_file, + }) + } + + fn load(&self) -> io::Result { + let path = self.root.join("accounts.json"); + match secure_file::read_string(&path) { + Ok(data) => serde_json::from_str(&data).map_err(|_| { + io::Error::other( + "Saved Claude accounts are unreadable. The existing file has been preserved.", + ) + }), + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(Store::default()), + Err(e) => Err(e), + } + } + + fn save(&self, store: &Store) -> io::Result<()> { + std::fs::create_dir_all(&self.root)?; + let path = self.root.join("accounts.json"); + let temp = self.root.join(format!("{}.tmp", Uuid::new_v4())); + let result = (|| { + secure_file::write_string( + &temp, + &serde_json::to_string(store).map_err(io::Error::other)?, + )?; + std::fs::rename(&temp, &path) + })(); + if result.is_err() { + let _cleanup = std::fs::remove_file(temp); + } + result + } + + pub fn list(&self) -> io::Result> { + self.list_with_consent(super::claude_code_consent()) + } + + fn list_with_consent(&self, consent: bool) -> io::Result> { + let store = self.load()?; + let current = if consent { + read_login(&self.config_dir, &self.config_file)? + } else { + None + }; + // Without credential-read consent, activity is unknown. Stale identity + // metadata or unrelated credential entries cannot prove a live login. + // Leave saved accounts switchable; selecting the current one is a no-op. + let active = current.as_ref().map(SavedLogin::id).transpose()?; + let mut accounts = store + .accounts + .iter() + .map(|a| a.summary(active.as_ref() == a.id().ok().as_ref(), true)) + .collect::>>()?; + if let Some(current) = current + && !accounts.iter().any(|a| Some(&a.id) == active.as_ref()) + { + accounts.insert(0, current.summary(true, false)?); + } + Ok(accounts) + } + + pub fn save_current(&self) -> io::Result<()> { + 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) + } + + pub fn import(&self, login: SavedLogin) -> io::Result<()> { + login.validate()?; + let mut store = self.load()?; + upsert(&mut store, login)?; + self.save(&store) + } + + pub fn remove(&self, id: &str) -> io::Result<()> { + let mut store = self.load()?; + store + .accounts + .retain(|a| a.id().ok().as_deref() != Some(id)); + self.save(&store) + } + + pub fn switch(&self, id: &str) -> io::Result<()> { + let mut store = self.load()?; + let target = store + .accounts + .iter() + .find(|a| a.id().ok().as_deref() == Some(id)) + .cloned() + .ok_or_else(|| io::Error::other("Saved Claude account not found."))?; + target.validate()?; + if let Some(current) = read_login(&self.config_dir, &self.config_file)? { + if current.id()? == id { + return Ok(()); + } + // Preserve the latest refresh token before replacing the active login. + upsert(&mut store, current)?; + self.save(&store)?; + } + let credential_path = self.config_dir.join(".credentials.json"); + let old_credentials = read_object(&credential_path)?; + let mut credentials = old_credentials.clone(); + let mut config = read_object(&self.config_file)?; + credentials["claudeAiOauth"] = target.oauth; + config["oauthAccount"] = target.identity; + // Stage both files before touching either destination. Config is only + // metadata; on failure restore credentials while retaining both logins. + std::fs::create_dir_all(&self.config_dir)?; + let staged_credentials = stage_json(&credential_path, &credentials)?; + let staged_config = match stage_json(&self.config_file, &config) { + Ok(path) => path, + Err(e) => { + let _cleanup = std::fs::remove_file(staged_credentials); + return Err(e); + } + }; + if let Err(e) = std::fs::rename(&staged_credentials, &credential_path) { + let _cleanup = std::fs::remove_file(staged_credentials); + let _cleanup = std::fs::remove_file(staged_config); + return Err(e); + } + if let Err(e) = std::fs::rename(&staged_config, &self.config_file) { + let _cleanup = std::fs::remove_file(staged_config); + let restored = stage_json(&credential_path, &old_credentials) + .and_then(|p| std::fs::rename(p, &credential_path)); + return Err(io::Error::other(if restored.is_ok() { + format!("Could not update Claude identity; the previous login was restored: {e}") + } else { + "Claude identity update failed. Both accounts remain saved; close Claude Code and retry switching.".into() + })); + } + super::clear_account_caches(&credential_path); + Ok(()) + } +} + +fn upsert(store: &mut Store, login: SavedLogin) -> io::Result<()> { + let id = login.id()?; + if let Some(old) = store + .accounts + .iter_mut() + .find(|a| a.id().ok().as_deref() == Some(id.as_str())) + { + *old = login; + } else { + store.accounts.push(login); + } + Ok(()) +} + +fn required_string<'a>(object: &'a Value, key: &str) -> io::Result<&'a str> { + object + .get(key) + .and_then(Value::as_str) + .filter(|s| !s.trim().is_empty()) + .ok_or_else(|| io::Error::other(format!("Claude login is missing {key}. Sign in again."))) +} + +fn identity_id(identity: &Value) -> io::Result { + let account = required_string(identity, "accountUuid")?; + let org = required_string(identity, "organizationUuid")?; + Ok(format!("{account}:{org}")) +} + +fn read_object(path: &Path) -> io::Result { + match std::fs::read(path) { + Ok(data) => { + let value: Value = serde_json::from_slice(&data).map_err(|_| { + io::Error::other("Claude configuration is invalid JSON; it has not been changed.") + })?; + if !value.is_object() { + return Err(io::Error::other("Claude configuration must be an object.")); + } + Ok(value) + } + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(json!({})), + Err(e) => Err(e), + } +} + +fn read_login(dir: &Path, config: &Path) -> io::Result> { + let credentials = read_object(&dir.join(".credentials.json"))?; + let Some(oauth) = credentials.get("claudeAiOauth").filter(|v| !v.is_null()) else { + return Ok(None); + }; + let config = read_object(config)?; + let login = SavedLogin { + oauth: oauth.clone(), + identity: config["oauthAccount"].clone(), + }; + login.validate()?; + Ok(Some(login)) +} + +fn stage_json(path: &Path, value: &Value) -> io::Result { + use std::io::Write; + let temp = path.with_extension(format!("{}.tmp", Uuid::new_v4())); + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let result = (|| { + let mut file = options.open(&temp)?; + file.write_all(&serde_json::to_vec_pretty(value).map_err(io::Error::other)?)?; + file.sync_all() + })(); + if let Err(e) = result { + let _cleanup = std::fs::remove_file(&temp); + return Err(e); + } + Ok(temp) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn login(account: &str, org: &str, token: &str) -> SavedLogin { + SavedLogin { + oauth: json!({"accessToken":token,"refreshToken":format!("refresh-{token}"),"expiresAt":9999999999999_i64,"subscriptionType":"max"}), + identity: json!({"accountUuid":account,"organizationUuid":org,"emailAddress":"same@example.com","organizationName":org}), + } + } + + fn manager(dir: &Path) -> AccountManager { + let config_dir = dir.join("cli"); + std::fs::create_dir_all(&config_dir).unwrap(); + AccountManager { + root: dir.join("store"), + config_file: config_dir.join(".claude.json"), + config_dir, + } + } + + fn activate(manager: &AccountManager, login: &SavedLogin) { + std::fs::write(manager.config_dir.join(".credentials.json"), json!({"claudeAiOauth":login.oauth,"mcpOAuth":{"secret":"preserved"},"pluginSecrets":{"x":"secret"}}).to_string()).unwrap(); + std::fs::write(&manager.config_file, json!({"oauthAccount":login.identity,"projects":{"test":"preferences"},"hasCompletedOnboarding":true}).to_string()).unwrap(); + } + + #[test] + fn discovery_without_consent_never_opens_ambient_credentials() { + let dir = tempfile::tempdir().unwrap(); + let manager = manager(dir.path()); + manager.import(login("saved", "one", "stored")).unwrap(); + // Invalid identity metadata leaves activity unknown; never parse credentials. + std::fs::write(manager.config_dir.join(".credentials.json"), "invalid JSON").unwrap(); + std::fs::write(&manager.config_file, "invalid JSON").unwrap(); + let list = manager.list_with_consent(false).unwrap(); + assert_eq!(list.len(), 1); + assert!(list[0].is_saved && !list[0].is_active); + assert!(manager.list_with_consent(true).is_err()); + } + + #[test] + fn unknown_activity_keeps_saved_accounts_switchable_after_logout() { + let dir = tempfile::tempdir().unwrap(); + let account_manager = manager(dir.path()); + let saved = login("b", "two", "saved-token"); + account_manager.import(saved.clone()).unwrap(); + activate(&account_manager, &saved); + assert!(account_manager.list_with_consent(true).unwrap()[0].is_active); + assert!(!account_manager.list_with_consent(false).unwrap()[0].is_active); + + // Logout can preserve unrelated secrets and stale account metadata. + let credential_path = account_manager.config_dir.join(".credentials.json"); + let unrelated = json!({"mcpOAuth":{"secret":"preserved"},"pluginSecrets":{"x":"secret"}}); + std::fs::write(&credential_path, unrelated.to_string()).unwrap(); + for consent in [false, true] { + let accounts = account_manager.list_with_consent(consent).unwrap(); + assert_eq!(accounts.len(), 1); + assert!(accounts[0].is_saved && !accounts[0].is_active); + } + account_manager.switch("b:two").unwrap(); + let restored = read_object(&credential_path).unwrap(); + assert_eq!(restored["claudeAiOauth"]["accessToken"], "saved-token"); + assert_eq!(restored["mcpOAuth"], unrelated["mcpOAuth"]); + assert_eq!(restored["pluginSecrets"], unrelated["pluginSecrets"]); + assert!(account_manager.list_with_consent(true).unwrap()[0].is_active); + assert!(!account_manager.list_with_consent(false).unwrap()[0].is_active); + } + #[cfg(windows)] + #[test] + fn switching_does_not_require_a_metadata_write_after_credentials_change() { + let dir = tempfile::tempdir().unwrap(); + let manager = manager(dir.path()); + manager.import(login("b", "two", "new")).unwrap(); + let store_path = manager.root.join("accounts.json"); + let original_permissions = std::fs::metadata(&store_path).unwrap().permissions(); + let mut readonly = original_permissions.clone(); + readonly.set_readonly(true); + std::fs::set_permissions(&store_path, readonly).unwrap(); + let result = manager.switch("b:two"); + std::fs::set_permissions(&store_path, original_permissions).unwrap(); + result.unwrap(); + assert_eq!( + read_login(&manager.config_dir, &manager.config_file) + .unwrap() + .unwrap() + .id() + .unwrap(), + "b:two" + ); + } + + #[test] + fn switching_preserves_settings_and_latest_outgoing_refresh_token() { + let dir = tempfile::tempdir().unwrap(); + let manager = manager(dir.path()); + let a = login("a", "org-a", "old"); + let b = login("b", "org-b", "second"); + manager.import(a).unwrap(); + manager.import(b.clone()).unwrap(); + activate(&manager, &login("a", "org-a", "rotated")); + manager.switch(&b.id().unwrap()).unwrap(); + let credentials = read_object(&manager.config_dir.join(".credentials.json")).unwrap(); + assert_eq!(credentials["claudeAiOauth"]["accessToken"], "second"); + assert_eq!(credentials["mcpOAuth"]["secret"], "preserved"); + assert_eq!(credentials["pluginSecrets"]["x"], "secret"); + let config = read_object(&manager.config_file).unwrap(); + assert_eq!(config["oauthAccount"]["accountUuid"], "b"); + assert_eq!(config["projects"]["test"], "preferences"); + assert_eq!(config["hasCompletedOnboarding"], true); + manager.switch("a:org-a").unwrap(); + assert_eq!( + read_login(&manager.config_dir, &manager.config_file) + .unwrap() + .unwrap() + .oauth["accessToken"], + "rotated" + ); + } + + #[test] + fn first_switch_automatically_saves_ambient_account() { + let dir = tempfile::tempdir().unwrap(); + let manager = manager(dir.path()); + activate(&manager, &login("a", "one", "ambient")); + manager.import(login("b", "two", "new")).unwrap(); + assert!( + manager + .list_with_consent(true) + .unwrap() + .iter() + .any(|a| a.is_active && !a.is_saved) + ); + manager.switch("b:two").unwrap(); + manager.switch("a:one").unwrap(); + assert!( + manager + .list_with_consent(true) + .unwrap() + .iter() + .any(|a| a.id == "a:one" && a.is_active && a.is_saved) + ); + } + + #[test] + fn same_email_and_different_organizations_remain_distinct() { + let dir = tempfile::tempdir().unwrap(); + let manager = manager(dir.path()); + manager.import(login("a", "one", "first")).unwrap(); + manager.import(login("a", "two", "second")).unwrap(); + manager.import(login("a", "one", "updated")).unwrap(); + assert_eq!(manager.list_with_consent(true).unwrap().len(), 2); + assert_eq!( + manager.load().unwrap().accounts[0].oauth["accessToken"], + "updated" + ); + } + + #[test] + fn metadata_bridge_never_contains_credentials() { + let dir = tempfile::tempdir().unwrap(); + let manager = manager(dir.path()); + manager.import(login("a", "one", "secret-token")).unwrap(); + let json = serde_json::to_string(&manager.list_with_consent(true).unwrap()).unwrap(); + assert!(!json.contains("secret-token")); + assert!(!json.contains("refreshToken")); + assert!(json.contains("isSaved")); + #[cfg(windows)] + assert!( + !std::fs::read_to_string(manager.root.join("accounts.json")) + .unwrap() + .contains("secret-token") + ); + } + + #[test] + fn corrupt_config_and_unknown_target_do_not_replace_credentials() { + let dir = tempfile::tempdir().unwrap(); + let manager = manager(dir.path()); + activate(&manager, &login("a", "one", "original")); + manager.import(login("b", "two", "other")).unwrap(); + let path = manager.config_dir.join(".credentials.json"); + let original = std::fs::read(&path).unwrap(); + assert!(manager.switch("unknown").is_err()); + std::fs::write(&manager.config_file, "invalid JSON").unwrap(); + assert!(manager.switch("b:two").is_err()); + assert_eq!(std::fs::read(path).unwrap(), original); + } + + #[test] + fn corrupt_store_is_not_replaced_and_removal_does_not_log_out() { + let dir = tempfile::tempdir().unwrap(); + let manager = manager(dir.path()); + activate(&manager, &login("a", "one", "original")); + manager.save_current().unwrap(); + manager.remove("a:one").unwrap(); + let list = manager.list_with_consent(true).unwrap(); + assert_eq!(list.len(), 1); + assert!(list[0].is_active && !list[0].is_saved); + let path = manager.root.join("accounts.json"); + std::fs::write(&path, "broken").unwrap(); + assert!(manager.save_current().is_err()); + assert_eq!(std::fs::read_to_string(path).unwrap(), "broken"); + } + + /// Opt-in compatibility smoke test. Uses existing saved logins only inside + /// a disposable CLI home and makes no model requests. Never switches the + /// user's real CLI home or prints credentials/identity values. + #[test] + #[ignore = "requires native Claude Code and at least two saved subscription accounts"] + fn installed_cli_recognizes_saved_accounts_in_isolated_home() { + use std::process::{Command, Stdio}; + let source = AccountManager::new().unwrap().load().unwrap(); + assert!( + source.accounts.len() >= 2, + "Save two accounts before running this smoke test." + ); + let dir = tempfile::tempdir().unwrap(); + let manager = manager(dir.path()); + for account in &source.accounts { + manager.import(account.clone()).unwrap(); + } + for account in source.accounts.iter().take(2) { + manager.switch(&account.id().unwrap()).unwrap(); + let mut command = Command::new(super::login::executable().unwrap()); + command + .args(["auth", "status", "--json"]) + .env("CLAUDE_CONFIG_DIR", &manager.config_dir) + .current_dir(&manager.config_dir) + .stdin(Stdio::null()); + for key in [ + "ANTHROPIC_API_KEY", + "ANTHROPIC_AUTH_TOKEN", + "CLAUDE_CODE_OAUTH_TOKEN", + "CLAUDECODE", + ] { + command.env_remove(key); + } + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + command.creation_flags(0x0800_0000); + } + let output = command.output().unwrap(); + assert!( + output.status.success(), + "Claude auth status failed in isolated home." + ); + let status: Value = + serde_json::from_slice(&output.stdout).expect("Claude status must return JSON."); + assert!( + status["loggedIn"] == true, + "Claude must recognize the selected subscription login." + ); + assert!( + status["email"] == account.identity["emailAddress"], + "Claude must report the selected account's identity." + ); + } + } +} diff --git a/rust/src/providers/claude/accounts/login.rs b/rust/src/providers/claude/accounts/login.rs new file mode 100644 index 0000000000..9590bd2d1d --- /dev/null +++ b/rust/src/providers/claude/accounts/login.rs @@ -0,0 +1,533 @@ +use std::io; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::{Duration, Instant}; + +use super::{SavedLogin, read_login}; + +#[cfg(windows)] +mod windows_child; +#[cfg(windows)] +use windows_child::LoginChild; +#[cfg(not(windows))] +type LoginChild = std::process::Child; + +static CANCEL: AtomicBool = AtomicBool::new(false); + +const AUTH_OVERRIDES: [&str; 7] = [ + "ANTHROPIC_API_KEY", + "ANTHROPIC_AUTH_TOKEN", + "CLAUDE_CODE_OAUTH_TOKEN", + "CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR", + "CLAUDE_CODE_USE_BEDROCK", + "CLAUDE_CODE_USE_VERTEX", + "CLAUDE_CODE_USE_FOUNDRY", +]; + +// Electron Desktop also uses claude.exe. Its Windows version resource says +// "Claude"; the native CLI's says "Claude Code". Keep Desktop running. +#[cfg(windows)] +const CLI_PROCESS_COUNT_SCRIPT: &str = r"@(Get-Process | Where-Object { $_.Path -eq $env:CODEXBAR_CLAUDE_EXE -or $_.Path -like '*\.local\share\claude\versions\*' -or ($_.ProcessName -eq 'claude' -and $_.MainModule.FileVersionInfo.ProductName -ne 'Claude') }).Count"; + +pub fn cancel_login() { + CANCEL.store(true, Ordering::SeqCst); +} + +pub fn begin_login() { + CANCEL.store(false, Ordering::SeqCst); +} + +pub fn executable() -> io::Result { + if let Some(home) = dirs::home_dir() { + let native = home.join(if cfg!(windows) { + ".local/bin/claude.exe" + } else { + ".local/bin/claude" + }); + if native.is_file() { + return Ok(native); + } + } + let path = which::which("claude").map_err(|_| { + io::Error::other("Claude Code was not found. Install the Claude Code CLI, then try again.") + })?; + if cfg!(windows) + && path + .extension() + .is_none_or(|e| !e.eq_ignore_ascii_case("exe")) + { + return Err(io::Error::other( + "Account sign-in requires the native Claude Code executable. Install Claude Code using its native Windows installer.", + )); + } + Ok(path) +} + +fn command(executable: &Path, dir: &Path) -> Command { + let mut command = Command::new(executable); + command + .env("CLAUDE_CONFIG_DIR", dir) + .current_dir(dir) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + // The login must use browser-based subscription auth, not inherited API auth. + for key in AUTH_OVERRIDES { + command.env_remove(key); + } + command.env_remove("CLAUDECODE"); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + command.creation_flags(0x0800_0000); + } + command +} + +struct LoginDirectory { + root: PathBuf, + path: PathBuf, +} + +impl Drop for LoginDirectory { + fn drop(&mut self) { + // Delete only the UUID directory we created, never a caller-supplied path. + if let (Ok(root), Ok(path)) = (self.root.canonicalize(), self.path.canonicalize()) + && path.parent() == Some(root.as_path()) + { + let _cleanup = std::fs::remove_dir_all(path); + } + } +} + +fn login_root() -> io::Result { + dirs::config_dir() + .map(|dir| dir.join("CodexBar/claude-accounts/logins")) + .ok_or_else(|| io::Error::other("Configuration directory not found.")) +} + +/// Called once by the primary desktop instance, before accepting sign-in work. +pub fn cleanup_abandoned_logins() -> io::Result<()> { + cleanup_login_root(&login_root()?) +} + +fn cleanup_login_root(root: &Path) -> io::Result<()> { + match std::fs::symlink_metadata(root) { + Ok(metadata) if is_link(&metadata) => { + return Err(io::Error::other( + "Refusing to clean a linked Claude sign-in root.", + )); + } + Ok(_) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(error), + } + let entries = match std::fs::read_dir(root) { + Ok(entries) => entries, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(error), + }; + let root = root.canonicalize()?; + let mut first_error = None; + for entry in entries { + if let Err(error) = entry.and_then(|entry| cleanup_login_entry(&root, entry)) { + first_error.get_or_insert(error); + } + } + first_error.map_or(Ok(()), Err) +} + +fn cleanup_login_entry(root: &Path, entry: std::fs::DirEntry) -> io::Result<()> { + if uuid::Uuid::parse_str(&entry.file_name().to_string_lossy()).is_err() { + return Ok(()); + } + let metadata = entry.path().symlink_metadata()?; + if !metadata.is_dir() || is_link(&metadata) { + return Ok(()); + } + let path = entry.path().canonicalize()?; + if path.parent() == Some(root) { + std::fs::remove_dir_all(path)?; + } + Ok(()) +} + +fn is_link(metadata: &std::fs::Metadata) -> bool { + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + if metadata.file_attributes() & 0x400 != 0 { + return true; + } // FILE_ATTRIBUTE_REPARSE_POINT + } + metadata.file_type().is_symlink() +} + +fn spawn_login_process(command: &mut Command) -> io::Result { + #[cfg(windows)] + { + LoginChild::spawn(command) + } + #[cfg(not(windows))] + { + command.spawn() + } +} + +pub fn login() -> io::Result { + let exe = executable()?; + let root = login_root()?; + std::fs::create_dir_all(&root)?; + let dir = LoginDirectory { + path: root.join(uuid::Uuid::new_v4().to_string()), + root, + }; + std::fs::create_dir(&dir.path)?; + let mut child = + spawn_login_process(command(&exe, &dir.path).args(["auth", "login", "--claudeai"]))?; + wait_for_login(&mut child, &dir.path, &CANCEL, Duration::from_secs(300)) +} + +fn wait_for_login( + child: &mut LoginChild, + dir: &Path, + cancel: &AtomicBool, + timeout: Duration, +) -> io::Result { + let start = Instant::now(); + loop { + let status = match child.try_wait() { + Ok(status) => status, + Err(e) => { + let _kill = child.kill(); + let _reap = child.wait(); + return Err(e); + } + }; + if let Some(status) = status { + if !status.success() { + return Err(io::Error::other( + "Claude sign-in did not complete. Try again and finish sign-in in your browser.", + )); + } + return read_login(dir, &dir.join(".claude.json"))?.ok_or_else(|| { + io::Error::other( + "Claude Code did not save a subscription login. Please sign in again.", + ) + }); + } + let cancelled = cancel.load(Ordering::SeqCst); + if cancelled || start.elapsed() > timeout { + let _kill = child.kill(); + let _reap = child.wait(); + return Err(io::Error::other(if cancelled { + "Claude sign-in cancelled." + } else { + "Claude sign-in timed out. Please try again." + })); + } + std::thread::sleep(Duration::from_millis(150)); + } +} + +fn require_subscription_environment( + get: impl Fn(&str) -> Option, +) -> io::Result<()> { + for key in AUTH_OVERRIDES { + if get(key).is_some_and(|v| !v.is_empty()) { + return Err(io::Error::other(format!( + "{key} overrides Claude subscription login. Unset it and restart Win-CodexBar before switching accounts." + ))); + } + } + Ok(()) +} + +/// Existing CLI processes retain credentials in memory and may rotate them. +/// Require them to exit; account management never terminates user tasks. +pub fn require_cli_closed() -> io::Result<()> { + require_subscription_environment(|key| std::env::var_os(key))?; + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + let exe = executable()?.canonicalize()?; + let output = Command::new("powershell.exe") + .env( + "CODEXBAR_CLAUDE_EXE", + exe.to_string_lossy().trim_start_matches(r"\\?\"), + ) + .args([ + "-NoProfile", + "-NonInteractive", + "-Command", + CLI_PROCESS_COUNT_SCRIPT, + ]) + .creation_flags(0x0800_0000) + .output()?; + if !output.status.success() { + return Err(io::Error::other( + "Could not check whether Claude Code is running.", + )); + } + let count: usize = String::from_utf8_lossy(&output.stdout) + .trim() + .parse() + .map_err(|_| io::Error::other("Could not check whether Claude Code is running."))?; + if count > 0 { + return Err(io::Error::other( + "Close your running Claude Code CLI sessions, then switch accounts and reopen Claude Code.", + )); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn startup_cleanup_removes_only_uuid_login_directories() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("logins"); + let abandoned = root.join(uuid::Uuid::new_v4().to_string()); + let unrelated = root.join("keep-me"); + std::fs::create_dir_all(&abandoned).unwrap(); + std::fs::create_dir_all(&unrelated).unwrap(); + std::fs::write(abandoned.join(".credentials.json"), "abandoned-fixture").unwrap(); + std::fs::write(unrelated.join("keep.txt"), "preserved").unwrap(); + cleanup_login_root(&root).unwrap(); + assert!(!abandoned.exists()); + assert!(unrelated.join("keep.txt").exists()); + assert!(root.exists()); + } + + #[cfg(windows)] + #[test] + fn startup_cleanup_continues_after_a_locked_down_directory() { + use std::os::windows::fs::OpenOptionsExt; + let dir = tempfile::tempdir().unwrap(); + let blocked = dir.path().join("00000000-0000-4000-8000-000000000000"); + let removable = dir.path().join("ffffffff-ffff-4fff-8fff-ffffffffffff"); + std::fs::create_dir(&blocked).unwrap(); + std::fs::create_dir(&removable).unwrap(); + let path = blocked.join(".credentials.json"); + std::fs::write(&path, "fixture").unwrap(); + let locked = std::fs::OpenOptions::new() + .read(true) + .share_mode(0) + .open(&path) + .unwrap(); + let result = cleanup_login_root(dir.path()); + drop(locked); + assert!(result.is_err()); + assert!(!removable.exists()); + } + + #[cfg(windows)] + #[test] + fn startup_cleanup_does_not_follow_junctions() { + use std::os::windows::process::CommandExt; + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("logins"); + let outside = dir.path().join("outside"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::create_dir_all(&outside).unwrap(); + std::fs::write(outside.join("keep.txt"), "preserved").unwrap(); + let link = root.join(uuid::Uuid::new_v4().to_string()); + let output = Command::new("powershell.exe") + .args(["-NoProfile", "-NonInteractive", "-Command", "New-Item -ItemType Junction -Path $env:CODEXBAR_TEST_LINK -Target $env:CODEXBAR_TEST_TARGET | Out-Null"]) + .env("CODEXBAR_TEST_LINK", &link).env("CODEXBAR_TEST_TARGET", &outside) + .creation_flags(0x0800_0000).output().unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + cleanup_login_root(&root).unwrap(); + assert!(outside.join("keep.txt").exists()); + assert!(link.exists()); + assert!(cleanup_login_root(&link).is_err()); + } + + #[cfg(windows)] + #[test] + fn abrupt_parent_exit_terminates_the_login_child() { + use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}; + use std::os::windows::process::CommandExt; + use windows::Win32::Foundation::{HANDLE, WAIT_OBJECT_0}; + use windows::Win32::System::Threading::{ + OpenProcess, PROCESS_SYNCHRONIZE, WaitForSingleObject, + }; + const ROOT: &str = "CODEXBAR_CLAUDE_LOGIN_JOB_TEST_ROOT"; + if let Some(root) = std::env::var_os(ROOT) { + let root = PathBuf::from(root); + let login = child(&root, true, 0); + std::fs::write(root.join("child.pid"), login.id.to_string()).unwrap(); + let deadline = Instant::now() + Duration::from_secs(10); + while !root.join("exit-now").exists() { + assert!(Instant::now() < deadline); + std::thread::sleep(Duration::from_millis(20)); + } + // Deliberately bypass Drop: the OS must close the private job handle. + std::process::exit(0); + } + let dir = tempfile::tempdir().unwrap(); + let parent = Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "providers::claude::accounts::login::tests::abrupt_parent_exit_terminates_the_login_child", "--nocapture"]) + .env(ROOT, dir.path()).creation_flags(0x0800_0000) + .stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap(); + let pid_file = dir.path().join("child.pid"); + let deadline = Instant::now() + Duration::from_secs(10); + while !pid_file.exists() { + assert!(Instant::now() < deadline); + std::thread::sleep(Duration::from_millis(20)); + } + let pid = std::fs::read_to_string(pid_file).unwrap().parse().unwrap(); + // SAFETY: the helper reported its live child; this owned wait-only handle prevents PID reuse ambiguity. + let process = unsafe { + OwnedHandle::from_raw_handle(OpenProcess(PROCESS_SYNCHRONIZE, false, pid).unwrap().0) + }; + std::fs::write(dir.path().join("exit-now"), "").unwrap(); + let output = parent.wait_with_output().unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + // SAFETY: valid wait-only process handle owned above. + unsafe { WaitForSingleObject(HANDLE(process.as_raw_handle()), 5_000) }, + WAIT_OBJECT_0 + ); + } + + #[test] + fn descriptor_override_blocks_switching_and_is_removed_from_login_children() { + let descriptor = "CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR"; + let error = require_subscription_environment(|key| { + (key == descriptor).then(|| std::ffi::OsString::from("3")) + }) + .unwrap_err(); + assert!(error.to_string().contains(descriptor)); + assert!(require_subscription_environment(|_| None).is_ok()); + assert!(require_subscription_environment(|_| Some(std::ffi::OsString::new())).is_ok()); + let command = command(Path::new("claude.exe"), Path::new("isolated")); + assert!( + command + .get_envs() + .any(|(key, value)| key == descriptor && value.is_none()) + ); + } + + #[cfg(windows)] + #[test] + fn process_guard_distinguishes_native_cli_from_store_desktop() { + use std::os::windows::process::CommandExt; + for (product, expected) in [("Claude", "0"), ("Claude Code", "1")] { + let script = format!( + "function Get-Process {{ [pscustomobject]@{{ ProcessName='claude'; Path='C:\\fixture\\claude.exe'; MainModule=[pscustomobject]@{{FileVersionInfo=[pscustomobject]@{{ProductName='{product}'}}}} }} }}; {CLI_PROCESS_COUNT_SCRIPT}" + ); + let output = Command::new("powershell.exe") + .args(["-NoProfile", "-NonInteractive", "-Command", &script]) + .env("CODEXBAR_CLAUDE_EXE", "C:\\different\\claude.exe") + .creation_flags(0x0800_0000) + .output() + .unwrap(); + assert!(output.status.success()); + assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), expected); + } + } + + fn child(dir: &Path, sleep: bool, exit: u32) -> LoginChild { + #[cfg(windows)] + let mut process = command(&which::which("powershell.exe").unwrap(), dir); + #[cfg(windows)] + process.args([ + "-NoProfile", + "-NonInteractive", + "-Command", + &format!( + "{}exit {exit}", + if sleep { + "Start-Sleep -Seconds 30; " + } else { + "" + } + ), + ]); + #[cfg(not(windows))] + let mut process = command(Path::new("sh"), dir); + #[cfg(not(windows))] + process.args([ + "-c", + &format!("{}exit {exit}", if sleep { "exec sleep 30; " } else { "" }), + ]); + spawn_login_process(&mut process).unwrap() + } + + #[test] + fn cancelled_and_timed_out_logins_reap_child() { + let dir = tempfile::tempdir().unwrap(); + for cancelled in [true, false] { + let mut child = child(dir.path(), true, 0); + let result = wait_for_login( + &mut child, + dir.path(), + &AtomicBool::new(cancelled), + Duration::from_millis(10), + ); + assert!(result.is_err()); + assert!(child.try_wait().unwrap().is_some()); + } + } + + #[test] + fn successful_login_reads_only_the_isolated_directory_and_failed_exit_is_rejected() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join(".credentials.json"), + r#"{"claudeAiOauth":{"accessToken":"isolated","refreshToken":"refresh"}}"#, + ) + .unwrap(); + std::fs::write(dir.path().join(".claude.json"), r#"{"oauthAccount":{"accountUuid":"test","organizationUuid":"org","emailAddress":"test@example.com"}}"#).unwrap(); + let login = wait_for_login( + &mut child(dir.path(), false, 0), + dir.path(), + &AtomicBool::new(false), + Duration::from_secs(10), + ) + .unwrap(); + assert_eq!(login.id().unwrap(), "test:org"); + assert!( + wait_for_login( + &mut child(dir.path(), false, 1), + dir.path(), + &AtomicBool::new(false), + Duration::from_secs(10) + ) + .is_err() + ); + } + + #[test] + fn temporary_login_cleanup_cannot_remove_the_root() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("logins"); + let path = root.join(uuid::Uuid::new_v4().to_string()); + std::fs::create_dir_all(&path).unwrap(); + std::fs::write(path.join("secret.json"), "secret").unwrap(); + drop(LoginDirectory { + root: root.clone(), + path: path.clone(), + }); + assert!(!path.exists()); + assert!(root.exists()); + drop(LoginDirectory { + root: root.clone(), + path: root.clone(), + }); + assert!(root.exists()); + } +} diff --git a/rust/src/providers/claude/accounts/login/windows_child.rs b/rust/src/providers/claude/accounts/login/windows_child.rs new file mode 100644 index 0000000000..898cde65c0 --- /dev/null +++ b/rust/src/providers/claude/accounts/login/windows_child.rs @@ -0,0 +1,307 @@ +//! A login child joins its kill-on-close job atomically at process creation. +//! https://devblogs.microsoft.com/oldnewthing/20230209-00/?p=107812 + +use std::ffi::{OsStr, OsString}; +use std::io; +use std::os::windows::ffi::OsStrExt; +use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}; +use std::os::windows::process::ExitStatusExt; +use std::process::{Command, ExitStatus}; + +use windows::Win32::Foundation::{ + HANDLE, HANDLE_FLAG_INHERIT, SetHandleInformation, WAIT_OBJECT_0, WAIT_TIMEOUT, +}; +use windows::Win32::System::JobObjects::{ + CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, JOBOBJECT_BASIC_LIMIT_INFORMATION, + JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation, + SetInformationJobObject, TerminateJobObject, +}; +use windows::Win32::System::Threading::{ + CREATE_NO_WINDOW, CREATE_UNICODE_ENVIRONMENT, CreateProcessW, DeleteProcThreadAttributeList, + EXTENDED_STARTUPINFO_PRESENT, GetExitCodeProcess, INFINITE, InitializeProcThreadAttributeList, + LPPROC_THREAD_ATTRIBUTE_LIST, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, + PROC_THREAD_ATTRIBUTE_JOB_LIST, PROCESS_INFORMATION, STARTF_USESTDHANDLES, STARTUPINFOEXW, + STARTUPINFOW, UpdateProcThreadAttribute, WaitForSingleObject, +}; +use windows::core::{PCWSTR, PWSTR}; + +pub(super) struct LoginChild { + process: OwnedHandle, + job: OwnedHandle, + #[cfg(test)] + pub(super) id: u32, +} + +impl LoginChild { + pub(super) fn spawn(command: &Command) -> io::Result { + let application = wide(command.get_program())?; + let directory = command + .get_current_dir() + .map(|p| wide(p.as_os_str())) + .transpose()?; + let mut arguments = Vec::new(); + for arg in std::iter::once(command.get_program()).chain(command.get_args()) { + if !arguments.is_empty() { + arguments.push(b' ' as u16); + } + quote_argument(arg, &mut arguments)?; + } + arguments.push(0); + let environment = environment(command)?; + // No job handle is inheritable. Only the NUL stdio handle is passed to + // the child, so app termination always closes the last job handle. + let job = + // SAFETY: a successful call transfers a unique, valid job handle. + unsafe { owned(CreateJobObjectW(None, PCWSTR::null()).map_err(io::Error::other)?) }; + let limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION { + BasicLimitInformation: JOBOBJECT_BASIC_LIMIT_INFORMATION { + LimitFlags: JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, + ..Default::default() + }, + ..Default::default() + }; + // SAFETY: valid owned job handle and a correctly sized, initialized limit structure. + unsafe { + SetInformationJobObject( + handle(&job), + JobObjectExtendedLimitInformation, + (&limits as *const JOBOBJECT_EXTENDED_LIMIT_INFORMATION).cast(), + u32::try_from(std::mem::size_of_val(&limits)).map_err(io::Error::other)?, + ) + .map_err(io::Error::other)?; + } + let nul = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open("NUL")?; + let stdio = HANDLE(nul.as_raw_handle()); + // SAFETY: NUL belongs to this call and will be closed after process creation. + unsafe { + SetHandleInformation(stdio, HANDLE_FLAG_INHERIT.0, HANDLE_FLAG_INHERIT) + .map_err(io::Error::other)?; + } + let jobs = [handle(&job)]; + let handles = [stdio]; + let mut attributes = Attributes::new()?; + // SAFETY: both arrays remain alive through CreateProcessW; each attribute is a HANDLE array. + unsafe { + UpdateProcThreadAttribute( + attributes.ptr(), + 0, + PROC_THREAD_ATTRIBUTE_JOB_LIST as usize, + Some(jobs.as_ptr().cast()), + std::mem::size_of_val(&jobs), + None, + None, + ) + .map_err(io::Error::other)?; + UpdateProcThreadAttribute( + attributes.ptr(), + 0, + PROC_THREAD_ATTRIBUTE_HANDLE_LIST as usize, + Some(handles.as_ptr().cast()), + std::mem::size_of_val(&handles), + None, + None, + ) + .map_err(io::Error::other)?; + } + let startup = STARTUPINFOEXW { + StartupInfo: STARTUPINFOW { + cb: u32::try_from(std::mem::size_of::()) + .map_err(io::Error::other)?, + dwFlags: STARTF_USESTDHANDLES, + hStdInput: stdio, + hStdOutput: stdio, + hStdError: stdio, + ..Default::default() + }, + lpAttributeList: attributes.ptr(), + }; + let mut info = PROCESS_INFORMATION::default(); + // SAFETY: all strings are terminated UTF-16, the mutable command buffer + // is owned, and environment/attribute storage outlives this call. The + // job-list attribute binds the child before its first thread can run. + unsafe { + CreateProcessW( + PCWSTR(application.as_ptr()), + PWSTR(arguments.as_mut_ptr()), + None, + None, + true, + CREATE_NO_WINDOW | CREATE_UNICODE_ENVIRONMENT | EXTENDED_STARTUPINFO_PRESENT, + Some(environment.as_ptr().cast()), + directory + .as_ref() + .map_or(PCWSTR::null(), |value| PCWSTR(value.as_ptr())), + &startup.StartupInfo, + &mut info, + ) + .map_err(io::Error::other)?; + let _thread = owned(info.hThread); + Ok(Self { + process: owned(info.hProcess), + job, + #[cfg(test)] + id: info.dwProcessId, + }) + } + } + + pub(super) fn try_wait(&mut self) -> io::Result> { + // SAFETY: the process handle remains owned by self. + match unsafe { WaitForSingleObject(handle(&self.process), 0) } { + WAIT_TIMEOUT => Ok(None), + WAIT_OBJECT_0 => self.exit_status().map(Some), + _ => Err(io::Error::last_os_error()), + } + } + + pub(super) fn kill(&mut self) -> io::Result<()> { + // SAFETY: this private job contains only the isolated login process tree. + unsafe { TerminateJobObject(handle(&self.job), 1).map_err(io::Error::other) } + } + + pub(super) fn wait(&mut self) -> io::Result { + // SAFETY: the process handle remains owned by self. + if unsafe { WaitForSingleObject(handle(&self.process), INFINITE) } != WAIT_OBJECT_0 { + return Err(io::Error::last_os_error()); + } + self.exit_status() + } + + fn exit_status(&self) -> io::Result { + let mut code = 0; + // SAFETY: the process handle and output pointer are valid. + unsafe { + GetExitCodeProcess(handle(&self.process), &mut code).map_err(io::Error::other)?; + } + Ok(ExitStatus::from_raw(code)) + } +} + +impl Drop for LoginChild { + fn drop(&mut self) { + let _terminated = self.kill(); + // Give the login process a chance to release file handles before the + // directory guard removes its home. Closing the job also covers a crash. + // SAFETY: self still owns the valid process handle during Drop. + unsafe { + WaitForSingleObject(handle(&self.process), 5_000); + } + } +} + +struct Attributes(Vec); + +impl Attributes { + fn new() -> io::Result { + let mut bytes = 0; + // SAFETY: the first call only queries the required buffer size. + unsafe { + let _query = InitializeProcThreadAttributeList( + LPPROC_THREAD_ATTRIBUTE_LIST::default(), + 2, + 0, + &mut bytes, + ); + } + if bytes == 0 { + return Err(io::Error::last_os_error()); + } + let mut value = Self(vec![0; bytes.div_ceil(std::mem::size_of::())]); + if let Err(error) = + // SAFETY: the owned allocation is aligned and at least bytes long. + unsafe { InitializeProcThreadAttributeList(value.ptr(), 2, 0, &mut bytes) } + { + // An uninitialized list must not be passed to DeleteProcThreadAttributeList. + value.0.clear(); + return Err(io::Error::other(error)); + } + Ok(value) + } + + fn ptr(&mut self) -> LPPROC_THREAD_ATTRIBUTE_LIST { + LPPROC_THREAD_ATTRIBUTE_LIST(self.0.as_mut_ptr().cast()) + } +} + +impl Drop for Attributes { + fn drop(&mut self) { + if !self.0.is_empty() { + // SAFETY: this list was initialized and has not yet been deleted. + unsafe { + DeleteProcThreadAttributeList(self.ptr()); + } + } + } +} + +fn handle(value: &OwnedHandle) -> HANDLE { + HANDLE(value.as_raw_handle()) +} + +unsafe fn owned(value: HANDLE) -> OwnedHandle { + // SAFETY: caller transfers a unique valid Win32 handle from a successful API call. + unsafe { OwnedHandle::from_raw_handle(value.0) } +} + +fn wide(value: &OsStr) -> io::Result> { + let mut value: Vec<_> = value.encode_wide().collect(); + if value.contains(&0) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "NUL in process argument", + )); + } + value.push(0); + Ok(value) +} + +fn quote_argument(value: &OsStr, output: &mut Vec) -> io::Result<()> { + output.push(b'"' as u16); + let mut slashes = 0; + for code in wide(value)?.into_iter().take_while(|&code| code != 0) { + if code == b'\\' as u16 { + slashes += 1; + continue; + } + let count = if code == b'"' as u16 { + slashes * 2 + 1 + } else { + slashes + }; + output.extend(std::iter::repeat_n(b'\\' as u16, count)); + output.push(code); + slashes = 0; + } + output.extend(std::iter::repeat_n(b'\\' as u16, slashes * 2)); + output.push(b'"' as u16); + Ok(()) +} + +fn environment(command: &Command) -> io::Result> { + let mut values: Vec<(OsString, OsString)> = std::env::vars_os().collect(); + for (name, value) in command.get_envs() { + values.retain(|(key, _)| { + !key.to_string_lossy() + .eq_ignore_ascii_case(&name.to_string_lossy()) + }); + if let Some(value) = value { + values.push((name.to_owned(), value.to_owned())); + } + } + values.sort_by_cached_key(|(key, _)| key.to_string_lossy().to_uppercase()); + let mut block = Vec::new(); + for (name, value) in values { + let mut entry = name; + entry.push("="); + entry.push(value); + block.extend(wide(&entry)?); + } + if block.is_empty() { + block.push(0); + } + block.push(0); + Ok(block) +} diff --git a/rust/src/providers/claude/mod.rs b/rust/src/providers/claude/mod.rs index fd76d8e2a1..9534aa85d4 100755 --- a/rust/src/providers/claude/mod.rs +++ b/rust/src/providers/claude/mod.rs @@ -1,5 +1,6 @@ //! Claude provider implementation +pub mod accounts; mod admin_api; mod cli_reset; mod oauth; @@ -48,6 +49,13 @@ struct CachedCliResult { static CLI_RESULT_CACHE: LazyLock>> = LazyLock::new(|| Mutex::new(None)); +fn clear_account_caches(credential_path: &std::path::Path) { + if let Ok(mut cache) = CLI_RESULT_CACHE.lock() { + *cache = None; + } + oauth::clear_account_cache(credential_path); +} + /// Store a successful CLI fetch result in the 15-minute cache. fn cache_cli_result(result: ProviderFetchResult) { if let Ok(mut guard) = CLI_RESULT_CACHE.lock() { @@ -365,6 +373,9 @@ async fn run_claude_pty_probe( probe: ClaudePtyProbeOptions, ) -> Result { tokio::task::spawn_blocking(move || { + // Keep ownership in the worker: cancelling the async refresh does not + // stop spawn_blocking or its CLI process from rotating credentials. + let _account_operation = accounts::CREDENTIAL_OPERATION.blocking_lock(); cleanup_probe_session_jsonl(&working_directory); let session_id = load_or_create_probe_session_id(&working_directory); let env = claude_passive_probe_env(TtyCommandRunner::enriched_environment()); diff --git a/rust/src/providers/claude/oauth/credentials_store.rs b/rust/src/providers/claude/oauth/credentials_store.rs index bda3e5dbbd..2015d4ad85 100644 --- a/rust/src/providers/claude/oauth/credentials_store.rs +++ b/rust/src/providers/claude/oauth/credentials_store.rs @@ -16,7 +16,6 @@ use std::sync::{Mutex, OnceLock}; use super::ClaudeOAuthCredentials; use crate::core::ProviderError; -const CREDENTIALS_PATH: &str = ".claude/.credentials.json"; 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"; @@ -65,6 +64,12 @@ fn refreshed_cache() -> &'static Mutex, value: String) { /// Get the credentials file path fn credentials_path() -> Result { - dirs::home_dir() - .map(|home| home.join(CREDENTIALS_PATH)) - .ok_or_else(|| ProviderError::OAuth("Could not find home directory".to_string())) + super::super::accounts::config_dir() + .map(|home| home.join(".credentials.json")) + .map_err(|e| ProviderError::OAuth(e.to_string())) } /// Persist refreshed tokens back to `~/.claude/.credentials.json`, updating diff --git a/rust/src/providers/claude/oauth/mod.rs b/rust/src/providers/claude/oauth/mod.rs index d44619e6ed..4118d7b65a 100644 --- a/rust/src/providers/claude/oauth/mod.rs +++ b/rust/src/providers/claude/oauth/mod.rs @@ -15,6 +15,13 @@ use crate::core::{NamedRateWindow, ProviderError, ProviderFetchResult, RateWindo mod credentials_store; mod refresh; +pub(super) fn clear_account_cache(credential_path: &std::path::Path) { + credentials_store::clear_cache(); + clear_refresh_backoff(&credentials_store::CredentialSource::File( + credential_path.to_path_buf(), + )); +} + /// OAuth credentials from Claude CLI #[derive(Debug, Clone)] pub struct ClaudeOAuthCredentials { @@ -233,6 +240,7 @@ impl ClaudeOAuthFetcher { /// OAuth token first (like the Claude CLI does) so the panel stays green /// without the user having to re-run `claude`. pub async fn fetch(&self) -> Result { + let _account_operation = super::accounts::CREDENTIAL_OPERATION.lock().await; let (credentials, source) = credentials_store::load_credentials()?; let (credentials, refresh_outcome) = self.ensure_fresh_credentials(credentials, source).await; diff --git a/rust/src/providers/claude/oauth/tests.rs b/rust/src/providers/claude/oauth/tests.rs index 73657c6266..9a4998c18f 100644 --- a/rust/src/providers/claude/oauth/tests.rs +++ b/rust/src/providers/claude/oauth/tests.rs @@ -448,6 +448,24 @@ fn transient_refresh_failure_gets_5min_backoff() { ); } +#[test] +fn switching_accounts_clears_the_credential_files_transient_cooldown() { + let source = unique_source("switch"); + let super::credentials_store::CredentialSource::File(path) = &source else { + panic!("file source") + }; + let now = std::time::Instant::now(); + super::record_refresh_backoff( + &source, + super::refresh::RefreshFailureKind::Transient, + now, + Some("old-token"), + ); + assert!(super::active_refresh_backoff(&source, now, Some("new-token")).is_some()); + super::clear_account_cache(path); + assert!(super::active_refresh_backoff(&source, now, Some("new-token")).is_none()); +} + #[test] fn backoff_kinds_have_distinct_user_messages() { let terminal = super::terminal_refresh_message();