From f7ef1b2690ecfed1163231503ed09002ec3b93ef Mon Sep 17 00:00:00 2001 From: xuelongmu Date: Tue, 8 Sep 2026 00:12:27 -0400 Subject: [PATCH 1/2] Make Codex switching and Desktop restarts reliable --- .../src-tauri/src/commands/codex_accounts.rs | 345 ++++++++++++++++-- .../src-tauri/src/commands/providers.rs | 29 +- apps/desktop-tauri/src/lib/tauri.ts | 8 +- .../credentials/CodexAccountsSection.test.tsx | 19 +- .../credentials/CodexAccountsSection.tsx | 11 +- apps/desktop-tauri/src/types/bridge.ts | 1 + rust/src/codex_accounts/account_manager.rs | 141 ++++++- rust/src/codex_accounts/api.rs | 344 ++++++++++++++++- rust/src/codex_accounts/codex_desktop.rs | 212 +++++++---- rust/src/codex_accounts/mod.rs | 5 + rust/src/codex_accounts/models.rs | 51 ++- 11 files changed, 1047 insertions(+), 119 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 179c84ada1..54065088e4 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/codex_accounts.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/codex_accounts.rs @@ -1,5 +1,4 @@ use std::collections::HashMap; -use std::path::PathBuf; use std::sync::{Arc, Mutex}; use uuid::Uuid; @@ -16,6 +15,8 @@ use super::*; // ── Codex multi-account (ADR 0003, milestone 2) ────────────────────── const DEFAULT_FETCH_TIMEOUT_SECONDS: u64 = 60; +static ACCOUNT_MUTATION: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); +static PENDING_RESTART: Mutex> = Mutex::new(None); /// All stored + discovered Codex accounts, with the stored list preferred. pub(crate) fn load_codex_accounts() -> Result, String> { @@ -28,7 +29,15 @@ pub(crate) fn load_codex_accounts() -> Result, String> { .map_err(|e| e.to_string())?; let ambient = manager.discover_ambient_account(&existing); - let mut merged: Vec = managed.clone(); + Ok(reconcile_codex_accounts(&existing, &managed, ambient)) +} + +fn reconcile_codex_accounts( + existing: &[CodexAccount], + managed: &[CodexAccount], + ambient: Option, +) -> Vec { + let mut merged: Vec = managed.to_vec(); if let Some(ambient) = ambient { if let Some(entry) = merged.iter_mut().find(|account| account.matches(&ambient)) { entry.merge_from(&ambient); @@ -40,6 +49,17 @@ pub(crate) fn load_codex_accounts() -> Result, String> { // Reconcile persisted metadata (nickname, stored timestamps) for managed homes. let mut reconciled: Vec = existing .iter() + // The ambient home can change identity outside this app. Always use + // its fresh discovery instead of retaining an old record for that path. + .filter(|account| account.source.owns_files()) + // A managed home can also be reauthenticated outside the app. Do not + // retain its former identity alongside the account now owning its auth. + .filter(|account| { + !managed.iter().any(|fresh| { + fresh.standardized_home_path() == account.standardized_home_path() + && !fresh.matches(account) + }) + }) .map(|account| { let mut account = account.clone(); if let Some(fresh) = managed.iter().find(|fresh| fresh.matches(&account)) { @@ -56,7 +76,7 @@ pub(crate) fn load_codex_accounts() -> Result, String> { } } - Ok(reconciled) + reconciled } /// Persist the given accounts to the account store. @@ -83,6 +103,7 @@ pub(crate) fn persist_codex_accounts(accounts: &[CodexAccount]) -> Result<(), St pub(crate) async fn refresh_codex_account_lanes( app: tauri::AppHandle, fetch_permits: Arc, + generation: u64, ) { let accounts = match load_codex_accounts() { Ok(accounts) => accounts, @@ -128,18 +149,38 @@ pub(crate) async fn refresh_codex_account_lanes( })); } - let mut snapshots = SnapshotStore::new().load().unwrap_or_default(); + let mut updates = Vec::new(); for handle in handles { if let Ok(Some((id, snapshot))) = handle.await { - snapshots.insert(id, snapshot); + updates.push((id, snapshot)); } } - if let Err(e) = SnapshotStore::new().save(&snapshots) { - tracing::warn!("codex account lanes: failed to persist snapshots: {e}"); + // Hold the generation owner through the read/merge/write so an invalidated + // batch cannot overwrite a replacement batch's account snapshots. + let state = app.state::>(); + let Ok(state) = state.lock() else { return }; + match save_codex_lane_results(&state, generation, updates) { + Ok(false) => return, + Err(e) => tracing::warn!("codex account lanes: failed to persist snapshots: {e}"), + Ok(true) => {} } events::emit_codex_accounts_updated(&app); } +fn save_codex_lane_results( + state: &AppState, + generation: u64, + updates: Vec<(Uuid, codexbar::codex_accounts::AccountUsageSnapshot)>, +) -> Result { + if !is_current_provider_refresh_generation(state, generation) { + return Ok(false); + } + let mut snapshots = SnapshotStore::new().load()?; + snapshots.extend(updates); + SnapshotStore::new().save(&snapshots)?; + Ok(true) +} + #[tauri::command] pub fn codex_accounts_list() -> Result, String> { load_codex_accounts() @@ -147,6 +188,9 @@ pub fn codex_accounts_list() -> Result, String> { #[tauri::command] pub async fn codex_account_add(app: tauri::AppHandle) -> Result { + let _mutation = ACCOUNT_MUTATION + .try_lock() + .map_err(|_| "A Codex account operation is already in progress.".to_string())?; let manager = CodexAccountManager::new(); let account = tauri::async_runtime::spawn_blocking(move || manager.add_managed_account(None)) .await @@ -161,6 +205,9 @@ pub async fn codex_account_add(app: tauri::AppHandle) -> Result Result<(), String> { + let _mutation = ACCOUNT_MUTATION + .try_lock() + .map_err(|_| "A Codex account operation is already in progress.".to_string())?; let manager = CodexAccountManager::new(); let accounts = load_codex_accounts()?; let target = accounts @@ -168,9 +215,12 @@ pub fn codex_account_remove(app: tauri::AppHandle, id: String) -> Result<(), Str .find(|account| account.id.to_string() == id) .ok_or_else(|| "Codex account not found.".to_string())?; + let mut pending_restart = PENDING_RESTART.lock().map_err(|e| e.to_string())?; manager .remove_managed_files_if_owned(target) .map_err(into_user_message)?; + invalidate_restart_for_removed_account(&mut pending_restart, target); + drop(pending_restart); let remaining: Vec = accounts .into_iter() @@ -178,14 +228,43 @@ pub fn codex_account_remove(app: tauri::AppHandle, id: String) -> Result<(), Str .collect(); persist_codex_accounts(&remaining)?; events::emit_settings_changed(&app); + accounts_changed(&app); Ok(()) } +fn invalidate_restart_for_removed_account( + pending: &mut Option, + removed: &CodexAccount, +) { + if pending.as_ref().is_some_and(|result| { + result + .materialized_account + .as_ref() + .is_some_and(|account| account.matches(removed)) + || result + .ambient_account + .as_ref() + .is_some_and(|account| account.matches(removed)) + || [ + &result.desktop_session_backup_path, + &result.desktop_session_restore_path, + ] + .into_iter() + .flatten() + .any(|path| path.parent() == Some(removed.codex_home_path.as_path())) + }) { + *pending = None; + } +} + #[tauri::command] pub async fn codex_account_switch( app: tauri::AppHandle, id: String, ) -> Result { + let _mutation = ACCOUNT_MUTATION + .try_lock() + .map_err(|_| "A Codex account operation is already in progress.".to_string())?; let manager = CodexAccountManager::new(); let accounts = load_codex_accounts()?; let target = accounts @@ -203,18 +282,47 @@ pub async fn codex_account_switch( .map_err(into_user_message)?; // Materialized ambient account may need persisting. - if let Some(materialized) = &result.materialized_account { + *PENDING_RESTART.lock().map_err(|e| e.to_string())? = result + .desktop_session_restore_path + .as_ref() + .map(|_| result.clone()); + 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); + persist_materialized_account(result.materialized_account.as_ref()); + + // Discovery must see the persisted account ID before a lane fetch starts. + // Credential replacement is already committed. Even if optional account + // metadata cannot be saved, refresh and notify all surfaces of that switch. + let refresh_app = app.clone(); + tauri::async_runtime::spawn(async move { + let _ = do_refresh_providers(&refresh_app).await; + }); + + events::emit_settings_changed(&app); + accounts_changed(&app); + Ok(result) +} + +fn persist_materialized_account(materialized: Option<&CodexAccount>) { + let Some(materialized) = materialized else { + return; + }; + let persist = || -> Result<(), String> { let mut accounts = load_codex_accounts()?; if let Some(entry) = accounts.iter_mut().find(|a| a.matches(materialized)) { entry.merge_from(materialized); } else { accounts.push(materialized.clone()); } - persist_codex_accounts(&accounts)?; + persist_codex_accounts(&accounts) + }; + if let Err(error) = persist() { + tracing::warn!("Codex account switched but account metadata could not be saved: {error}"); } - - events::emit_settings_changed(&app); - Ok(result) } #[tauri::command] @@ -261,36 +369,56 @@ pub fn codex_account_snapshots() #[tauri::command] pub async fn codex_account_restart_desktop( _app: tauri::AppHandle, - session_root: Option, - backup_destination: Option, - restore_source: Option, + switch_id: String, ) -> Result<(), String> { + let _mutation = ACCOUNT_MUTATION + .try_lock() + .map_err(|_| "A Codex account operation is already in progress.".to_string())?; + let pending = PENDING_RESTART.lock().map_err(|e| e.to_string())?.clone(); + let active = CodexAccountManager::new().discover_ambient_account(&[]); + let pending = validate_pending_restart(pending.as_ref(), &switch_id, active.as_ref())?.clone(); tauri::async_runtime::spawn_blocking(move || { - let session_root = session_root.map(PathBuf::from); - let backup_destination = backup_destination.map(PathBuf::from); - let restore_source = restore_source.map(PathBuf::from); restart_codex_desktop( 0.8, - session_root.as_deref(), - backup_destination.as_deref(), - restore_source.as_deref(), + None, + pending.desktop_session_backup_path.as_deref(), + pending.desktop_session_restore_path.as_deref(), ) }) .await .map_err(|e| e.to_string())? .map_err(|e| e.to_string())?; + // The outgoing session backup is single-use. Replaying it after relaunch + // would overwrite that backup with the newly active account's session. + *PENDING_RESTART.lock().map_err(|e| e.to_string())? = None; Ok(()) } +fn validate_pending_restart<'a>( + pending: Option<&'a CodexSwitchResult>, + switch_id: &str, + active: Option<&CodexAccount>, +) -> Result<&'a CodexSwitchResult, String> { + pending.filter(|result| { + result.switch_id.to_string() == switch_id + && result.ambient_account.as_ref().zip(active).is_some_and(|(expected, current)| expected.matches(current)) + }).ok_or_else(|| "The selected account changed after this restart prompt opened. Switch to the intended account again before restarting Codex Desktop.".into()) +} + /// 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> { let accounts = load_codex_accounts()?; persist_codex_accounts(&accounts)?; events::emit_settings_changed(&app); + accounts_changed(&app); Ok(()) } +fn accounts_changed(app: &tauri::AppHandle) { + events::emit_codex_accounts_updated(app); +} + fn into_user_message(error: CodexAccountManagerError) -> String { match error { CodexAccountManagerError::Message(msg) => msg, @@ -328,6 +456,181 @@ pub fn get_codex_accounts_state( mod tests { use super::*; + #[test] + fn committed_switch_tolerates_unreadable_account_metadata() { + use codexbar::codex_accounts::file_locations; + let root = std::env::temp_dir().join(format!("codex-switch-metadata-{}", Uuid::new_v4())); + std::fs::create_dir_all(&root).unwrap(); + file_locations::with_app_support_directory(root.clone()); + let account_file = file_locations::accounts_file(); + std::fs::write(&account_file, "invalid account metadata").unwrap(); + // This post-commit operation cannot propagate an error to the switch + // command and skip the refresh/events that follow it. + persist_materialized_account(Some(&sample_account())); + assert_eq!( + std::fs::read_to_string(&account_file).unwrap(), + "invalid account metadata" + ); + file_locations::clear_app_support_directory_override(); + assert!(root.starts_with(std::env::temp_dir())); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn removing_an_involved_account_revokes_the_pending_session_restart() { + let mut outgoing = sample_account(); + outgoing.provider_account_id = Some("outgoing".into()); + outgoing.codex_home_path = "/tmp/outgoing".into(); + let active = sample_account(); + let mut unrelated = sample_account(); + unrelated.provider_account_id = Some("unrelated".into()); + unrelated.codex_home_path = "/tmp/unrelated".into(); + let result = CodexSwitchResult { + switch_id: Uuid::new_v4(), + materialized_account: Some(outgoing.clone()), + ambient_account: Some(active.clone()), + backup_path: None, + desktop_session_backup_path: Some(outgoing.codex_home_path.join("desktop-session")), + desktop_session_restore_path: Some(active.codex_home_path.join("desktop-session")), + desktop_session_restore_exists: false, + }; + let mut pending = Some(result.clone()); + invalidate_restart_for_removed_account(&mut pending, &unrelated); + assert!(pending.is_some()); + for removed in [&outgoing, &active] { + let mut pending = Some(result.clone()); + invalidate_restart_for_removed_account(&mut pending, removed); + assert!(pending.is_none()); + assert!( + validate_pending_restart( + pending.as_ref(), + &result.switch_id.to_string(), + Some(&active) + ) + .is_err() + ); + } + } + + #[test] + fn superseded_lanes_cannot_overwrite_newer_snapshots() { + use codexbar::codex_accounts::{AccountUsageSnapshot, file_locations}; + let root = std::env::temp_dir().join(format!("codex-lane-generation-{}", Uuid::new_v4())); + std::fs::create_dir_all(&root).unwrap(); + file_locations::with_app_support_directory(root.clone()); + let mut state = AppState::new(); + let old_generation = state.provider_refresh_generation; + invalidate_account_usage(&mut state, ProviderId::Codex); + let id = Uuid::new_v4(); + let snapshot = AccountUsageSnapshot { + email: Some("new@example.com".into()), + provider_account_id: Some("new".into()), + plan: None, + allowed: None, + limit_reached: None, + primary_window: None, + secondary_window: None, + credits: None, + updated_at: codexbar::codex_accounts::utc_now(), + }; + assert!( + save_codex_lane_results( + &state, + state.provider_refresh_generation, + vec![(id, snapshot.clone())] + ) + .unwrap() + ); + let before = std::fs::read(file_locations::snapshots_file()).unwrap(); + let stale = AccountUsageSnapshot { + email: Some("old@example.com".into()), + ..snapshot + }; + assert!(!save_codex_lane_results(&state, old_generation, vec![(id, stale)]).unwrap()); + assert_eq!( + std::fs::read(file_locations::snapshots_file()).unwrap(), + before + ); + file_locations::clear_app_support_directory_override(); + assert!(root.starts_with(std::env::temp_dir())); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn restart_rejects_superseded_prompts_and_external_identity_changes() { + let active = sample_account(); + let pending = CodexSwitchResult { + switch_id: Uuid::new_v4(), + ambient_account: Some(active.clone()), + materialized_account: None, + backup_path: None, + desktop_session_backup_path: None, + desktop_session_restore_path: None, + desktop_session_restore_exists: false, + }; + let id = pending.switch_id.to_string(); + assert!(validate_pending_restart(Some(&pending), &id, Some(&active)).is_ok()); + assert!( + validate_pending_restart(Some(&pending), &Uuid::new_v4().to_string(), Some(&active)) + .is_err() + ); + let mut other = active; + other.provider_account_id = Some("different-account".into()); + assert!(validate_pending_restart(Some(&pending), &id, Some(&other)).is_err()); + assert!(validate_pending_restart(None, &id, Some(&other)).is_err()); + } + + #[test] + fn codex_switch_supersedes_inflight_usage_and_keeps_other_providers() { + let mut state = AppState::new(); + let other = invalidate_account_usage(&mut state, ProviderId::Claude); + let mut old = invalidate_account_usage(&mut state, ProviderId::Codex); + old.account_email = Some("old@example.com".into()); + old.primary.used_percent = 80.0; + old.error = None; + state.provider_cache = vec![other, old]; + state.is_refreshing = true; + let generation = state.provider_refresh_generation; + let pending = invalidate_account_usage(&mut state, ProviderId::Codex); + assert!(!is_current_provider_refresh_generation(&state, generation)); + assert!(!state.is_refreshing); + assert_eq!(state.provider_cache.len(), 2); + assert!( + state + .provider_cache + .iter() + .any(|s| s.provider_id == "claude") + ); + assert!(pending.account_email.is_none() && pending.error.is_some()); + assert_eq!(pending.primary.used_percent, 0.0); + } + + #[test] + fn reconciliation_replaces_changed_managed_identity_without_inheriting_metadata() { + let mut stale = sample_account(); + stale.nickname = Some("Former account".into()); + let mut fresh = sample_account(); + fresh.provider_account_id = Some("replacement".into()); + fresh.email_hint = Some("replacement@example.com".into()); + let accounts = reconcile_codex_accounts(&[stale.clone()], &[fresh.clone()], None); + assert_eq!(accounts.len(), 1); + assert_eq!(accounts[0].id, fresh.id); + assert_ne!(accounts[0].id, stale.id); + assert_eq!(accounts[0].nickname, None); + assert_eq!(accounts[0].email_hint, fresh.email_hint); + } + + #[test] + fn reconciliation_preserves_metadata_for_unchanged_managed_identity() { + let mut stored = sample_account(); + stored.nickname = Some("Work".into()); + let fresh = sample_account(); + let accounts = reconcile_codex_accounts(&[stored.clone()], &[fresh], None); + assert_eq!(accounts.len(), 1); + assert_eq!(accounts[0].id, stored.id); + assert_eq!(accounts[0].nickname, stored.nickname); + } + fn sample_account() -> CodexAccount { CodexAccount::new( Uuid::new_v4(), diff --git a/apps/desktop-tauri/src-tauri/src/commands/providers.rs b/apps/desktop-tauri/src-tauri/src/commands/providers.rs index 958d8002d3..4af69c24d5 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. @@ -391,7 +413,12 @@ fn spawn_provider_refreshes( let app_handle = app.clone(); let fetch_permits = Arc::clone(&fetch_permits); handles.push(tokio::spawn(async move { - super::codex_accounts::refresh_codex_account_lanes(app_handle, fetch_permits).await; + super::codex_accounts::refresh_codex_account_lanes( + app_handle, + fetch_permits, + generation, + ) + .await; })); } diff --git a/apps/desktop-tauri/src/lib/tauri.ts b/apps/desktop-tauri/src/lib/tauri.ts index d255a3815e..dc48d0f007 100644 --- a/apps/desktop-tauri/src/lib/tauri.ts +++ b/apps/desktop-tauri/src/lib/tauri.ts @@ -514,14 +514,10 @@ export function codexAccountSnapshots(): Promise< } export function codexAccountRestartDesktop( - sessionRoot?: string | null, - backupDestination?: string | null, - restoreSource?: string | null, + switchId: string, ): Promise { return invoke("codex_account_restart_desktop", { - sessionRoot, - backupDestination, - restoreSource, + switchId, }); } 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 38a2c39450..6e1c1e7a6f 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 @@ -93,6 +93,17 @@ describe("CodexAccountsSection", () => { }); }); + 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); + render(); + await screen.findByText("CodexAccountsSwitchButton"); + await act(async () => { screen.getByText("CodexAccountsSwitchButton").click(); }); + expect(screen.getByText("CodexSwitchSuccess")).toBeDefined(); + expect(screen.queryByText("CodexAccountsRestartDesktop")).toBeNull(); + expect(tauriMocks.codexAccountRestartDesktop).not.toHaveBeenCalled(); + }); + it("adds an account and reloads", async () => { tauriMocks.getCodexAccountsState.mockResolvedValueOnce( { accounts: [], snapshots: {} } as CodexAccountsStateBridge, @@ -115,12 +126,12 @@ describe("CodexAccountsSection", () => { expect(tauriMocks.codexAccountAdd).toHaveBeenCalledTimes(1); }); - it("switches an account and offers a desktop restart when a session can be restored", async () => { + it.each([true, false])("offers a desktop restart even for a first switch (saved session: %s)", async (restoreExists) => { tauriMocks.getCodexAccountsState.mockResolvedValue( { accounts: [account("1")], snapshots: {} } as CodexAccountsStateBridge, ); tauriMocks.codexAccountSwitch.mockResolvedValue( - { desktopSessionRestoreExists: true, desktopSessionRestorePath: "C:/s", desktopSessionBackupPath: null } as CodexSwitchResult, + { switchId: "latest-switch", desktopSessionRestoreExists: restoreExists, desktopSessionRestorePath: "C:/s", desktopSessionBackupPath: null } as CodexSwitchResult, ); render(); await waitFor(() => { @@ -139,6 +150,8 @@ describe("CodexAccountsSection", () => { screen.getByText("CodexAccountsRestartDesktop").click(); }); expect(tauriMocks.codexAccountRestartDesktop).toHaveBeenCalledTimes(1); + expect(tauriMocks.codexAccountRestartDesktop).toHaveBeenCalledWith("latest-switch"); + expect(screen.queryByText("CodexAccountsRestartDesktop")).toBeNull(); }); }); @@ -190,4 +203,4 @@ describe("CodexAccountsSection containment styles", () => { expect(actions).toContain("flex-shrink: 0"); expect(actions).toContain("nowrap"); }); -}); \ No newline at end of file +}); 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 b59332530f..4f3aa1d8b6 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 @@ -136,11 +136,8 @@ export function CodexAccountsSection({ t }: Props) { setBusy(true); setError(null); try { - await codexAccountRestartDesktop( - null, - switchResult.desktopSessionBackupPath ?? null, - switchResult.desktopSessionRestorePath ?? null, - ); + await codexAccountRestartDesktop(switchResult.switchId); + setSwitchResult(null); } catch (err: unknown) { setError(err instanceof Error ? err.message : String(err)); } finally { @@ -178,7 +175,7 @@ export function CodexAccountsSection({ t }: Props) { {switchResult && (
{t("CodexSwitchSuccess")} - {switchResult.desktopSessionRestoreExists && ( + {switchResult.desktopSessionRestorePath && ( <> {" "} {t("CodexSwitchRestartPrompt")}{" "} @@ -300,4 +297,4 @@ function CodexUsagePill({ {label || t("CodexAccountsUsageUnavailable")} ); -} \ No newline at end of file +} diff --git a/apps/desktop-tauri/src/types/bridge.ts b/apps/desktop-tauri/src/types/bridge.ts index 221732ab2b..19b2960e74 100644 --- a/apps/desktop-tauri/src/types/bridge.ts +++ b/apps/desktop-tauri/src/types/bridge.ts @@ -942,6 +942,7 @@ export interface CodexAccountUsageSnapshot { } export interface CodexSwitchResult { + switchId: string; materializedAccount: CodexAccount | null; backupPath: string | null; ambientAccount: CodexAccount | null; diff --git a/rust/src/codex_accounts/account_manager.rs b/rust/src/codex_accounts/account_manager.rs index 9cb16121c4..855320edcb 100644 --- a/rust/src/codex_accounts/account_manager.rs +++ b/rust/src/codex_accounts/account_manager.rs @@ -41,6 +41,7 @@ impl From for CodexAccountManagerError { #[derive(Debug, Clone, serde::Serialize)] #[serde(rename_all = "camelCase")] pub struct CodexSwitchResult { + pub switch_id: Uuid, pub materialized_account: Option, pub backup_path: Option, pub ambient_account: Option, @@ -190,6 +191,9 @@ impl CodexAccountManager { target: &CodexAccount, existing: &[CodexAccount], ) -> Result { + // Called by the shell's blocking worker: keep the guard here so + // cancelling its async caller cannot release it before the copy ends. + let _credentials = super::CREDENTIAL_OPERATIONS.blocking_write(); ensure_directories()?; let target_auth_path = target.codex_home_path.join("auth.json"); @@ -200,6 +204,21 @@ impl CodexAccountManager { } let ambient_account = self.discover_ambient_account(existing); + if ambient_account + .as_ref() + .is_some_and(|ambient| ambient.matches(target)) + { + // A no-op must not replace auth or restore/clear the live session. + return Ok(CodexSwitchResult { + switch_id: Uuid::new_v4(), + materialized_account: None, + backup_path: None, + ambient_account, + desktop_session_backup_path: None, + desktop_session_restore_path: None, + desktop_session_restore_exists: false, + }); + } let session_root = codex_desktop_session_root(); let mut materialized_account: Option = None; if let Some(ambient) = &ambient_account { @@ -233,6 +252,7 @@ impl CodexAccountManager { ); Ok(CodexSwitchResult { + switch_id: Uuid::new_v4(), materialized_account, backup_path, ambient_account: self.discover_ambient_account(existing), @@ -367,6 +387,14 @@ impl CodexAccountManager { account: &CodexAccount, ) -> Result, CodexAccountManagerError> { ensure_directories()?; + if self + .discovered_managed_account(&account.codex_home_path, &[]) + .is_some_and(|fresh| !fresh.matches(account)) + { + return Err(CodexAccountManagerError::Message( + "This managed home now belongs to a different account. Refresh the account list before removing it.".into(), + )); + } let mut targets: Vec = vec![ std::path::absolute(&account.codex_home_path) .unwrap_or_else(|_| account.codex_home_path.clone()), @@ -505,7 +533,7 @@ impl CodexAccountManager { } } -fn candidate_account( +pub(super) fn candidate_account( identity: AuthBackedIdentity, home_path: &Path, source: CodexAccountSource, @@ -702,6 +730,117 @@ mod tests { super::super::file_locations::clear_app_support_directory_override(); } + #[test] + fn removing_stale_account_preserves_replacement_credentials() { + let dir = tempfile::tempdir().unwrap(); + super::super::file_locations::with_app_support_directory(dir.path().to_path_buf()); + let home = dir.path().join("managed-homes/replaced"); + std::fs::create_dir_all(&home).unwrap(); + let stale = make_account(home.clone(), "old@example.com", "old-id"); + write_auth(&home, "new@example.com", "new-id"); + let before = std::fs::read(home.join("auth.json")).unwrap(); + assert!( + CodexAccountManager::new() + .remove_managed_files_if_owned(&stale) + .is_err() + ); + assert_eq!(std::fs::read(home.join("auth.json")).unwrap(), before); + super::super::file_locations::clear_app_support_directory_override(); + } + + #[test] + fn same_identity_switch_preserves_auth_and_has_no_session_restore() { + let dir = tempfile::tempdir().unwrap(); + let ambient = dir.path().join("ambient"); + let saved = dir.path().join("managed-homes/saved"); + let session = dir.path().join("session"); + for path in [&ambient, &saved, &session] { + fs::create_dir_all(path).unwrap(); + } + write_auth(&ambient, "same@example.com", "same-id"); + write_auth(&saved, "same@example.com", "same-id"); + fs::write(session.join("Preferences"), "current-session").unwrap(); + let auth_before = fs::read(ambient.join("auth.json")).unwrap(); + super::super::file_locations::with_app_support_directory(dir.path().to_path_buf()); + super::super::file_locations::with_ambient_codex_home(ambient.clone()); + super::super::file_locations::with_codex_desktop_session_root(session.clone()); + for home in [ambient.clone(), saved] { + let target = make_account(home, "same@example.com", "same-id"); + let result = CodexAccountManager::new() + .switch_active_account(&target, &[]) + .unwrap(); + assert!(result.backup_path.is_none()); + assert!(result.materialized_account.is_none()); + assert!(result.desktop_session_backup_path.is_none()); + assert!(result.desktop_session_restore_path.is_none()); + assert_eq!(fs::read(ambient.join("auth.json")).unwrap(), auth_before); + assert_eq!( + fs::read_to_string(session.join("Preferences")).unwrap(), + "current-session" + ); + } + super::super::file_locations::clear_app_support_directory_override(); + super::super::file_locations::clear_ambient_codex_home_override(); + super::super::file_locations::clear_codex_desktop_session_root_override(); + } + + #[test] + fn switch_waits_for_credential_refresh_before_materializing_outgoing_auth() { + let dir = tempfile::tempdir().unwrap(); + let ambient = dir.path().join("ambient"); + let saved = dir.path().join("managed-homes/target"); + fs::create_dir_all(&ambient).unwrap(); + fs::create_dir_all(&saved).unwrap(); + write_auth(&ambient, "old@example.com", "old-id"); + write_auth(&saved, "new@example.com", "new-id"); + let refresh_guard = super::super::CREDENTIAL_OPERATIONS.blocking_read(); + let (ready_tx, ready_rx) = std::sync::mpsc::channel(); + let (done_tx, done_rx) = std::sync::mpsc::channel(); + let root = dir.path().to_path_buf(); + let worker_ambient = ambient.clone(); + let worker = std::thread::spawn(move || { + super::super::file_locations::with_app_support_directory(root.clone()); + super::super::file_locations::with_ambient_codex_home(worker_ambient); + super::super::file_locations::with_codex_desktop_session_root(root.join("session")); + let target = make_account(saved, "new@example.com", "new-id"); + ready_tx.send(()).unwrap(); + let result = CodexAccountManager::new().switch_active_account(&target, &[]); + done_tx.send(result).unwrap(); + }); + ready_rx.recv_timeout(Duration::from_secs(5)).unwrap(); + assert!(matches!( + done_rx.recv_timeout(Duration::from_millis(100)), + Err(std::sync::mpsc::RecvTimeoutError::Timeout) + )); + let mut refreshed: serde_json::Value = + serde_json::from_slice(&fs::read(ambient.join("auth.json")).unwrap()).unwrap(); + refreshed["tokens"]["access_token"] = serde_json::json!("rotated-old-token"); + fs::write( + ambient.join("auth.json"), + serde_json::to_vec(&refreshed).unwrap(), + ) + .unwrap(); + drop(refresh_guard); + let result = done_rx + .recv_timeout(Duration::from_secs(5)) + .unwrap() + .unwrap(); + worker.join().unwrap(); + let materialized = result.materialized_account.unwrap(); + let outgoing: serde_json::Value = serde_json::from_slice( + &fs::read(materialized.codex_home_path.join("auth.json")).unwrap(), + ) + .unwrap(); + assert_eq!(outgoing["tokens"]["access_token"], "rotated-old-token"); + assert_eq!( + load_identity(&ambient) + .unwrap() + .provider_account_id + .as_deref(), + Some("new-id") + ); + } + #[test] fn switch_active_account_updates_global_state_creator_id() { let dir = tempfile::tempdir().unwrap(); diff --git a/rust/src/codex_accounts/api.rs b/rust/src/codex_accounts/api.rs index 5482dfa323..2807fbdfb5 100644 --- a/rust/src/codex_accounts/api.rs +++ b/rust/src/codex_accounts/api.rs @@ -4,7 +4,9 @@ //! refreshes tokens via the OpenAI OAuth endpoint, fetches `wham/usage` (or a //! configured custom base URL) and normalizes the quota windows. -use std::path::Path; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, LazyLock, Mutex, Weak}; use base64::Engine; use chrono::{DateTime, Utc}; @@ -21,6 +23,28 @@ pub const REFRESH_CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann"; const REQUEST_TIMEOUT_SECONDS: u64 = 30; const UNAUTHORIZED_MESSAGE: &str = "The Codex usage API request returned unauthorized."; +type CredentialLane = tokio::sync::Mutex<()>; +static CREDENTIAL_LANES: LazyLock>>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +fn credential_lane(home: &Path) -> Result, CodexApiError> { + let path = home.join("auth.json").canonicalize().map_err(|error| { + CodexApiError::Message(format!( + "Could not resolve the account's auth file: {error}" + )) + })?; + let mut lanes = CREDENTIAL_LANES + .lock() + .map_err(|error| CodexApiError::Message(error.to_string()))?; + lanes.retain(|_, lane| lane.strong_count() > 0); + if let Some(lane) = lanes.get(&path).and_then(Weak::upgrade) { + return Ok(lane); + } + let lane = Arc::new(CredentialLane::new(())); + lanes.insert(path, Arc::downgrade(&lane)); + Ok(lane) +} + /// Friendly error surfaced to callers. #[derive(Debug, Error)] pub enum CodexApiError { @@ -178,7 +202,71 @@ pub fn save_credentials( serde_json::json!(Utc::now().to_rfc3339_opts(chrono::SecondsFormat::AutoSi, true)), ); } - std::fs::write(&auth_path, serde_json::to_vec_pretty(&payload)?) + write_auth_contents(codex_home_path, &serde_json::to_vec_pretty(&payload)?) +} + +fn write_auth_contents(home: &Path, contents: &[u8]) -> std::io::Result<()> { + use std::io::Write; + let auth_path = home.join("auth.json"); + // Preserve an existing auth-file symlink by replacing its resolved target. + let destination = auth_path.canonicalize().unwrap_or(auth_path); + let staged = destination.with_file_name(format!(".auth-{}.tmp", uuid::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 mut file = options.open(&staged)?; + let written = file.write_all(contents); + drop(file); + let result = written.and_then(|()| std::fs::rename(&staged, &destination)); + if result.is_err() { + let _cleanup = std::fs::remove_file(staged); + } + result +} + +fn synchronize_active_copy(ambient_home: &Path, managed_home: &Path) -> std::io::Result<()> { + let ambient_path = ambient_home.join("auth.json").canonicalize()?; + let managed_path = managed_home.join("auth.json").canonicalize()?; + if ambient_path == managed_path { + return Ok(()); + } + let ambient_json = std::fs::read_to_string(ambient_path)?; + let managed_json = std::fs::read_to_string(managed_path)?; + if ambient_json == managed_json { + return Ok(()); + } + let ambient = parse_credentials_json(&ambient_json).map_err(std::io::Error::other)?; + let managed = parse_credentials_json(&managed_json).map_err(std::io::Error::other)?; + let account = |credentials: &AuthCredentials, home: &Path, source| { + super::account_manager::candidate_account( + identity_from_credentials(credentials), + home, + source, + ) + }; + if !account( + &ambient, + ambient_home, + super::models::CodexAccountSource::Ambient, + ) + .matches(&account( + &managed, + managed_home, + super::models::CodexAccountSource::ManagedByApp, + )) { + return Ok(()); + } + // Older builds may already have refreshed only the managed copy. Recover + // its newer chain before making the ambient home authoritative for fetches. + if managed.last_refresh > ambient.last_refresh { + write_auth_contents(ambient_home, managed_json.as_bytes()) + } else { + write_auth_contents(managed_home, ambient_json.as_bytes()) + } } fn identity_from_credentials(credentials: &AuthCredentials) -> AuthBackedIdentity { @@ -281,6 +369,64 @@ impl CodexAccountApi { codex_home_path: &Path, email_hint: Option<&str>, verify_live_data: bool, + ) -> Result { + let _credentials = super::CREDENTIAL_OPERATIONS.read().await; + let target = super::account_manager::candidate_account( + load_identity(codex_home_path)?, + codex_home_path, + super::models::CodexAccountSource::ManagedByApp, + ); + let ambient = super::CodexAccountManager::new().discover_ambient_account(&[]); + if let Some(ambient) = ambient.filter(|ambient| ambient.matches(&target)) { + // The Desktop/CLI consumes the ambient token chain. All fetches + // for its managed copies must share that lane and refresh it first. + self.fetch_home_snapshot( + &ambient.codex_home_path, + email_hint, + verify_live_data, + Some(codex_home_path), + ) + .await + } else { + self.fetch_home_snapshot(codex_home_path, email_hint, verify_live_data, None) + .await + } + } + + async fn fetch_home_snapshot( + &self, + codex_home_path: &Path, + email_hint: Option<&str>, + verify_live_data: bool, + managed_copy: Option<&Path>, + ) -> Result { + // Read only after earlier fetches for this auth path have persisted any + // rotated tokens. Distinct homes retain independent fetch lanes. + let _home = credential_lane(codex_home_path)?.lock_owned().await; + let synchronize = || { + if let Some(managed) = managed_copy + && let Err(error) = synchronize_active_copy(codex_home_path, managed) + { + tracing::warn!( + "Could not synchronize the active Codex account's managed credentials: {error}" + ); + } + }; + synchronize(); + let result = self + .fetch_locked_snapshot(codex_home_path, email_hint, verify_live_data) + .await; + // A refresh can have rotated credentials even when the usage request + // fails. Synchronize before releasing the shared ambient lane. + synchronize(); + result + } + + async fn fetch_locked_snapshot( + &self, + codex_home_path: &Path, + email_hint: Option<&str>, + verify_live_data: bool, ) -> Result { let mut credentials = load_credentials(codex_home_path)?; @@ -753,6 +899,200 @@ fn credits_equivalent( mod tests { use super::*; + #[tokio::test] + async fn active_fetches_use_and_sync_ambient_credentials_even_when_usage_fails() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; + use tokio::time::{Duration, timeout}; + + for (target_id, managed_newer, expected_token) in [ + ("active", false, "ambient-token"), + ("active", true, "managed-token"), + ("other", true, "managed-token"), + ] { + let ambient = tempfile::tempdir().unwrap(); + let managed = tempfile::tempdir().unwrap(); + let credentials = |account: &str, token: &str| AuthCredentials { + access_token: token.into(), + refresh_token: format!("refresh-{token}"), + id_token: None, + account_id: Some(account.into()), + last_refresh: Some(Utc::now()), + }; + save_credentials(ambient.path(), &credentials("active", "ambient-token")).unwrap(); + save_credentials(managed.path(), &credentials(target_id, "managed-token")).unwrap(); + let now = Utc::now(); + for (home, refreshed) in [ + (ambient.path(), now - chrono::TimeDelta::hours(1)), + ( + managed.path(), + if managed_newer { + now + } else { + now - chrono::TimeDelta::hours(2) + }, + ), + ] { + let path = home.join("auth.json"); + let mut json: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + json["last_refresh"] = serde_json::json!(refreshed.to_rfc3339()); + std::fs::write(path, json.to_string()).unwrap(); + } + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let config = format!( + "chatgpt_base_url = \"http://{}\"\n", + listener.local_addr().unwrap() + ); + for home in [ambient.path(), managed.path()] { + std::fs::write(home.join("config.toml"), &config).unwrap(); + } + super::super::file_locations::with_ambient_codex_home(ambient.path().to_owned()); + let api = CodexAccountApi { + client: reqwest::Client::builder().no_proxy().build().unwrap(), + }; + let server = async { + let (mut stream, _) = timeout(Duration::from_secs(5), listener.accept()) + .await + .unwrap() + .unwrap(); + let mut headers = Vec::new(); + while !headers.ends_with(b"\r\n\r\n") { + headers.push( + timeout(Duration::from_secs(5), stream.read_u8()) + .await + .unwrap() + .unwrap(), + ); + } + stream.write_all(b"HTTP/1.1 500 Internal Server Error\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}").await.unwrap(); + String::from_utf8(headers).unwrap().to_lowercase() + }; + let (result, headers) = + tokio::join!(api.fetch_snapshot(managed.path(), None, false), server); + super::super::file_locations::clear_ambient_codex_home_override(); + assert!(result.is_err()); + assert!(headers.contains(&format!("authorization: bearer {expected_token}"))); + let saved = load_credentials(managed.path()).unwrap(); + assert_eq!(saved.access_token, expected_token); + assert_eq!(saved.refresh_token, format!("refresh-{expected_token}")); + assert_eq!( + load_credentials(ambient.path()).unwrap().access_token, + if target_id == "active" { + expected_token + } else { + "ambient-token" + } + ); + } + } + + #[tokio::test] + async fn overlapping_fetches_reload_credentials_and_keep_other_homes_parallel() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::{TcpListener, TcpStream}; + use tokio::time::{Duration, timeout}; + + async fn request(listener: &TcpListener) -> (TcpStream, String) { + let (mut stream, _) = timeout(Duration::from_secs(5), listener.accept()) + .await + .unwrap() + .unwrap(); + let mut headers = Vec::new(); + while !headers.ends_with(b"\r\n\r\n") { + headers.push( + timeout(Duration::from_secs(5), stream.read_u8()) + .await + .unwrap() + .unwrap(), + ); + } + (stream, String::from_utf8(headers).unwrap().to_lowercase()) + } + async fn respond(mut stream: TcpStream) { + stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}").await.unwrap(); + } + fn configure(home: &Path, address: std::net::SocketAddr, token: &str) { + std::fs::write( + home.join("config.toml"), + format!("chatgpt_base_url = \"http://{address}\"\n"), + ) + .unwrap(); + std::fs::write( + home.join("auth.json"), + serde_json::json!({"OPENAI_API_KEY":token}).to_string(), + ) + .unwrap(); + } + fn fetch( + home: PathBuf, + ) -> tokio::task::JoinHandle> { + tokio::spawn(async move { + let api = CodexAccountApi { + client: reqwest::Client::builder().no_proxy().build().unwrap(), + }; + // Exercise per-home concurrency independently of other tests + // that intentionally take the global account-switch write lock. + api.fetch_home_snapshot(&home, None, false, None).await + }) + } + + let first_home = tempfile::tempdir().unwrap(); + let other_home = tempfile::tempdir().unwrap(); + let first_server = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let other_server = TcpListener::bind("127.0.0.1:0").await.unwrap(); + configure( + first_home.path(), + first_server.local_addr().unwrap(), + "old-token", + ); + configure( + other_home.path(), + other_server.local_addr().unwrap(), + "other-token", + ); + let first = fetch(first_home.path().to_owned()); + let (first_stream, headers) = request(&first_server).await; + assert!(headers.contains("authorization: bearer old-token")); + // A lexical alias of the same auth path must share the first lane. + let second = fetch(first_home.path().join(".")); + let other = fetch(other_home.path().to_owned()); + let (other_stream, headers) = request(&other_server).await; + assert!(headers.contains("authorization: bearer other-token")); + respond(other_stream).await; + timeout(Duration::from_secs(5), other) + .await + .unwrap() + .unwrap() + .unwrap(); + assert!( + timeout(Duration::from_millis(100), first_server.accept()) + .await + .is_err() + ); + + // Model a rotated token being persisted by the first in-flight fetch. + configure( + first_home.path(), + first_server.local_addr().unwrap(), + "rotated-token", + ); + respond(first_stream).await; + timeout(Duration::from_secs(5), first) + .await + .unwrap() + .unwrap() + .unwrap(); + let (second_stream, headers) = request(&first_server).await; + assert!(headers.contains("authorization: bearer rotated-token")); + respond(second_stream).await; + timeout(Duration::from_secs(5), second) + .await + .unwrap() + .unwrap() + .unwrap(); + } + #[test] fn parse_credentials_accepts_api_key() { let creds = parse_credentials_json(r#"{"OPENAI_API_KEY":"sk-test"}"#).unwrap(); diff --git a/rust/src/codex_accounts/codex_desktop.rs b/rust/src/codex_accounts/codex_desktop.rs index a483250fb4..10eeedcf5c 100644 --- a/rust/src/codex_accounts/codex_desktop.rs +++ b/rust/src/codex_accounts/codex_desktop.rs @@ -64,6 +64,10 @@ $backupDestination = {backup_destination_literal} $restoreSource = {restore_source_literal} $sessionEntries = {session_entries_literal} New-Item -ItemType Directory -Path ([System.IO.Path]::GetDirectoryName($logPath)) -Force | Out-Null +trap {{ + Add-Content -LiteralPath $logPath -Value ("Restart failed: " + $_.Exception.Message) + exit 1 +}} function Write-Log([string]$message) {{ Add-Content -LiteralPath $logPath -Value ("[{{0}}] {{1}}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff'), $message) }} @@ -71,7 +75,11 @@ function Clear-SessionEntry([string]$root, [string]$relativePath) {{ if (-not $root) {{ return }} - $targetPath = Join-Path $root $relativePath + $resolvedRoot = [System.IO.Path]::GetFullPath($root).TrimEnd('\') + '\' + $targetPath = [System.IO.Path]::GetFullPath((Join-Path $resolvedRoot $relativePath)) + if (-not $targetPath.StartsWith($resolvedRoot, [StringComparison]::OrdinalIgnoreCase)) {{ + throw 'Session entry escaped its root.' + }} if (Test-Path -LiteralPath $targetPath) {{ Remove-Item -LiteralPath $targetPath -Recurse -Force -ErrorAction Stop }} @@ -117,14 +125,14 @@ function Sync-DesktopSessionState() {{ Write-Log ("Backed up session entry: " + $relativePath) }} catch {{ Write-Log ("Failed to back up session entry " + $relativePath + ": " + $_.Exception.Message) + throw }} }} Write-Log ("Backed up desktop session state to " + $backupDestination) }} if ($restoreSource) {{ if (-not (Test-Path -LiteralPath $restoreSource)) {{ - Write-Log ("Restore source is missing; leaving the current desktop session in place: " + $restoreSource) - return + Write-Log ("No saved session for target; clearing the previous desktop session: " + $restoreSource) }} foreach ($relativePath in $sessionEntries) {{ try {{ @@ -133,81 +141,51 @@ function Sync-DesktopSessionState() {{ Write-Log ("Restored session entry: " + $relativePath) }} catch {{ Write-Log ("Failed to restore session entry " + $relativePath + ": " + $_.Exception.Message) + throw }} }} Write-Log ("Restored desktop session state from " + $restoreSource) }} }} Write-Log 'Restart requested.' -$mainProcess = Get-CimInstance Win32_Process | Where-Object {{ - $_.Name -eq 'Codex.exe' -and - $_.ExecutablePath -and - $_.ExecutablePath -notlike '*\resources\codex.exe' -and - $_.CommandLine -notmatch '--type=' -}} | Select-Object -First 1 -$launcherPath = $mainProcess.ExecutablePath -if ($launcherPath) {{ - Write-Log ("Using running launcher path: " + $launcherPath) -}} -if (-not $launcherPath) {{ - $package = Get-AppxPackage | Where-Object {{ - $_.Name -eq 'OpenAI.Codex' -or $_.PackageFamilyName -like 'OpenAI.Codex*' - }} | Sort-Object Version -Descending | Select-Object -First 1 - if ($package -and $package.InstallLocation) {{ - $launcherPath = Join-Path $package.InstallLocation 'app\Codex.exe' - Write-Log ("Using package launcher path: " + $launcherPath) - }} +$package = Get-AppxPackage -Name OpenAI.Codex | Sort-Object Version -Descending | Select-Object -First 1 +if (-not $package -or -not $package.InstallLocation) {{ + throw 'Unable to locate the Codex Desktop package.' }} -if (-not $launcherPath) {{ - Write-Log 'Unable to locate the Codex Desktop executable.' +$packageRoot = [System.IO.Path]::GetFullPath($package.InstallLocation).TrimEnd('\') + '\' +[xml]$manifest = Get-Content -LiteralPath (Join-Path $packageRoot 'AppxManifest.xml') +$application = $manifest.Package.Applications.Application | Select-Object -First 1 +$launcherPath = [System.IO.Path]::GetFullPath((Join-Path $packageRoot $application.Executable)) +if (-not $launcherPath.StartsWith($packageRoot, [StringComparison]::OrdinalIgnoreCase) -or + -not (Test-Path -LiteralPath $launcherPath -PathType Leaf)) {{ throw 'Unable to locate the Codex Desktop executable.' }} +Write-Log ("Using package launcher path: " + $launcherPath) Start-Sleep -Milliseconds {delay_ms} +# Do not kill process trees: this helper can descend from a Codex task. +# Stop only this package's GUI executable, including its renderer processes. +# Standalone codex.exe processes and the restart helper must stay running. $codexProcesses = Get-CimInstance Win32_Process | Where-Object {{ - $_.Name -ieq 'Codex.exe' -or - $_.ExecutablePath -like '*\OpenAI.Codex_*\app\Codex.exe' -or - $_.ExecutablePath -like '*\OpenAI.Codex_*\app\resources\codex.exe' + $_.ExecutablePath -and $_.ExecutablePath -ieq $launcherPath }} -Write-Log ("Found " + $codexProcesses.Count + " Codex processes to stop.") -$codexProcesses | ForEach-Object {{ - try {{ - & taskkill.exe /PID $_.ProcessId /F /T | Out-Null - Write-Log ("taskkill succeeded for PID " + $_.ProcessId) - }} catch {{ - Write-Log ("taskkill failed for PID " + $_.ProcessId + ": " + $_.Exception.Message) - }} - try {{ - Stop-Process -Id $_.ProcessId -Force -ErrorAction Stop - Write-Log ("Stop-Process succeeded for PID " + $_.ProcessId) - }} catch {{ - Write-Log ("Stop-Process failed for PID " + $_.ProcessId + ": " + $_.Exception.Message) +foreach ($process in $codexProcesses) {{ + if (-not (Get-Process -Id $process.ProcessId -ErrorAction SilentlyContinue)) {{ continue }} + Write-Log ("Stopping Desktop GUI process " + $process.ProcessId) + & taskkill.exe /PID $process.ProcessId /F 2>&1 | Out-Null + if ($LASTEXITCODE -ne 0 -and (Get-Process -Id $process.ProcessId -ErrorAction SilentlyContinue)) {{ + throw 'Unable to stop Codex Desktop. Session files were left unchanged.' }} }} $deadline = (Get-Date).AddSeconds(8) -while ((Get-Date) -lt $deadline) {{ +do {{ $remaining = Get-CimInstance Win32_Process | Where-Object {{ - $_.Name -ieq 'Codex.exe' -or - $_.ExecutablePath -like '*\OpenAI.Codex_*\app\Codex.exe' -or - $_.ExecutablePath -like '*\OpenAI.Codex_*\app\resources\codex.exe' - }} - if (-not $remaining) {{ - Write-Log 'All Codex processes exited.' - break - }} - Write-Log ("Still waiting for " + $remaining.Count + " Codex processes to exit.") - $remaining | ForEach-Object {{ - try {{ - & taskkill.exe /PID $_.ProcessId /F /T | Out-Null - }} catch {{}} + $_.ExecutablePath -and $_.ExecutablePath -ieq $launcherPath }} + if (-not $remaining) {{ break }} Start-Sleep -Milliseconds 250 -}} -if (Get-CimInstance Win32_Process | Where-Object {{ - $_.Name -ieq 'Codex.exe' -or - $_.ExecutablePath -like '*\OpenAI.Codex_*\app\Codex.exe' -or - $_.ExecutablePath -like '*\OpenAI.Codex_*\app\resources\codex.exe' -}}) {{ - Write-Log 'Continuing with relaunch after timeout while some Codex processes still appear alive.' +}} while ((Get-Date) -lt $deadline) +if ($remaining) {{ + throw 'Codex Desktop is still running. Session files were left unchanged.' }} Sync-DesktopSessionState Start-Sleep -Milliseconds 700 @@ -251,7 +229,7 @@ pub fn build_restart_command(script_path: &Path) -> Vec { ] } -/// Write the restart script and launch a hidden PowerShell that runs it. +/// Run the hidden restart script and report failures to the invoking surface. pub fn restart_codex_desktop( delay_seconds: f64, session_root: Option<&Path>, @@ -289,9 +267,16 @@ fn launch_hidden_powershell(script_path: &Path) -> Result<(), CodexDesktopContro const CREATE_NO_WINDOW: u32 = 0x0800_0000; const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200; command.creation_flags(CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP); - command.spawn().map(|_| ()).map_err(|error| { + let status = command.status().map_err(|error| { CodexDesktopControlError::Message(format!("Failed to restart Codex Desktop: {error}")) - }) + })?; + if !status.success() { + return Err(CodexDesktopControlError::Message(format!( + "Codex Desktop could not restart. Its session backup or restore may have failed. See {} for details.", + restart_log_path().display(), + ))); + } + Ok(()) } #[cfg(not(windows))] @@ -330,6 +315,99 @@ fn powershell_string_array(values: &[&str]) -> String { mod tests { use super::*; + #[cfg(windows)] + #[test] + fn hidden_script_failure_is_returned_to_the_caller() { + let dir = tempfile::tempdir().unwrap(); + let script = dir.path().join("failure.ps1"); + std::fs::write(&script, "throw 'Simulated session copy failure'").unwrap(); + assert!( + launch_hidden_powershell(&script) + .unwrap_err() + .to_string() + .contains("could not restart") + ); + } + + #[cfg(windows)] + #[test] + fn restart_uses_manifest_gui_and_leaves_standalone_cli_alone() { + let dir = tempfile::tempdir().unwrap(); + super::super::file_locations::with_app_support_directory(dir.path().join("support")); + let package = dir.path().join("package"); + std::fs::create_dir_all(package.join("app")).unwrap(); + std::fs::write(package.join("app/ChatGPT.exe"), b"fixture").unwrap(); + std::fs::write(package.join("AppxManifest.xml"), + r#""#).unwrap(); + let session = dir.path().join("session"); + let backup = dir.path().join("backup"); + std::fs::create_dir_all(session.join("Local Storage")).unwrap(); + std::fs::write(session.join("Local Storage/old-account"), b"old session").unwrap(); + std::fs::write(session.join("Preferences"), b"old preferences").unwrap(); + std::fs::write(session.join("unrelated-file"), b"preserve").unwrap(); + let script = format!( + r#" +$fixturePackage = {package} +$script:stopped = $false +function Get-AppxPackage {{ [pscustomobject]@{{ InstallLocation = $fixturePackage; Version = '1.0' }} }} +function Get-CimInstance {{ + [pscustomobject]@{{ ExecutablePath = 'C:\standalone\codex.exe'; ProcessId = 987654320; Name = 'codex.exe'; CommandLine = 'codex app-server' }} + if (-not $script:stopped) {{ + [pscustomobject]@{{ ExecutablePath = (Join-Path $fixturePackage 'app\ChatGPT.exe'); ProcessId = 987654321; Name = 'ChatGPT.exe'; CommandLine = 'ChatGPT.exe' }} + }} +}} +function taskkill.exe {{ + if ($args -contains '/T') {{ throw 'Process-tree shutdown would kill the restart helper' }} + if ($args[1] -ne 987654321) {{ throw 'Attempted to stop standalone CLI' }} + $script:stopped = $true + $global:LASTEXITCODE = 0 +}} +function Get-Process {{ param($Id) [pscustomobject]@{{ Id = $Id }} }} +function Start-Sleep {{}} +function Start-Process {{ param($FilePath) + if ($FilePath -ne (Join-Path $fixturePackage 'app\ChatGPT.exe')) {{ throw 'Wrong launcher' }} + $script:launched = $true +}} +{restart} +if (-not $script:stopped -or -not $script:launched) {{ throw 'Restart did not complete' }} +"#, + package = powershell_literal_path(&package), + restart = build_restart_script( + 0.0, + Some(&session), + Some(&backup), + Some(&dir.path().join("missing-target")) + ) + ); + super::super::file_locations::clear_app_support_directory_override(); + let path = dir.path().join("test-restart.ps1"); + std::fs::write(&path, script).unwrap(); + let argv = build_restart_command(&path); + let output = std::process::Command::new(&argv[0]) + .args(&argv[1..]) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(!session.join("Local Storage").exists()); + assert!(!session.join("Preferences").exists()); + assert_eq!( + std::fs::read(backup.join("Local Storage/old-account")).unwrap(), + b"old session" + ); + assert_eq!( + std::fs::read(backup.join("Preferences")).unwrap(), + b"old preferences" + ); + assert_eq!( + std::fs::read(session.join("unrelated-file")).unwrap(), + b"preserve" + ); + } + #[test] fn build_restart_script_includes_restart_flow() { let script = build_restart_script(1.25, None, None, None); @@ -337,7 +415,9 @@ mod tests { assert!(script.contains("Get-CimInstance Win32_Process")); assert!(script.contains("Get-AppxPackage")); assert!(script.contains("taskkill.exe /PID")); - assert!(script.contains("Stop-Process -Id $_.ProcessId -Force")); + assert!(script.contains("$manifest.Package.Applications.Application")); + assert!(script.contains("$_.ExecutablePath -ieq $launcherPath")); + assert!(!script.contains("$_.Name -ieq 'Codex.exe'")); assert!(script.contains("Start-Process -FilePath $launcherPath")); assert!(script.contains("Start-Sleep -Milliseconds 1250")); } @@ -371,9 +451,7 @@ mod tests { assert!(script.contains("Copy-SessionEntry $restoreSource $sessionRoot $relativePath")); assert!(script.contains("Clear-SessionEntry $sessionRoot $relativePath")); assert!( - script.contains( - "Restore source is missing; leaving the current desktop session in place" - ) + script.contains("No saved session for target; clearing the previous desktop session") ); assert!(script.contains("Failed to back up session entry")); assert!(script.contains("Failed to restore session entry")); diff --git a/rust/src/codex_accounts/mod.rs b/rust/src/codex_accounts/mod.rs index 494e6e75b3..38c1066aa7 100644 --- a/rust/src/codex_accounts/mod.rs +++ b/rust/src/codex_accounts/mod.rs @@ -19,6 +19,11 @@ pub mod login_runner; pub mod models; pub mod stores; +// Refreshes may rotate auth.json. Keep account replacement exclusive with +// those reads/writes while allowing different account lanes to fetch together. +pub(crate) static CREDENTIAL_OPERATIONS: tokio::sync::RwLock<()> = + tokio::sync::RwLock::const_new(()); + pub use account_manager::{CodexAccountManager, CodexAccountManagerError, CodexSwitchResult}; pub use api::{AuthBackedIdentity, AuthCredentials, CodexAccountApi, CodexApiError, load_identity}; pub use codex_desktop::{ diff --git a/rust/src/codex_accounts/models.rs b/rust/src/codex_accounts/models.rs index 11021adbaa..f367bff70c 100644 --- a/rust/src/codex_accounts/models.rs +++ b/rust/src/codex_accounts/models.rs @@ -185,9 +185,6 @@ impl CodexAccount { /// Whether two accounts refer to the same identity. pub fn matches(&self, other: &CodexAccount) -> bool { - if self.standardized_home_path() == other.standardized_home_path() { - return true; - } if let (Some(a), Some(b)) = ( self.normalized_provider_account_id(), other.normalized_provider_account_id(), @@ -203,16 +200,18 @@ impl CodexAccount { if let (Some(a), Some(b)) = ( self.normalized_auth_subject(), other.normalized_auth_subject(), - ) && a == b - { - return true; + ) { + return a == b; } - if let (Some(a), Some(b)) = (self.normalized_email_hint(), other.normalized_email_hint()) - && a == b - { - return true; + if let (Some(a), Some(b)) = (self.normalized_email_hint(), other.normalized_email_hint()) { + return a == b; } - false + // Path fallback is only safe when neither record identifies its owner. + self.normalized_auth_subject().is_none() + && other.normalized_auth_subject().is_none() + && self.normalized_email_hint().is_none() + && other.normalized_email_hint().is_none() + && self.standardized_home_path() == other.standardized_home_path() } /// Merge a fresher discovery into this account, preferring managed/recency. @@ -547,6 +546,36 @@ mod tests { Some("acct-2"), ); assert!(!a.matches(&b)); + let mut same_home = b.clone(); + same_home.codex_home_path = a.codex_home_path.clone(); + assert!( + !a.matches(&same_home), + "switching auth.json changes the identity at the same home" + ); + } + + #[test] + fn different_fallback_identities_do_not_match_the_same_home() { + let mut a = account( + "11111111-1111-1111-1111-111111111111", + "/x/a", + CodexAccountSource::ManagedByApp, + None, + ); + let mut b = a.clone(); + a.email_hint = Some("old@example.com".into()); + b.email_hint = Some("new@example.com".into()); + assert!(!a.matches(&b)); + b.email_hint = a.email_hint.clone(); + assert!(a.matches(&b)); + a.auth_subject = Some("old-subject".into()); + b.auth_subject = Some("new-subject".into()); + assert!(!a.matches(&b)); + b.auth_subject = a.auth_subject.clone(); + assert!(a.matches(&b)); + b.auth_subject = None; + b.email_hint = None; + assert!(!a.matches(&b)); } #[test] From f647fcb1b1d4924475458c2f20a4ff7d39af81fc Mon Sep 17 00:00:00 2001 From: NessZerra <90105158+Finesssee@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:14:41 +0700 Subject: [PATCH 2/2] Refactor Codex account coordination --- .../src-tauri/src/commands/codex_accounts.rs | 140 +------ rust/src/codex_accounts/account_manager.rs | 3 +- rust/src/codex_accounts/api.rs | 388 +----------------- rust/src/codex_accounts/credentials.rs | 256 ++++++++++++ rust/src/codex_accounts/fetch_coordination.rs | 136 ++++++ rust/src/codex_accounts/mod.rs | 7 +- rust/src/codex_accounts/switch_runtime.rs | 203 +++++++++ 7 files changed, 643 insertions(+), 490 deletions(-) create mode 100644 rust/src/codex_accounts/credentials.rs create mode 100644 rust/src/codex_accounts/fetch_coordination.rs create mode 100644 rust/src/codex_accounts/switch_runtime.rs 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 54065088e4..8c2d181698 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/codex_accounts.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/codex_accounts.rs @@ -5,7 +5,7 @@ use uuid::Uuid; use codexbar::codex_accounts::{ AccountStore, CodexAccount, CodexAccountApi, CodexAccountManager, CodexAccountManagerError, - CodexApiError, CodexSwitchResult, SnapshotStore, restart_codex_desktop, + CodexAccountRuntime, CodexApiError, CodexSwitchResult, SnapshotStore, restart_codex_desktop, }; use crate::state::AppState; @@ -15,8 +15,6 @@ use super::*; // ── Codex multi-account (ADR 0003, milestone 2) ────────────────────── const DEFAULT_FETCH_TIMEOUT_SECONDS: u64 = 60; -static ACCOUNT_MUTATION: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); -static PENDING_RESTART: Mutex> = Mutex::new(None); /// All stored + discovered Codex accounts, with the stored list preferred. pub(crate) fn load_codex_accounts() -> Result, String> { @@ -188,9 +186,8 @@ pub fn codex_accounts_list() -> Result, String> { #[tauri::command] pub async fn codex_account_add(app: tauri::AppHandle) -> Result { - let _mutation = ACCOUNT_MUTATION - .try_lock() - .map_err(|_| "A Codex account operation is already in progress.".to_string())?; + let runtime = CodexAccountRuntime::new(); + let _mutation = runtime.try_begin_mutation().map_err(into_user_message)?; let manager = CodexAccountManager::new(); let account = tauri::async_runtime::spawn_blocking(move || manager.add_managed_account(None)) .await @@ -205,9 +202,8 @@ pub async fn codex_account_add(app: tauri::AppHandle) -> Result Result<(), String> { - let _mutation = ACCOUNT_MUTATION - .try_lock() - .map_err(|_| "A Codex account operation is already in progress.".to_string())?; + let runtime = CodexAccountRuntime::new(); + let _mutation = runtime.try_begin_mutation().map_err(into_user_message)?; let manager = CodexAccountManager::new(); let accounts = load_codex_accounts()?; let target = accounts @@ -215,12 +211,12 @@ pub fn codex_account_remove(app: tauri::AppHandle, id: String) -> Result<(), Str .find(|account| account.id.to_string() == id) .ok_or_else(|| "Codex account not found.".to_string())?; - let mut pending_restart = PENDING_RESTART.lock().map_err(|e| e.to_string())?; manager .remove_managed_files_if_owned(target) .map_err(into_user_message)?; - invalidate_restart_for_removed_account(&mut pending_restart, target); - drop(pending_restart); + runtime + .invalidate_restart_for_removed_account(target) + .map_err(into_user_message)?; let remaining: Vec = accounts .into_iter() @@ -232,39 +228,13 @@ pub fn codex_account_remove(app: tauri::AppHandle, id: String) -> Result<(), Str Ok(()) } -fn invalidate_restart_for_removed_account( - pending: &mut Option, - removed: &CodexAccount, -) { - if pending.as_ref().is_some_and(|result| { - result - .materialized_account - .as_ref() - .is_some_and(|account| account.matches(removed)) - || result - .ambient_account - .as_ref() - .is_some_and(|account| account.matches(removed)) - || [ - &result.desktop_session_backup_path, - &result.desktop_session_restore_path, - ] - .into_iter() - .flatten() - .any(|path| path.parent() == Some(removed.codex_home_path.as_path())) - }) { - *pending = None; - } -} - #[tauri::command] pub async fn codex_account_switch( app: tauri::AppHandle, id: String, ) -> Result { - let _mutation = ACCOUNT_MUTATION - .try_lock() - .map_err(|_| "A Codex account operation is already in progress.".to_string())?; + let runtime = CodexAccountRuntime::new(); + let _mutation = runtime.try_begin_mutation().map_err(into_user_message)?; let manager = CodexAccountManager::new(); let accounts = load_codex_accounts()?; let target = accounts @@ -282,10 +252,9 @@ pub async fn codex_account_switch( .map_err(into_user_message)?; // Materialized ambient account may need persisting. - *PENDING_RESTART.lock().map_err(|e| e.to_string())? = result - .desktop_session_restore_path - .as_ref() - .map(|_| result.clone()); + runtime + .remember_restart(&result) + .map_err(into_user_message)?; let pending = { let state = app.state::>(); let mut state = state.lock().map_err(|e| e.to_string())?; @@ -371,12 +340,12 @@ pub async fn codex_account_restart_desktop( _app: tauri::AppHandle, switch_id: String, ) -> Result<(), String> { - let _mutation = ACCOUNT_MUTATION - .try_lock() - .map_err(|_| "A Codex account operation is already in progress.".to_string())?; - let pending = PENDING_RESTART.lock().map_err(|e| e.to_string())?.clone(); + let runtime = CodexAccountRuntime::new(); + let _mutation = runtime.try_begin_mutation().map_err(into_user_message)?; let active = CodexAccountManager::new().discover_ambient_account(&[]); - let pending = validate_pending_restart(pending.as_ref(), &switch_id, active.as_ref())?.clone(); + let pending = runtime + .pending_restart_for(&switch_id, active.as_ref()) + .map_err(into_user_message)?; tauri::async_runtime::spawn_blocking(move || { restart_codex_desktop( 0.8, @@ -390,21 +359,10 @@ pub async fn codex_account_restart_desktop( .map_err(|e| e.to_string())?; // The outgoing session backup is single-use. Replaying it after relaunch // would overwrite that backup with the newly active account's session. - *PENDING_RESTART.lock().map_err(|e| e.to_string())? = None; + runtime.clear_pending_restart().map_err(into_user_message)?; Ok(()) } -fn validate_pending_restart<'a>( - pending: Option<&'a CodexSwitchResult>, - switch_id: &str, - active: Option<&CodexAccount>, -) -> Result<&'a CodexSwitchResult, String> { - pending.filter(|result| { - result.switch_id.to_string() == switch_id - && result.ambient_account.as_ref().zip(active).is_some_and(|(expected, current)| expected.matches(current)) - }).ok_or_else(|| "The selected account changed after this restart prompt opened. Switch to the intended account again before restarting Codex Desktop.".into()) -} - /// 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> { @@ -476,42 +434,6 @@ mod tests { std::fs::remove_dir_all(root).unwrap(); } - #[test] - fn removing_an_involved_account_revokes_the_pending_session_restart() { - let mut outgoing = sample_account(); - outgoing.provider_account_id = Some("outgoing".into()); - outgoing.codex_home_path = "/tmp/outgoing".into(); - let active = sample_account(); - let mut unrelated = sample_account(); - unrelated.provider_account_id = Some("unrelated".into()); - unrelated.codex_home_path = "/tmp/unrelated".into(); - let result = CodexSwitchResult { - switch_id: Uuid::new_v4(), - materialized_account: Some(outgoing.clone()), - ambient_account: Some(active.clone()), - backup_path: None, - desktop_session_backup_path: Some(outgoing.codex_home_path.join("desktop-session")), - desktop_session_restore_path: Some(active.codex_home_path.join("desktop-session")), - desktop_session_restore_exists: false, - }; - let mut pending = Some(result.clone()); - invalidate_restart_for_removed_account(&mut pending, &unrelated); - assert!(pending.is_some()); - for removed in [&outgoing, &active] { - let mut pending = Some(result.clone()); - invalidate_restart_for_removed_account(&mut pending, removed); - assert!(pending.is_none()); - assert!( - validate_pending_restart( - pending.as_ref(), - &result.switch_id.to_string(), - Some(&active) - ) - .is_err() - ); - } - } - #[test] fn superseded_lanes_cannot_overwrite_newer_snapshots() { use codexbar::codex_accounts::{AccountUsageSnapshot, file_locations}; @@ -556,30 +478,6 @@ mod tests { std::fs::remove_dir_all(root).unwrap(); } - #[test] - fn restart_rejects_superseded_prompts_and_external_identity_changes() { - let active = sample_account(); - let pending = CodexSwitchResult { - switch_id: Uuid::new_v4(), - ambient_account: Some(active.clone()), - materialized_account: None, - backup_path: None, - desktop_session_backup_path: None, - desktop_session_restore_path: None, - desktop_session_restore_exists: false, - }; - let id = pending.switch_id.to_string(); - assert!(validate_pending_restart(Some(&pending), &id, Some(&active)).is_ok()); - assert!( - validate_pending_restart(Some(&pending), &Uuid::new_v4().to_string(), Some(&active)) - .is_err() - ); - let mut other = active; - other.provider_account_id = Some("different-account".into()); - assert!(validate_pending_restart(Some(&pending), &id, Some(&other)).is_err()); - assert!(validate_pending_restart(None, &id, Some(&other)).is_err()); - } - #[test] fn codex_switch_supersedes_inflight_usage_and_keeps_other_providers() { let mut state = AppState::new(); diff --git a/rust/src/codex_accounts/account_manager.rs b/rust/src/codex_accounts/account_manager.rs index 855320edcb..5797af8486 100644 --- a/rust/src/codex_accounts/account_manager.rs +++ b/rust/src/codex_accounts/account_manager.rs @@ -14,7 +14,8 @@ use chrono::{DateTime, Utc}; use thiserror::Error; use uuid::Uuid; -use super::api::{AuthBackedIdentity, CodexApiError, load_identity}; +use super::api::CodexApiError; +use super::credentials::{AuthBackedIdentity, load_identity}; use super::file_locations::{ ambient_codex_home, auth_backups_directory, codex_desktop_session_root, desktop_session_snapshot_path, ensure_directories, managed_homes_directory, diff --git a/rust/src/codex_accounts/api.rs b/rust/src/codex_accounts/api.rs index 2807fbdfb5..bf2b0025b5 100644 --- a/rust/src/codex_accounts/api.rs +++ b/rust/src/codex_accounts/api.rs @@ -4,14 +4,18 @@ //! refreshes tokens via the OpenAI OAuth endpoint, fetches `wham/usage` (or a //! configured custom base URL) and normalizes the quota windows. -use std::collections::HashMap; -use std::path::{Path, PathBuf}; -use std::sync::{Arc, LazyLock, Mutex, Weak}; +use std::path::Path; -use base64::Engine; use chrono::{DateTime, Utc}; use thiserror::Error; +pub use super::credentials::{ + AuthBackedIdentity, AuthCredentials, jwt_payload, load_credentials, load_identity, + parse_credentials_json, save_credentials, +}; +use super::credentials::{ + account_id_from_id_token, identity_from_credentials, normalize_string, string_value, +}; use super::models::{ AccountUsageSnapshot, CreditsBalanceSnapshot, UsageWindowSnapshot, WindowRole, }; @@ -23,28 +27,6 @@ pub const REFRESH_CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann"; const REQUEST_TIMEOUT_SECONDS: u64 = 30; const UNAUTHORIZED_MESSAGE: &str = "The Codex usage API request returned unauthorized."; -type CredentialLane = tokio::sync::Mutex<()>; -static CREDENTIAL_LANES: LazyLock>>> = - LazyLock::new(|| Mutex::new(HashMap::new())); - -fn credential_lane(home: &Path) -> Result, CodexApiError> { - let path = home.join("auth.json").canonicalize().map_err(|error| { - CodexApiError::Message(format!( - "Could not resolve the account's auth file: {error}" - )) - })?; - let mut lanes = CREDENTIAL_LANES - .lock() - .map_err(|error| CodexApiError::Message(error.to_string()))?; - lanes.retain(|_, lane| lane.strong_count() > 0); - if let Some(lane) = lanes.get(&path).and_then(Weak::upgrade) { - return Ok(lane); - } - let lane = Arc::new(CredentialLane::new(())); - lanes.insert(path, Arc::downgrade(&lane)); - Ok(lane) -} - /// Friendly error surfaced to callers. #[derive(Debug, Error)] pub enum CodexApiError { @@ -56,295 +38,6 @@ pub enum CodexApiError { Parse(String), } -/// Identity derived from a Codex account's credentials. -#[derive(Debug, Clone)] -pub struct AuthBackedIdentity { - pub email: Option, - pub auth_subject: Option, - pub plan: Option, - pub provider_account_id: Option, -} - -/// Raw auth.json credentials. -#[derive(Debug, Clone)] -pub struct AuthCredentials { - pub access_token: String, - pub refresh_token: String, - pub id_token: Option, - pub account_id: Option, - pub last_refresh: Option>, -} - -impl AuthCredentials { - pub fn needs_refresh(&self) -> bool { - self.last_refresh - .is_none_or(|last| Utc::now() - last > chrono::TimeDelta::days(8)) - } -} - -/// Load the account identity from a Codex home's `auth.json`. -pub fn load_identity(codex_home_path: &Path) -> Result { - Ok(identity_from_credentials(&load_credentials( - codex_home_path, - )?)) -} - -/// Read and parse `auth.json`. -pub fn load_credentials(codex_home_path: &Path) -> Result { - let auth_path = codex_home_path.join("auth.json"); - let content = std::fs::read_to_string(&auth_path).map_err(|e| { - if e.kind() == std::io::ErrorKind::NotFound { - CodexApiError::Message("No `auth.json` was found for this account.".to_string()) - } else { - CodexApiError::Parse(format!("Failed to read the auth file: {e}")) - } - })?; - parse_credentials_json(&content) -} - -/// Parse `auth.json` contents, accepting `OPENAI_API_KEY` or a `tokens` object. -pub fn parse_credentials_json(content: &str) -> Result { - let json: serde_json::Value = serde_json::from_str(content) - .map_err(|e| CodexApiError::Parse(format!("Failed to parse the auth file: {e}")))?; - - if let Some(api_key) = json - .get("OPENAI_API_KEY") - .and_then(|v| v.as_str()) - .map(str::trim) - .filter(|s| !s.is_empty()) - { - return Ok(AuthCredentials { - access_token: api_key.to_string(), - refresh_token: String::new(), - id_token: None, - account_id: None, - last_refresh: None, - }); - } - - let tokens = json - .get("tokens") - .and_then(|v| v.as_object()) - .ok_or_else(|| { - CodexApiError::Message( - "The required token fields are missing from `auth.json`.".to_string(), - ) - })?; - - let access_token = tokens - .get("access_token") - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - .ok_or_else(|| { - CodexApiError::Message( - "The required token fields are missing from `auth.json`.".to_string(), - ) - })? - .to_string(); - - let id_token = tokens - .get("id_token") - .and_then(|v| v.as_str()) - .map(str::to_string); - let account_id = tokens - .get("account_id") - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - .map(str::to_string) - .or_else(|| account_id_from_id_token(id_token.as_deref())); - - Ok(AuthCredentials { - access_token, - refresh_token: tokens - .get("refresh_token") - .and_then(|v| v.as_str()) - .unwrap_or_default() - .to_string(), - id_token, - account_id, - last_refresh: json - .get("last_refresh") - .and_then(|v| v.as_str()) - .and_then(super::models::parse_datetime), - }) -} - -/// Save (possibly refreshed) credentials back to `auth.json`. -pub fn save_credentials( - codex_home_path: &Path, - credentials: &AuthCredentials, -) -> std::io::Result<()> { - let auth_path = codex_home_path.join("auth.json"); - let mut payload: serde_json::Value = std::fs::read_to_string(&auth_path) - .ok() - .and_then(|raw| serde_json::from_str(&raw).ok()) - .unwrap_or_else(|| serde_json::json!({})); - - let mut tokens = serde_json::Map::new(); - tokens.insert( - "access_token".to_string(), - serde_json::json!(credentials.access_token), - ); - tokens.insert( - "refresh_token".to_string(), - serde_json::json!(credentials.refresh_token), - ); - if let Some(id_token) = &credentials.id_token { - tokens.insert("id_token".to_string(), serde_json::json!(id_token)); - } - if let Some(account_id) = &credentials.account_id { - tokens.insert("account_id".to_string(), serde_json::json!(account_id)); - } - if let Some(obj) = payload.as_object_mut() { - obj.insert("tokens".to_string(), serde_json::Value::Object(tokens)); - obj.insert( - "last_refresh".to_string(), - serde_json::json!(Utc::now().to_rfc3339_opts(chrono::SecondsFormat::AutoSi, true)), - ); - } - write_auth_contents(codex_home_path, &serde_json::to_vec_pretty(&payload)?) -} - -fn write_auth_contents(home: &Path, contents: &[u8]) -> std::io::Result<()> { - use std::io::Write; - let auth_path = home.join("auth.json"); - // Preserve an existing auth-file symlink by replacing its resolved target. - let destination = auth_path.canonicalize().unwrap_or(auth_path); - let staged = destination.with_file_name(format!(".auth-{}.tmp", uuid::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 mut file = options.open(&staged)?; - let written = file.write_all(contents); - drop(file); - let result = written.and_then(|()| std::fs::rename(&staged, &destination)); - if result.is_err() { - let _cleanup = std::fs::remove_file(staged); - } - result -} - -fn synchronize_active_copy(ambient_home: &Path, managed_home: &Path) -> std::io::Result<()> { - let ambient_path = ambient_home.join("auth.json").canonicalize()?; - let managed_path = managed_home.join("auth.json").canonicalize()?; - if ambient_path == managed_path { - return Ok(()); - } - let ambient_json = std::fs::read_to_string(ambient_path)?; - let managed_json = std::fs::read_to_string(managed_path)?; - if ambient_json == managed_json { - return Ok(()); - } - let ambient = parse_credentials_json(&ambient_json).map_err(std::io::Error::other)?; - let managed = parse_credentials_json(&managed_json).map_err(std::io::Error::other)?; - let account = |credentials: &AuthCredentials, home: &Path, source| { - super::account_manager::candidate_account( - identity_from_credentials(credentials), - home, - source, - ) - }; - if !account( - &ambient, - ambient_home, - super::models::CodexAccountSource::Ambient, - ) - .matches(&account( - &managed, - managed_home, - super::models::CodexAccountSource::ManagedByApp, - )) { - return Ok(()); - } - // Older builds may already have refreshed only the managed copy. Recover - // its newer chain before making the ambient home authoritative for fetches. - if managed.last_refresh > ambient.last_refresh { - write_auth_contents(ambient_home, managed_json.as_bytes()) - } else { - write_auth_contents(managed_home, ambient_json.as_bytes()) - } -} - -fn identity_from_credentials(credentials: &AuthCredentials) -> AuthBackedIdentity { - let payload = credentials - .id_token - .as_deref() - .and_then(jwt_payload) - .unwrap_or_default(); - let auth = payload - .get("https://api.openai.com/auth") - .and_then(|v| v.as_object()) - .cloned() - .unwrap_or_default(); - let profile = payload - .get("https://api.openai.com/profile") - .and_then(|v| v.as_object()) - .cloned() - .unwrap_or_default(); - - let email = normalize_string(payload.get("email").and_then(|v| v.as_str())) - .or_else(|| normalize_string(profile.get("email").and_then(|v| v.as_str()))); - let auth_subject = normalize_string(payload.get("sub").and_then(|v| v.as_str())); - let plan = normalize_string(auth.get("chatgpt_plan_type").and_then(|v| v.as_str())) - .or_else(|| normalize_string(payload.get("chatgpt_plan_type").and_then(|v| v.as_str()))); - let provider_account_id = normalize_string(credentials.account_id.as_deref()) - .or_else(|| normalize_string(auth.get("chatgpt_account_id").and_then(|v| v.as_str()))) - .or_else(|| normalize_string(payload.get("chatgpt_account_id").and_then(|v| v.as_str()))); - - AuthBackedIdentity { - email, - auth_subject, - plan, - provider_account_id, - } -} - -/// Minimal JWT payload extraction (base64url payload, no signature verification). -pub fn jwt_payload(token: &str) -> Option> { - let mut parts = token.split('.'); - let _header = parts.next()?; - let payload = parts.next()?; - let mut padded = payload.to_string(); - while padded.len() % 4 != 0 { - padded.push('='); - } - let decoded = base64::engine::general_purpose::URL_SAFE - .decode(padded.as_bytes()) - .ok()?; - serde_json::from_slice::(&decoded) - .ok()? - .as_object() - .cloned() -} - -fn account_id_from_id_token(id_token: Option<&str>) -> Option { - let payload = id_token.and_then(jwt_payload)?; - let auth = payload - .get("https://api.openai.com/auth") - .and_then(|v| v.as_object())?; - normalize_string(auth.get("chatgpt_account_id").and_then(|v| v.as_str())) - .or_else(|| normalize_string(payload.get("chatgpt_account_id").and_then(|v| v.as_str()))) -} - -fn normalize_string(value: Option<&str>) -> Option { - value - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(str::to_string) -} - -fn string_value(value: &serde_json::Value, key: &str) -> Option { - value - .get(key) - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - .map(str::to_string) -} - // ── Quota fetching ────────────────────────────────────────────────────────── /// Client for live quota reads. Stateless per call; refresh decisions happen in @@ -370,59 +63,16 @@ impl CodexAccountApi { email_hint: Option<&str>, verify_live_data: bool, ) -> Result { - let _credentials = super::CREDENTIAL_OPERATIONS.read().await; - let target = super::account_manager::candidate_account( - load_identity(codex_home_path)?, + super::fetch_coordination::fetch_snapshot( + self, codex_home_path, - super::models::CodexAccountSource::ManagedByApp, - ); - let ambient = super::CodexAccountManager::new().discover_ambient_account(&[]); - if let Some(ambient) = ambient.filter(|ambient| ambient.matches(&target)) { - // The Desktop/CLI consumes the ambient token chain. All fetches - // for its managed copies must share that lane and refresh it first. - self.fetch_home_snapshot( - &ambient.codex_home_path, - email_hint, - verify_live_data, - Some(codex_home_path), - ) - .await - } else { - self.fetch_home_snapshot(codex_home_path, email_hint, verify_live_data, None) - .await - } - } - - async fn fetch_home_snapshot( - &self, - codex_home_path: &Path, - email_hint: Option<&str>, - verify_live_data: bool, - managed_copy: Option<&Path>, - ) -> Result { - // Read only after earlier fetches for this auth path have persisted any - // rotated tokens. Distinct homes retain independent fetch lanes. - let _home = credential_lane(codex_home_path)?.lock_owned().await; - let synchronize = || { - if let Some(managed) = managed_copy - && let Err(error) = synchronize_active_copy(codex_home_path, managed) - { - tracing::warn!( - "Could not synchronize the active Codex account's managed credentials: {error}" - ); - } - }; - synchronize(); - let result = self - .fetch_locked_snapshot(codex_home_path, email_hint, verify_live_data) - .await; - // A refresh can have rotated credentials even when the usage request - // fails. Synchronize before releasing the shared ambient lane. - synchronize(); - result + email_hint, + verify_live_data, + ) + .await } - async fn fetch_locked_snapshot( + pub(super) async fn fetch_locked_snapshot( &self, codex_home_path: &Path, email_hint: Option<&str>, @@ -898,6 +548,7 @@ fn credits_equivalent( #[cfg(test)] mod tests { use super::*; + use base64::Engine; #[tokio::test] async fn active_fetches_use_and_sync_ambient_credentials_even_when_usage_fails() { @@ -1025,7 +676,7 @@ mod tests { .unwrap(); } fn fetch( - home: PathBuf, + home: std::path::PathBuf, ) -> tokio::task::JoinHandle> { tokio::spawn(async move { let api = CodexAccountApi { @@ -1033,7 +684,10 @@ mod tests { }; // Exercise per-home concurrency independently of other tests // that intentionally take the global account-switch write lock. - api.fetch_home_snapshot(&home, None, false, None).await + super::super::fetch_coordination::fetch_home_snapshot( + &api, &home, None, false, None, + ) + .await }) } diff --git a/rust/src/codex_accounts/credentials.rs b/rust/src/codex_accounts/credentials.rs new file mode 100644 index 0000000000..2ef98286c7 --- /dev/null +++ b/rust/src/codex_accounts/credentials.rs @@ -0,0 +1,256 @@ +//! Codex credential-file parsing, identity derivation, and persistence. + +use std::path::Path; + +use base64::Engine; +use chrono::{DateTime, Utc}; + +use super::api::CodexApiError; + +/// Identity derived from a Codex account's credentials. +#[derive(Debug, Clone)] +pub struct AuthBackedIdentity { + pub email: Option, + pub auth_subject: Option, + pub plan: Option, + pub provider_account_id: Option, +} + +/// Raw auth.json credentials. +#[derive(Debug, Clone)] +pub struct AuthCredentials { + pub access_token: String, + pub refresh_token: String, + pub id_token: Option, + pub account_id: Option, + pub last_refresh: Option>, +} + +impl AuthCredentials { + pub fn needs_refresh(&self) -> bool { + self.last_refresh + .is_none_or(|last| Utc::now() - last > chrono::TimeDelta::days(8)) + } +} + +/// Load the account identity from a Codex home's `auth.json`. +pub fn load_identity(codex_home_path: &Path) -> Result { + Ok(identity_from_credentials(&load_credentials( + codex_home_path, + )?)) +} + +/// Read and parse `auth.json`. +pub fn load_credentials(codex_home_path: &Path) -> Result { + let auth_path = codex_home_path.join("auth.json"); + let content = std::fs::read_to_string(&auth_path).map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + CodexApiError::Message("No `auth.json` was found for this account.".to_string()) + } else { + CodexApiError::Parse(format!("Failed to read the auth file: {e}")) + } + })?; + parse_credentials_json(&content) +} + +/// Parse `auth.json` contents, accepting `OPENAI_API_KEY` or a `tokens` object. +pub fn parse_credentials_json(content: &str) -> Result { + let json: serde_json::Value = serde_json::from_str(content) + .map_err(|e| CodexApiError::Parse(format!("Failed to parse the auth file: {e}")))?; + + if let Some(api_key) = json + .get("OPENAI_API_KEY") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + { + return Ok(AuthCredentials { + access_token: api_key.to_string(), + refresh_token: String::new(), + id_token: None, + account_id: None, + last_refresh: None, + }); + } + + let tokens = json + .get("tokens") + .and_then(|v| v.as_object()) + .ok_or_else(|| { + CodexApiError::Message( + "The required token fields are missing from `auth.json`.".to_string(), + ) + })?; + + let access_token = tokens + .get("access_token") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .ok_or_else(|| { + CodexApiError::Message( + "The required token fields are missing from `auth.json`.".to_string(), + ) + })? + .to_string(); + + let id_token = tokens + .get("id_token") + .and_then(|v| v.as_str()) + .map(str::to_string); + let account_id = tokens + .get("account_id") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .or_else(|| account_id_from_id_token(id_token.as_deref())); + + Ok(AuthCredentials { + access_token, + refresh_token: tokens + .get("refresh_token") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(), + id_token, + account_id, + last_refresh: json + .get("last_refresh") + .and_then(|v| v.as_str()) + .and_then(super::models::parse_datetime), + }) +} + +/// Save (possibly refreshed) credentials back to `auth.json`. +pub fn save_credentials( + codex_home_path: &Path, + credentials: &AuthCredentials, +) -> std::io::Result<()> { + let auth_path = codex_home_path.join("auth.json"); + let mut payload: serde_json::Value = std::fs::read_to_string(&auth_path) + .ok() + .and_then(|raw| serde_json::from_str(&raw).ok()) + .unwrap_or_else(|| serde_json::json!({})); + + let mut tokens = serde_json::Map::new(); + tokens.insert( + "access_token".to_string(), + serde_json::json!(credentials.access_token), + ); + tokens.insert( + "refresh_token".to_string(), + serde_json::json!(credentials.refresh_token), + ); + if let Some(id_token) = &credentials.id_token { + tokens.insert("id_token".to_string(), serde_json::json!(id_token)); + } + if let Some(account_id) = &credentials.account_id { + tokens.insert("account_id".to_string(), serde_json::json!(account_id)); + } + if let Some(obj) = payload.as_object_mut() { + obj.insert("tokens".to_string(), serde_json::Value::Object(tokens)); + obj.insert( + "last_refresh".to_string(), + serde_json::json!(Utc::now().to_rfc3339_opts(chrono::SecondsFormat::AutoSi, true)), + ); + } + write_auth_contents(codex_home_path, &serde_json::to_vec_pretty(&payload)?) +} + +pub(super) fn write_auth_contents(home: &Path, contents: &[u8]) -> std::io::Result<()> { + use std::io::Write; + let auth_path = home.join("auth.json"); + // Preserve an existing auth-file symlink by replacing its resolved target. + let destination = auth_path.canonicalize().unwrap_or(auth_path); + let staged = destination.with_file_name(format!(".auth-{}.tmp", uuid::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 mut file = options.open(&staged)?; + let written = file.write_all(contents); + drop(file); + let result = written.and_then(|()| std::fs::rename(&staged, &destination)); + if result.is_err() { + let _cleanup = std::fs::remove_file(staged); + } + result +} + +pub(super) fn identity_from_credentials(credentials: &AuthCredentials) -> AuthBackedIdentity { + let payload = credentials + .id_token + .as_deref() + .and_then(jwt_payload) + .unwrap_or_default(); + let auth = payload + .get("https://api.openai.com/auth") + .and_then(|v| v.as_object()) + .cloned() + .unwrap_or_default(); + let profile = payload + .get("https://api.openai.com/profile") + .and_then(|v| v.as_object()) + .cloned() + .unwrap_or_default(); + + let email = normalize_string(payload.get("email").and_then(|v| v.as_str())) + .or_else(|| normalize_string(profile.get("email").and_then(|v| v.as_str()))); + let auth_subject = normalize_string(payload.get("sub").and_then(|v| v.as_str())); + let plan = normalize_string(auth.get("chatgpt_plan_type").and_then(|v| v.as_str())) + .or_else(|| normalize_string(payload.get("chatgpt_plan_type").and_then(|v| v.as_str()))); + let provider_account_id = normalize_string(credentials.account_id.as_deref()) + .or_else(|| normalize_string(auth.get("chatgpt_account_id").and_then(|v| v.as_str()))) + .or_else(|| normalize_string(payload.get("chatgpt_account_id").and_then(|v| v.as_str()))); + + AuthBackedIdentity { + email, + auth_subject, + plan, + provider_account_id, + } +} + +/// Minimal JWT payload extraction (base64url payload, no signature verification). +pub fn jwt_payload(token: &str) -> Option> { + let mut parts = token.split('.'); + let _header = parts.next()?; + let payload = parts.next()?; + let mut padded = payload.to_string(); + while padded.len() % 4 != 0 { + padded.push('='); + } + let decoded = base64::engine::general_purpose::URL_SAFE + .decode(padded.as_bytes()) + .ok()?; + serde_json::from_slice::(&decoded) + .ok()? + .as_object() + .cloned() +} + +pub(super) fn account_id_from_id_token(id_token: Option<&str>) -> Option { + let payload = id_token.and_then(jwt_payload)?; + let auth = payload + .get("https://api.openai.com/auth") + .and_then(|v| v.as_object())?; + normalize_string(auth.get("chatgpt_account_id").and_then(|v| v.as_str())) + .or_else(|| normalize_string(payload.get("chatgpt_account_id").and_then(|v| v.as_str()))) +} + +pub(super) fn normalize_string(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) +} + +pub(super) fn string_value(value: &serde_json::Value, key: &str) -> Option { + value + .get(key) + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(str::to_string) +} diff --git a/rust/src/codex_accounts/fetch_coordination.rs b/rust/src/codex_accounts/fetch_coordination.rs new file mode 100644 index 0000000000..318bdeb248 --- /dev/null +++ b/rust/src/codex_accounts/fetch_coordination.rs @@ -0,0 +1,136 @@ +//! Active Codex credential routing and per-auth-file fetch serialization. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, LazyLock, Mutex, Weak}; + +use super::api::{CodexAccountApi, CodexApiError}; +use super::credentials::{ + AuthCredentials, identity_from_credentials, load_identity, parse_credentials_json, + write_auth_contents, +}; +use super::models::{AccountUsageSnapshot, CodexAccountSource}; + +type CredentialLane = tokio::sync::Mutex<()>; +static CREDENTIAL_LANES: LazyLock>>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +struct FetchRoute { + home: PathBuf, + managed_copy: Option, +} + +fn credential_lane(home: &Path) -> Result, CodexApiError> { + let path = home.join("auth.json").canonicalize().map_err(|error| { + CodexApiError::Message(format!( + "Could not resolve the account's auth file: {error}" + )) + })?; + let mut lanes = CREDENTIAL_LANES + .lock() + .map_err(|error| CodexApiError::Message(error.to_string()))?; + lanes.retain(|_, lane| lane.strong_count() > 0); + if let Some(lane) = lanes.get(&path).and_then(Weak::upgrade) { + return Ok(lane); + } + let lane = Arc::new(CredentialLane::new(())); + lanes.insert(path, Arc::downgrade(&lane)); + Ok(lane) +} + +fn resolve_fetch_route(codex_home_path: &Path) -> Result { + let target = super::account_manager::candidate_account( + load_identity(codex_home_path)?, + codex_home_path, + CodexAccountSource::ManagedByApp, + ); + let ambient = super::CodexAccountManager::new().discover_ambient_account(&[]); + if let Some(ambient) = ambient.filter(|ambient| ambient.matches(&target)) { + Ok(FetchRoute { + home: ambient.codex_home_path, + managed_copy: Some(codex_home_path.to_owned()), + }) + } else { + Ok(FetchRoute { + home: codex_home_path.to_owned(), + managed_copy: None, + }) + } +} + +fn synchronize_active_copy(ambient_home: &Path, managed_home: &Path) -> std::io::Result<()> { + let ambient_path = ambient_home.join("auth.json").canonicalize()?; + let managed_path = managed_home.join("auth.json").canonicalize()?; + if ambient_path == managed_path { + return Ok(()); + } + let ambient_json = std::fs::read_to_string(ambient_path)?; + let managed_json = std::fs::read_to_string(managed_path)?; + if ambient_json == managed_json { + return Ok(()); + } + let ambient = parse_credentials_json(&ambient_json).map_err(std::io::Error::other)?; + let managed = parse_credentials_json(&managed_json).map_err(std::io::Error::other)?; + let account = |credentials: &AuthCredentials, home: &Path, source| { + super::account_manager::candidate_account( + identity_from_credentials(credentials), + home, + source, + ) + }; + if !account(&ambient, ambient_home, CodexAccountSource::Ambient).matches(&account( + &managed, + managed_home, + CodexAccountSource::ManagedByApp, + )) { + return Ok(()); + } + if managed.last_refresh > ambient.last_refresh { + write_auth_contents(ambient_home, managed_json.as_bytes()) + } else { + write_auth_contents(managed_home, ambient_json.as_bytes()) + } +} + +pub(super) async fn fetch_snapshot( + api: &CodexAccountApi, + codex_home_path: &Path, + email_hint: Option<&str>, + verify_live_data: bool, +) -> Result { + let _credentials = super::CREDENTIAL_OPERATIONS.read().await; + let route = resolve_fetch_route(codex_home_path)?; + fetch_home_snapshot( + api, + &route.home, + email_hint, + verify_live_data, + route.managed_copy.as_deref(), + ) + .await +} + +pub(super) async fn fetch_home_snapshot( + api: &CodexAccountApi, + codex_home_path: &Path, + email_hint: Option<&str>, + verify_live_data: bool, + managed_copy: Option<&Path>, +) -> Result { + let _home = credential_lane(codex_home_path)?.lock_owned().await; + let synchronize = || { + if let Some(managed) = managed_copy + && let Err(error) = synchronize_active_copy(codex_home_path, managed) + { + tracing::warn!( + "Could not synchronize the active Codex account's managed credentials: {error}" + ); + } + }; + synchronize(); + let result = api + .fetch_locked_snapshot(codex_home_path, email_hint, verify_live_data) + .await; + synchronize(); + result +} diff --git a/rust/src/codex_accounts/mod.rs b/rust/src/codex_accounts/mod.rs index 38c1066aa7..02acfdb29a 100644 --- a/rust/src/codex_accounts/mod.rs +++ b/rust/src/codex_accounts/mod.rs @@ -14,10 +14,13 @@ pub mod account_manager; pub mod api; pub mod codex_desktop; +pub(crate) mod credentials; +mod fetch_coordination; pub mod file_locations; pub mod login_runner; pub mod models; pub mod stores; +mod switch_runtime; // Refreshes may rotate auth.json. Keep account replacement exclusive with // those reads/writes while allowing different account lanes to fetch together. @@ -25,14 +28,16 @@ pub(crate) static CREDENTIAL_OPERATIONS: tokio::sync::RwLock<()> = tokio::sync::RwLock::const_new(()); pub use account_manager::{CodexAccountManager, CodexAccountManagerError, CodexSwitchResult}; -pub use api::{AuthBackedIdentity, AuthCredentials, CodexAccountApi, CodexApiError, load_identity}; +pub use api::{CodexAccountApi, CodexApiError}; pub use codex_desktop::{ CodexDesktopControlError, build_restart_command, build_restart_script, encode_powershell_script, restart_codex_desktop, }; +pub use credentials::{AuthBackedIdentity, AuthCredentials, load_identity}; pub use login_runner::{CodexLoginOutcome, CodexLoginResult, ManagedLoginProcess}; pub use models::{ AccountUsageSnapshot, CodexAccount, CodexAccountSource, CreditsBalanceSnapshot, RemovedAccountIdentity, UsageWindowSnapshot, utc_now, }; pub use stores::{AccountStore, SnapshotStore}; +pub use switch_runtime::CodexAccountRuntime; diff --git a/rust/src/codex_accounts/switch_runtime.rs b/rust/src/codex_accounts/switch_runtime.rs new file mode 100644 index 0000000000..6ca383cbd7 --- /dev/null +++ b/rust/src/codex_accounts/switch_runtime.rs @@ -0,0 +1,203 @@ +//! Process-wide coordination for Codex account mutations and Desktop restart handoff. +//! +//! The Tauri layer should issue commands, not own the account-switch state machine. + +use std::sync::Mutex; + +use super::{CodexAccount, CodexAccountManagerError, CodexSwitchResult}; + +static ACCOUNT_MUTATION: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); +static PENDING_RESTART: Mutex> = Mutex::new(None); + +#[derive(Debug, Default, Clone, Copy)] +pub struct CodexAccountRuntime; + +impl CodexAccountRuntime { + pub fn new() -> Self { + Self + } + + pub fn try_begin_mutation( + &self, + ) -> Result, CodexAccountManagerError> { + ACCOUNT_MUTATION.try_lock().map_err(|_| { + CodexAccountManagerError::Message( + "A Codex account operation is already in progress.".to_string(), + ) + }) + } + + pub fn remember_restart( + &self, + result: &CodexSwitchResult, + ) -> Result<(), CodexAccountManagerError> { + let pending = result + .desktop_session_restore_path + .as_ref() + .map(|_| result.clone()); + *PENDING_RESTART + .lock() + .map_err(|error| CodexAccountManagerError::Message(error.to_string()))? = pending; + Ok(()) + } + + pub fn invalidate_restart_for_removed_account( + &self, + removed: &CodexAccount, + ) -> Result<(), CodexAccountManagerError> { + let mut pending = PENDING_RESTART + .lock() + .map_err(|error| CodexAccountManagerError::Message(error.to_string()))?; + invalidate_restart_for_removed_account(&mut pending, removed); + Ok(()) + } + + pub fn pending_restart_for( + &self, + switch_id: &str, + active: Option<&CodexAccount>, + ) -> Result { + let pending = PENDING_RESTART + .lock() + .map_err(|error| CodexAccountManagerError::Message(error.to_string()))?; + validate_pending_restart(pending.as_ref(), switch_id, active).cloned() + } + + pub fn clear_pending_restart(&self) -> Result<(), CodexAccountManagerError> { + *PENDING_RESTART + .lock() + .map_err(|error| CodexAccountManagerError::Message(error.to_string()))? = None; + Ok(()) + } +} + +fn invalidate_restart_for_removed_account( + pending: &mut Option, + removed: &CodexAccount, +) { + if pending.as_ref().is_some_and(|result| { + result + .materialized_account + .as_ref() + .is_some_and(|account| account.matches(removed)) + || result + .ambient_account + .as_ref() + .is_some_and(|account| account.matches(removed)) + || [ + &result.desktop_session_backup_path, + &result.desktop_session_restore_path, + ] + .into_iter() + .flatten() + .any(|path| path.parent() == Some(removed.codex_home_path.as_path())) + }) { + *pending = None; + } +} + +fn validate_pending_restart<'a>( + pending: Option<&'a CodexSwitchResult>, + switch_id: &str, + active: Option<&CodexAccount>, +) -> Result<&'a CodexSwitchResult, CodexAccountManagerError> { + pending + .filter(|result| { + result.switch_id.to_string() == switch_id + && result + .ambient_account + .as_ref() + .zip(active) + .is_some_and(|(expected, current)| expected.matches(current)) + }) + .ok_or_else(|| { + CodexAccountManagerError::Message( + "The selected account changed after this restart prompt opened. Switch to the intended account again before restarting Codex Desktop." + .to_string(), + ) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use uuid::Uuid; + + fn sample_account() -> CodexAccount { + CodexAccount::new( + Uuid::new_v4(), + None, + Some("user@example.com".to_string()), + Some("auth0|acct".to_string()), + Some("acct".to_string()), + std::path::PathBuf::from("/tmp/fake-home"), + super::super::models::CodexAccountSource::ManagedByApp, + super::super::models::utc_now(), + super::super::models::utc_now(), + Some(super::super::models::utc_now()), + ) + } + + fn switch_result(outgoing: &CodexAccount, active: &CodexAccount) -> CodexSwitchResult { + CodexSwitchResult { + switch_id: Uuid::new_v4(), + materialized_account: Some(outgoing.clone()), + ambient_account: Some(active.clone()), + backup_path: None, + desktop_session_backup_path: Some(outgoing.codex_home_path.join("desktop-session")), + desktop_session_restore_path: Some(active.codex_home_path.join("desktop-session")), + desktop_session_restore_exists: false, + } + } + + #[test] + fn removing_involved_account_revokes_pending_restart() { + let mut outgoing = sample_account(); + outgoing.provider_account_id = Some("outgoing".into()); + outgoing.codex_home_path = "/tmp/outgoing".into(); + let active = sample_account(); + let mut unrelated = sample_account(); + unrelated.provider_account_id = Some("unrelated".into()); + unrelated.codex_home_path = "/tmp/unrelated".into(); + let result = switch_result(&outgoing, &active); + let mut pending = Some(result.clone()); + + invalidate_restart_for_removed_account(&mut pending, &unrelated); + assert!( + validate_pending_restart( + pending.as_ref(), + &result.switch_id.to_string(), + Some(&active) + ) + .is_ok() + ); + + invalidate_restart_for_removed_account(&mut pending, &outgoing); + assert!( + validate_pending_restart( + pending.as_ref(), + &result.switch_id.to_string(), + Some(&active) + ) + .is_err() + ); + } + + #[test] + fn restart_rejects_stale_prompt_and_external_identity_change() { + let outgoing = sample_account(); + let active = sample_account(); + let result = switch_result(&outgoing, &active); + let id = result.switch_id.to_string(); + + assert!(validate_pending_restart(Some(&result), &id, Some(&active)).is_ok()); + assert!( + validate_pending_restart(Some(&result), &Uuid::new_v4().to_string(), Some(&active)) + .is_err() + ); + let mut other = active; + other.provider_account_id = Some("different-account".into()); + assert!(validate_pending_restart(Some(&result), &id, Some(&other)).is_err()); + assert!(validate_pending_restart(None, &id, Some(&other)).is_err()); + } +}