diff --git a/apps/desktop-tauri/src-tauri/src/commands/codex_accounts.rs b/apps/desktop-tauri/src-tauri/src/commands/codex_accounts.rs index a40b4cc336..62df36f5b5 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/codex_accounts.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/codex_accounts.rs @@ -222,6 +222,50 @@ pub async fn codex_account_add(app: tauri::AppHandle) -> Result Result { + let runtime = CodexAccountRuntime::new(); + let _mutation = runtime.try_begin_mutation().map_err(into_user_message)?; + let target = ambient_account(&load_codex_accounts()?)?; + let manager = CodexAccountManager::new(); + let authenticated = + tauri::async_runtime::spawn_blocking(move || manager.reauthenticate(&target, None)) + .await + .map_err(|e| e.to_string())? + .map_err(into_user_message)?; + + // The login flow replaced the ambient auth file. Reconcile the identity + // before refreshing usage so every surface observes the new session. The + // logged-in record is transient: reconciliation can drop or replace the + // ambient identity, so report only a record that was actually persisted. + let account = match refresh_persisted_accounts(app.clone()) { + Ok(accounts) => canonical_reauthenticated_account(&accounts, &authenticated), + Err(e) => { + // Credential replacement is already committed, but the reconciled + // account set could not be saved. Reporting the transient login + // result would expose an account the store never committed, so the + // persistence error is surfaced instead. + tracing::warn!("Codex login succeeded but account metadata could not be saved: {e}"); + Err(e) + } + }; + let pending = { + let state = app.state::>(); + let mut state = state.lock().map_err(|e| e.to_string())?; + invalidate_account_usage(&mut state, ProviderId::Codex) + }; + events::emit_provider_updated(&app, &pending); + + let refresh_app = app.clone(); + tauri::async_runtime::spawn(async move { + let _ = do_refresh_providers(&refresh_app).await; + }); + + account +} + #[tauri::command] pub fn codex_account_remove(app: tauri::AppHandle, id: String) -> Result<(), String> { let runtime = CodexAccountRuntime::new(); @@ -401,12 +445,46 @@ pub async fn codex_account_restart_desktop( /// Merge discovered accounts back into the persisted list after identity /// changes (login/switch) so the store reflects reality. -fn refresh_persisted_accounts(app: tauri::AppHandle) -> Result<(), String> { +/// +/// Returns the post-reconciliation accounts, which is exactly the set that was +/// persisted, so callers can report a canonical account from persisted state. +fn refresh_persisted_accounts(app: tauri::AppHandle) -> Result, String> { let accounts = load_codex_accounts()?; persist_codex_accounts(&accounts)?; events::emit_settings_changed(&app); accounts_changed(&app); - Ok(()) + Ok(accounts) +} + +/// Select the ambient identity from a persisted account set. +fn ambient_account(accounts: &[CodexAccount]) -> Result { + accounts + .iter() + .find(|account| account.source == codexbar::codex_accounts::CodexAccountSource::Ambient) + .cloned() + .ok_or_else(|| "No ambient Codex account found.".to_string()) +} + +/// The account a reauthentication command should report. +/// +/// The persisted reconciled set is authoritative. A login that changes the +/// ambient identity produces a fresh persisted record with a new id, while the +/// login helper reuses the pre-login id, so the transient login result is used +/// only to locate its persisted counterpart. Prefer the persisted record +/// matching the authenticated identity, then the persisted ambient record. +/// When neither is present the login was never committed, so the command fails +/// instead of exposing a dropped or replaced transient account. +fn canonical_reauthenticated_account( + accounts: &[CodexAccount], + authenticated: &CodexAccount, +) -> Result { + if let Some(account) = accounts + .iter() + .find(|account| account.matches(authenticated)) + { + return Ok(account.clone()); + } + ambient_account(accounts) } fn accounts_changed(app: &tauri::AppHandle) { @@ -655,6 +733,126 @@ mod tests { ); } + #[test] + fn ambient_account_selects_only_the_ambient_identity() { + let managed = sample_account(); + let mut ambient = managed.clone(); + ambient.source = codexbar::codex_accounts::CodexAccountSource::Ambient; + + let selected = ambient_account(&[managed, ambient.clone()]).unwrap(); + + assert_eq!(selected.id, ambient.id); + assert_eq!( + selected.source, + codexbar::codex_accounts::CodexAccountSource::Ambient + ); + } + + #[test] + fn ambient_account_reports_when_no_ambient_identity_exists() { + assert_eq!( + ambient_account(&[sample_account()]).unwrap_err(), + "No ambient Codex account found." + ); + } + + #[test] + fn reconciled_ambient_identity_change_replaces_the_login_result() { + let mut stored = sample_account(); + stored.source = codexbar::codex_accounts::CodexAccountSource::Ambient; + stored.provider_account_id = Some("old-workspace".into()); + stored.email_hint = Some("old@example.com".into()); + + // Logging in as a different identity at the same ambient home. + let mut fresh = sample_account(); + fresh.source = codexbar::codex_accounts::CodexAccountSource::Ambient; + fresh.provider_account_id = Some("new-workspace".into()); + fresh.email_hint = Some("new@example.com".into()); + + let reconciled = reconcile_codex_accounts(&[stored.clone()], &[], Some(fresh)); + assert_eq!(reconciled.len(), 1); + assert_ne!(reconciled[0].id, stored.id); + + // `reauthenticate` reuses the pre-login id; the command must report the + // reconciled record so it agrees with the persisted store and events. + let mut authenticated = stored.clone(); + authenticated.email_hint = Some("new@example.com".into()); + let account = canonical_reauthenticated_account(&reconciled, &authenticated).unwrap(); + assert_eq!(account.id, reconciled[0].id); + assert_ne!(account.id, authenticated.id); + assert_eq!( + account.source, + codexbar::codex_accounts::CodexAccountSource::Ambient + ); + assert_eq!( + account.provider_account_id.as_deref(), + Some("new-workspace") + ); + } + + #[test] + fn canonical_reauthenticated_account_returns_the_persisted_replacement() { + let mut authenticated = sample_account(); + authenticated.source = codexbar::codex_accounts::CodexAccountSource::Ambient; + authenticated.provider_account_id = Some("old-workspace".into()); + authenticated.email_hint = Some("old@example.com".into()); + + let mut persisted = sample_account(); + persisted.source = codexbar::codex_accounts::CodexAccountSource::Ambient; + persisted.provider_account_id = Some("new-workspace".into()); + persisted.email_hint = Some("new@example.com".into()); + + let account = + canonical_reauthenticated_account(&[persisted.clone()], &authenticated).unwrap(); + assert_eq!(account.id, persisted.id); + assert_ne!(account.id, authenticated.id); + assert_eq!( + account.provider_account_id.as_deref(), + Some("new-workspace") + ); + } + + #[test] + fn canonical_reauthenticated_account_returns_the_unchanged_persisted_reauth() { + let mut persisted = sample_account(); + persisted.source = codexbar::codex_accounts::CodexAccountSource::Ambient; + persisted.nickname = Some("Work".into()); + persisted.provider_account_id = Some("workspace".into()); + + // The login helper does not carry optional stored metadata. + let mut authenticated = persisted.clone(); + authenticated.nickname = None; + + let account = + canonical_reauthenticated_account(&[persisted.clone()], &authenticated).unwrap(); + assert_eq!(account.id, persisted.id); + assert_eq!(account.nickname.as_deref(), Some("Work")); + } + + #[test] + fn canonical_reauthenticated_account_does_not_return_a_dropped_login() { + let mut authenticated = sample_account(); + authenticated.source = codexbar::codex_accounts::CodexAccountSource::Ambient; + authenticated.provider_account_id = Some("dropped-workspace".into()); + authenticated.email_hint = Some("dropped@example.com".into()); + + // The reconciled set dropped the login identity and holds no ambient + // record to replace it with. + let error = + canonical_reauthenticated_account(&[sample_account()], &authenticated).unwrap_err(); + assert_eq!(error, "No ambient Codex account found."); + } + + #[test] + fn canonical_reauthenticated_account_rejects_an_uncommitted_persistence_failure() { + let authenticated = sample_account(); + + // A failed persistence leaves no committed reconciled set; the transient + // login result must not be surfaced in its place. + let error = canonical_reauthenticated_account(&[], &authenticated).unwrap_err(); + assert_eq!(error, "No ambient Codex account found."); + } + #[test] fn sample_account_serializes_camel_case() { let json = serde_json::to_value(sample_account()).unwrap(); diff --git a/apps/desktop-tauri/src-tauri/src/main.rs b/apps/desktop-tauri/src-tauri/src/main.rs index 131d854840..1bd00baec9 100644 --- a/apps/desktop-tauri/src-tauri/src/main.rs +++ b/apps/desktop-tauri/src-tauri/src/main.rs @@ -184,6 +184,7 @@ fn main() { commands::claude_account_remove, commands::claude_account_switch, commands::codex_account_add, + commands::codex_account_reauthenticate, commands::codex_account_remove, commands::codex_account_switch, commands::codex_account_fetch, diff --git a/apps/desktop-tauri/src/i18n/keys.ts b/apps/desktop-tauri/src/i18n/keys.ts index 602b7aaf35..f9c07be67a 100644 --- a/apps/desktop-tauri/src/i18n/keys.ts +++ b/apps/desktop-tauri/src/i18n/keys.ts @@ -318,6 +318,7 @@ export const ALL_LOCALE_KEYS = [ "ClaudeAccountsAdded", "CodexAccountsHint", "CodexAccountsAddButton", + "CodexAccountsReauthenticateButton", "CodexAccountsSwitchButton", "CodexAccountsFetchButton", "CodexAccountsRemoveButton", diff --git a/apps/desktop-tauri/src/lib/tauri.ts b/apps/desktop-tauri/src/lib/tauri.ts index af0567c5c2..5ec88ec953 100644 --- a/apps/desktop-tauri/src/lib/tauri.ts +++ b/apps/desktop-tauri/src/lib/tauri.ts @@ -499,6 +499,10 @@ export function codexAccountAdd(): Promise { return invoke("codex_account_add"); } +export function codexAccountReauthenticate(): Promise { + return invoke("codex_account_reauthenticate"); +} + export function codexAccountRemove(id: string): Promise { return invoke("codex_account_remove", { id }); } diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/CodexAccountsSection.test.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/CodexAccountsSection.test.tsx index 6e1c1e7a6f..b8c76063a7 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/CodexAccountsSection.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/CodexAccountsSection.test.tsx @@ -12,6 +12,7 @@ const tauriMocks = vi.hoisted(() => ({ getCodexAccountsState: vi.fn(), codexAccountAdd: vi.fn(), codexAccountFetch: vi.fn(), + codexAccountReauthenticate: vi.fn(), codexAccountRemove: vi.fn(), codexAccountSwitch: vi.fn(), codexAccountRestartDesktop: vi.fn(), @@ -76,6 +77,7 @@ describe("CodexAccountsSection", () => { expect(screen.getByText("user-2@example.com")).toBeDefined(); expect(screen.getByText("CodexAccountsSourceManaged")).toBeDefined(); expect(screen.getByText("CodexAccountsSourceAmbient")).toBeDefined(); + expect(screen.getAllByText("CodexAccountsReauthenticateButton")).toHaveLength(1); }); it("shows the usage pill and blocked state from a snapshot", async () => { @@ -93,6 +95,26 @@ describe("CodexAccountsSection", () => { }); }); + it("offers ambient reauthentication and reloads the account state", async () => { + const ambient = account("ambient", { source: "ambient" }); + tauriMocks.getCodexAccountsState + .mockResolvedValueOnce({ accounts: [ambient], snapshots: {} } as CodexAccountsStateBridge) + .mockResolvedValueOnce({ accounts: [ambient], snapshots: { ambient: snapshot(12) } } as CodexAccountsStateBridge); + tauriMocks.codexAccountReauthenticate.mockResolvedValue(ambient); + + render(); + await screen.findByText("CodexAccountsReauthenticateButton"); + + await act(async () => { + screen.getByText("CodexAccountsReauthenticateButton").click(); + }); + + expect(tauriMocks.codexAccountReauthenticate).toHaveBeenCalledTimes(1); + await waitFor(() => { + expect(screen.getByText("free ยท 12%")).toBeDefined(); + }); + }); + it("does not offer a desktop session restart for a no-op switch", async () => { tauriMocks.getCodexAccountsState.mockResolvedValue({ accounts: [account("1")], snapshots: {} }); tauriMocks.codexAccountSwitch.mockResolvedValue({ switchId: "noop", desktopSessionRestorePath: null } as CodexSwitchResult); diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/CodexAccountsSection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/CodexAccountsSection.tsx index 3fd3d6ccea..c16c22e459 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/CodexAccountsSection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/CodexAccountsSection.tsx @@ -10,6 +10,7 @@ import type { LocaleKey } from "../../../../../i18n/keys"; import { codexAccountAdd, codexAccountFetch, + codexAccountReauthenticate, codexAccountRemove, codexAccountRestartDesktop, codexAccountSwitch, @@ -28,9 +29,9 @@ interface Props { * Multi-account Codex support (ADR 0003). Reads the shared account + * snapshot store via `get_codex_accounts_state` and drives the * `codex_account_*` IPC surface: add (login into a managed home), switch the - * active ambient identity, refresh per-account usage, and remove managed - * homes. For MSIX Codex Desktop installs a restart action is offered when a - * session snapshot is available to restore. + * active ambient identity, refresh per-account usage, reauthenticate the + * ambient identity, and remove managed homes. For MSIX Codex Desktop installs + * a restart action is offered when a session snapshot is available to restore. */ export function CodexAccountsSection({ t }: Props) { const [accounts, setAccounts] = useState([]); @@ -120,6 +121,20 @@ export function CodexAccountsSection({ t }: Props) { } }; + const handleReauthenticate = async () => { + setBusy(true); + setError(null); + setSwitchResult(null); + try { + await codexAccountReauthenticate(); + await load(); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + }; + const handleRemove = async (id: string) => { setBusy(true); setError(null); @@ -233,6 +248,16 @@ export function CodexAccountsSection({ t }: Props) {
+ {account.source === "ambient" && ( + + )}