From 5d8dda7f025f167a36c3cf2c2d29426be6263f79 Mon Sep 17 00:00:00 2001 From: Steve <52034225+stephanus-tantiono@users.noreply.github.com> Date: Thu, 10 Sep 2026 09:56:51 +0700 Subject: [PATCH] feat: add Codex ambient login refresh action --- .../src-tauri/src/commands/codex_accounts.rs | 65 +++++++++++++++++++ apps/desktop-tauri/src-tauri/src/main.rs | 1 + apps/desktop-tauri/src/i18n/keys.ts | 1 + apps/desktop-tauri/src/lib/tauri.ts | 4 ++ .../credentials/CodexAccountsSection.test.tsx | 22 +++++++ .../credentials/CodexAccountsSection.tsx | 31 ++++++++- rust/src/codex_accounts/account_manager.rs | 3 + rust/src/locale.rs | 1 + rust/src/locale/en-US.ftl | 3 +- 9 files changed, 127 insertions(+), 4 deletions(-) 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..2c8bbfd57d 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,43 @@ 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 account = 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. + if let Err(e) = refresh_persisted_accounts(app.clone()) { + tracing::warn!("Codex login succeeded but account metadata could not be saved: {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; + }); + + Ok(account) +} + #[tauri::command] pub fn codex_account_remove(app: tauri::AppHandle, id: String) -> Result<(), String> { let runtime = CodexAccountRuntime::new(); @@ -409,6 +446,14 @@ fn refresh_persisted_accounts(app: tauri::AppHandle) -> Result<(), String> { Ok(()) } +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()) +} + fn accounts_changed(app: &tauri::AppHandle) { events::emit_codex_accounts_updated(app); let handle = app.clone(); @@ -655,6 +700,26 @@ 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 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" && ( + + )}