From 1f3e153b95f1beadeb7a44daa6332631bb79686f Mon Sep 17 00:00:00 2001 From: BALEXANDROS <36165556+skyeyesec333@users.noreply.github.com> Date: Sat, 19 Sep 2026 21:54:05 +0700 Subject: [PATCH 1/3] Stop account switches from duplicating managed homes materialize_as_managed minted a fresh UUID home on every call. Because switch_account calls it whenever the outgoing ambient account differs from the target, every switch away from the ambient identity left behind another managed home. One install reached 24 homes for 2 accounts. Each duplicate was seeded with fs::copy from the ambient auth.json, so once those credentials went stale every new home carried the same expired token. Reuse a managed home that already holds credentials for this account, creating a fresh one only when none matches. When reusing, refresh the stored auth.json only if the ambient copy is at least as recently refreshed. --- rust/src/codex_accounts/account_manager.rs | 183 ++++++++++++++++++++- 1 file changed, 179 insertions(+), 4 deletions(-) diff --git a/rust/src/codex_accounts/account_manager.rs b/rust/src/codex_accounts/account_manager.rs index 40a0ff5de8..f88324f945 100644 --- a/rust/src/codex_accounts/account_manager.rs +++ b/rust/src/codex_accounts/account_manager.rs @@ -15,7 +15,7 @@ use thiserror::Error; use uuid::Uuid; use super::api::CodexApiError; -use super::credentials::{AuthBackedIdentity, load_identity}; +use super::credentials::{AuthBackedIdentity, load_identity, parse_credentials_json}; use super::file_locations::{ ambient_codex_home, auth_backups_directory, codex_desktop_session_root, desktop_session_snapshot_path, ensure_directories, managed_homes_directory, @@ -267,6 +267,11 @@ impl CodexAccountManager { } /// Copy the ambient account into an app-managed home. + /// + /// Reuses the managed home already holding this account's credentials when + /// one exists. Minting a fresh home on every call let repeated switches + /// accumulate one duplicate directory per switch, each holding a copy of + /// whatever `auth.json` happened to be ambient at the time. pub fn materialize_as_managed( &self, account: &CodexAccount, @@ -280,9 +285,23 @@ impl CodexAccountManager { )); } - let destination_home = managed_homes_directory().join(Uuid::new_v4().to_string()); - fs::create_dir_all(&destination_home)?; - fs::copy(&source_auth_path, destination_home.join("auth.json"))?; + let destination_home = match self.existing_managed_home_matching(account) { + Some(existing) => { + let existing_auth_path = existing.join("auth.json"); + // Never let a stale ambient file overwrite newer managed + // credentials; that turns a working account into a dead one. + if credentials_are_at_least_as_fresh(&source_auth_path, &existing_auth_path) { + fs::copy(&source_auth_path, &existing_auth_path)?; + } + existing + } + None => { + let fresh_home = managed_homes_directory().join(Uuid::new_v4().to_string()); + fs::create_dir_all(&fresh_home)?; + fs::copy(&source_auth_path, fresh_home.join("auth.json"))?; + fresh_home + } + }; let now = utc_now(); let mut materialized = CodexAccount::new( @@ -388,6 +407,27 @@ impl CodexAccountManager { let _written_payload = fs::write(path, format!("{encoded}\n")); } + /// Find an app-managed home that already holds credentials for `account`. + /// + /// Returns the lowest-named match so repeated calls are stable, and `None` + /// when the managed-homes directory is unreadable — callers then create a + /// fresh home, matching the previous behaviour. + fn existing_managed_home_matching(&self, account: &CodexAccount) -> Option { + let Ok(entries) = fs::read_dir(managed_homes_directory()) else { + return None; + }; + let mut matches: Vec = entries + .filter_map(|entry| entry.ok().map(|entry| entry.path())) + .filter(|home_path| home_path.is_dir()) + .filter(|home_path| { + self.discovered_managed_account(home_path, std::slice::from_ref(account)) + .is_some_and(|candidate| candidate.matches(account)) + }) + .collect(); + matches.sort(); + matches.into_iter().next() + } + fn managed_home_paths_matching( &self, account: &CodexAccount, @@ -641,6 +681,23 @@ fn directory_timestamp(path: &Path) -> DateTime { .unwrap_or_else(|_| utc_now()) } +/// Whether `candidate` holds credentials at least as recently refreshed as +/// `incumbent`, so reusing a managed home cannot downgrade a live account to a +/// stale token. Unreadable or undated credentials fall back to `true`, keeping +/// the previous "latest write wins" behaviour. +fn credentials_are_at_least_as_fresh(candidate: &Path, incumbent: &Path) -> bool { + let last_refresh = |path: &Path| { + fs::read_to_string(path) + .ok() + .and_then(|json| parse_credentials_json(&json).ok()) + .and_then(|credentials| credentials.last_refresh) + }; + match (last_refresh(candidate), last_refresh(incumbent)) { + (Some(candidate), Some(incumbent)) => candidate >= incumbent, + _ => true, + } +} + fn managed_home_key(path: &Path) -> String { std::path::absolute(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -770,6 +827,124 @@ mod tests { super::super::file_locations::clear_app_support_directory_override(); } + /// Write an auth.json whose credentials carry an explicit refresh time. + fn write_auth_refreshed_at( + home_path: &Path, + email: &str, + account_id: &str, + last_refresh: &str, + ) { + write_auth(home_path, email, account_id); + let auth_path = home_path.join("auth.json"); + let mut auth: serde_json::Value = + serde_json::from_slice(&std::fs::read(&auth_path).unwrap()).unwrap(); + auth["last_refresh"] = serde_json::Value::String(last_refresh.to_string()); + std::fs::write(&auth_path, serde_json::to_vec_pretty(&auth).unwrap()).unwrap(); + } + + fn managed_home_count(root: &Path) -> usize { + std::fs::read_dir(root.join("managed-homes")) + .map(|entries| entries.filter_map(Result::ok).count()) + .unwrap_or(0) + } + + #[test] + fn materialize_reuses_the_existing_home_for_the_same_account() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + super::super::file_locations::with_app_support_directory(root.to_path_buf()); + + let account_id = "b3f1c0de-0000-4000-8000-00000000cafe"; + let ambient_home = root.join("ambient"); + let existing_home = root.join("managed-homes").join("existing"); + for home in [&ambient_home, &existing_home] { + std::fs::create_dir_all(home).unwrap(); + } + write_auth(&ambient_home, "user@example.com", account_id); + write_auth(&existing_home, "user@example.com", account_id); + + let ambient = make_account(ambient_home, "user@example.com", account_id); + let manager = CodexAccountManager::new(); + + // Repeated switches must not leave a trail of duplicate homes. + let first = manager.materialize_as_managed(&ambient).unwrap(); + let second = manager.materialize_as_managed(&ambient).unwrap(); + + assert_eq!(first.codex_home_path, existing_home); + assert_eq!(second.codex_home_path, existing_home); + assert_eq!(managed_home_count(root), 1); + + super::super::file_locations::clear_app_support_directory_override(); + } + + #[test] + fn materialize_creates_a_home_when_no_managed_copy_matches() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + super::super::file_locations::with_app_support_directory(root.to_path_buf()); + + let ambient_home = root.join("ambient"); + let unrelated_home = root.join("managed-homes").join("unrelated"); + for home in [&ambient_home, &unrelated_home] { + std::fs::create_dir_all(home).unwrap(); + } + write_auth(&ambient_home, "user@example.com", "account-one"); + write_auth(&unrelated_home, "other@example.com", "account-two"); + + let ambient = make_account(ambient_home, "user@example.com", "account-one"); + let materialized = CodexAccountManager::new() + .materialize_as_managed(&ambient) + .unwrap(); + + assert_ne!(materialized.codex_home_path, unrelated_home); + assert!(materialized.codex_home_path.join("auth.json").exists()); + assert_eq!(managed_home_count(root), 2); + + super::super::file_locations::clear_app_support_directory_override(); + } + + #[test] + fn materialize_does_not_overwrite_newer_managed_credentials() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + super::super::file_locations::with_app_support_directory(root.to_path_buf()); + + let account_id = "0ff1ce00-0000-4000-8000-0000000000aa"; + let ambient_home = root.join("ambient"); + let existing_home = root.join("managed-homes").join("existing"); + for home in [&ambient_home, &existing_home] { + std::fs::create_dir_all(home).unwrap(); + } + // The ambient file is the stale one here: expired, left behind by an + // earlier session. Reuse must not copy it over live credentials. + write_auth_refreshed_at( + &ambient_home, + "user@example.com", + account_id, + "2026-01-01T00:00:00Z", + ); + write_auth_refreshed_at( + &existing_home, + "user@example.com", + account_id, + "2026-06-01T00:00:00Z", + ); + let preserved = std::fs::read(existing_home.join("auth.json")).unwrap(); + + let ambient = make_account(ambient_home, "user@example.com", account_id); + let materialized = CodexAccountManager::new() + .materialize_as_managed(&ambient) + .unwrap(); + + assert_eq!(materialized.codex_home_path, existing_home); + assert_eq!( + std::fs::read(existing_home.join("auth.json")).unwrap(), + preserved + ); + + super::super::file_locations::clear_app_support_directory_override(); + } + #[test] fn removing_stale_account_preserves_replacement_credentials() { let dir = tempfile::tempdir().unwrap(); From e13d6194eda1273808b4290e3ded468b47928207 Mon Sep 17 00:00:00 2001 From: NessZerra <90105158+Finesssee@users.noreply.github.com> Date: Sun, 20 Sep 2026 21:40:24 +0700 Subject: [PATCH 2/3] Move account_manager tests to sibling tests.rs --- rust/src/codex_accounts/account_manager.rs | 413 +----------------- .../codex_accounts/account_manager/tests.rs | 412 +++++++++++++++++ 2 files changed, 413 insertions(+), 412 deletions(-) create mode 100644 rust/src/codex_accounts/account_manager/tests.rs diff --git a/rust/src/codex_accounts/account_manager.rs b/rust/src/codex_accounts/account_manager.rs index f88324f945..ff36a3dbc7 100644 --- a/rust/src/codex_accounts/account_manager.rs +++ b/rust/src/codex_accounts/account_manager.rs @@ -751,415 +751,4 @@ fn looks_like_uuid(value: &str) -> bool { Uuid::parse_str(value.trim()).is_ok() } -#[cfg(test)] -mod tests { - use super::*; - use base64::Engine; - - /// Write an auth.json carrying a JWT identity for the given account id. - fn write_auth(home_path: &Path, email: &str, account_id: &str) { - let payload = serde_json::json!({ - "email": email, - "sub": format!("auth0|{account_id}"), - "https://api.openai.com/auth": { - "chatgpt_plan_type": "team", - "chatgpt_account_id": account_id, - }, - }); - let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD - .encode(serde_json::to_vec(&payload).unwrap()); - let auth_payload = serde_json::json!({ - "tokens": { - "access_token": format!("access-{account_id}"), - "refresh_token": format!("refresh-{account_id}"), - "id_token": format!("header.{encoded}.signature"), - "account_id": account_id, - }, - "last_refresh": "2026-04-23T00:00:00Z", - }); - std::fs::write( - home_path.join("auth.json"), - serde_json::to_vec_pretty(&auth_payload).unwrap(), - ) - .unwrap(); - } - - fn make_account(home_path: PathBuf, email: &str, account_id: &str) -> CodexAccount { - CodexAccount::new( - Uuid::new_v4(), - None, - Some(email.to_string()), - Some(format!("auth0|{account_id}")), - Some(account_id.to_string()), - home_path, - CodexAccountSource::ManagedByApp, - utc_now(), - utc_now(), - Some(utc_now()), - ) - } - - #[test] - fn remove_managed_account_removes_duplicate_homes_for_same_provider() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - super::super::file_locations::with_app_support_directory(root.to_path_buf()); - - let account_id = "83c5ae92-f5ee-41f8-9528-199110d1d0f9"; - let first_home = root.join("managed-homes").join("first"); - let duplicate_home = root.join("managed-homes").join("duplicate"); - let other_home = root.join("managed-homes").join("other"); - for home in [&first_home, &duplicate_home, &other_home] { - std::fs::create_dir_all(home).unwrap(); - } - write_auth(&first_home, "user@example.com", account_id); - write_auth(&duplicate_home, "user@example.com", account_id); - write_auth(&other_home, "user@example.com", "different-provider"); - - let account = make_account(first_home.clone(), "user@example.com", account_id); - let manager = CodexAccountManager::new(); - manager.remove_managed_files_if_owned(&account).unwrap(); - - assert!(!first_home.exists()); - assert!(!duplicate_home.exists()); - assert!(other_home.exists()); - - super::super::file_locations::clear_app_support_directory_override(); - } - - /// Write an auth.json whose credentials carry an explicit refresh time. - fn write_auth_refreshed_at( - home_path: &Path, - email: &str, - account_id: &str, - last_refresh: &str, - ) { - write_auth(home_path, email, account_id); - let auth_path = home_path.join("auth.json"); - let mut auth: serde_json::Value = - serde_json::from_slice(&std::fs::read(&auth_path).unwrap()).unwrap(); - auth["last_refresh"] = serde_json::Value::String(last_refresh.to_string()); - std::fs::write(&auth_path, serde_json::to_vec_pretty(&auth).unwrap()).unwrap(); - } - - fn managed_home_count(root: &Path) -> usize { - std::fs::read_dir(root.join("managed-homes")) - .map(|entries| entries.filter_map(Result::ok).count()) - .unwrap_or(0) - } - - #[test] - fn materialize_reuses_the_existing_home_for_the_same_account() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - super::super::file_locations::with_app_support_directory(root.to_path_buf()); - - let account_id = "b3f1c0de-0000-4000-8000-00000000cafe"; - let ambient_home = root.join("ambient"); - let existing_home = root.join("managed-homes").join("existing"); - for home in [&ambient_home, &existing_home] { - std::fs::create_dir_all(home).unwrap(); - } - write_auth(&ambient_home, "user@example.com", account_id); - write_auth(&existing_home, "user@example.com", account_id); - - let ambient = make_account(ambient_home, "user@example.com", account_id); - let manager = CodexAccountManager::new(); - - // Repeated switches must not leave a trail of duplicate homes. - let first = manager.materialize_as_managed(&ambient).unwrap(); - let second = manager.materialize_as_managed(&ambient).unwrap(); - - assert_eq!(first.codex_home_path, existing_home); - assert_eq!(second.codex_home_path, existing_home); - assert_eq!(managed_home_count(root), 1); - - super::super::file_locations::clear_app_support_directory_override(); - } - - #[test] - fn materialize_creates_a_home_when_no_managed_copy_matches() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - super::super::file_locations::with_app_support_directory(root.to_path_buf()); - - let ambient_home = root.join("ambient"); - let unrelated_home = root.join("managed-homes").join("unrelated"); - for home in [&ambient_home, &unrelated_home] { - std::fs::create_dir_all(home).unwrap(); - } - write_auth(&ambient_home, "user@example.com", "account-one"); - write_auth(&unrelated_home, "other@example.com", "account-two"); - - let ambient = make_account(ambient_home, "user@example.com", "account-one"); - let materialized = CodexAccountManager::new() - .materialize_as_managed(&ambient) - .unwrap(); - - assert_ne!(materialized.codex_home_path, unrelated_home); - assert!(materialized.codex_home_path.join("auth.json").exists()); - assert_eq!(managed_home_count(root), 2); - - super::super::file_locations::clear_app_support_directory_override(); - } - - #[test] - fn materialize_does_not_overwrite_newer_managed_credentials() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - super::super::file_locations::with_app_support_directory(root.to_path_buf()); - - let account_id = "0ff1ce00-0000-4000-8000-0000000000aa"; - let ambient_home = root.join("ambient"); - let existing_home = root.join("managed-homes").join("existing"); - for home in [&ambient_home, &existing_home] { - std::fs::create_dir_all(home).unwrap(); - } - // The ambient file is the stale one here: expired, left behind by an - // earlier session. Reuse must not copy it over live credentials. - write_auth_refreshed_at( - &ambient_home, - "user@example.com", - account_id, - "2026-01-01T00:00:00Z", - ); - write_auth_refreshed_at( - &existing_home, - "user@example.com", - account_id, - "2026-06-01T00:00:00Z", - ); - let preserved = std::fs::read(existing_home.join("auth.json")).unwrap(); - - let ambient = make_account(ambient_home, "user@example.com", account_id); - let materialized = CodexAccountManager::new() - .materialize_as_managed(&ambient) - .unwrap(); - - assert_eq!(materialized.codex_home_path, existing_home); - assert_eq!( - std::fs::read(existing_home.join("auth.json")).unwrap(), - preserved - ); - - 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(); - let root = dir.path(); - super::super::file_locations::with_app_support_directory(root.to_path_buf()); - - let old_account_id = "1ea93d04-5c50-42e3-857b-3db850785967"; - let new_account_id = "83c5ae92-f5ee-41f8-9528-199110d1d0f9"; - - let ambient_home = root.join(".codex"); - let target_home = root.join("managed-homes").join("target"); - let desktop_session_root = root.join("package-session"); - - std::fs::create_dir_all(&ambient_home).unwrap(); - std::fs::create_dir_all(&target_home).unwrap(); - std::fs::create_dir_all(&desktop_session_root).unwrap(); - - write_auth(&ambient_home, "old@example.com", old_account_id); - write_auth(&target_home, "new@example.com", new_account_id); - let target_session_dir = target_home.join("desktop-session").join("Network"); - std::fs::create_dir_all(&target_session_dir).unwrap(); - std::fs::write(target_session_dir.join("Cookies"), "cookie-data").unwrap(); - - let global_state = serde_json::json!({ - "electron-persisted-atom-state": { - "environment": { - "creator_id": format!("user-e9H3MsspGTF7UZJ8uaXuML55__{old_account_id}"), - } - } - }); - for file_name in [".codex-global-state.json", ".codex-global-state.json.bak"] { - std::fs::write( - ambient_home.join(file_name), - serde_json::to_vec_pretty(&global_state).unwrap(), - ) - .unwrap(); - } - - super::super::file_locations::with_ambient_codex_home(ambient_home.clone()); - super::super::file_locations::with_codex_desktop_session_root(desktop_session_root.clone()); - - let manager = CodexAccountManager::new(); - let target_account = make_account(target_home.clone(), "new@example.com", new_account_id); - let result = manager - .switch_active_account(&target_account, std::slice::from_ref(&target_account)) - .unwrap(); - - let ambient_auth: serde_json::Value = - serde_json::from_slice(&std::fs::read(ambient_home.join("auth.json")).unwrap()) - .unwrap(); - assert_eq!(ambient_auth["tokens"]["account_id"], new_account_id); - assert_eq!( - result - .ambient_account - .unwrap() - .provider_account_id - .as_deref(), - Some(new_account_id) - ); - let materialized = result.materialized_account.unwrap(); - assert_eq!( - materialized.provider_account_id.as_deref(), - Some(old_account_id) - ); - assert_eq!( - result.desktop_session_backup_path.unwrap(), - materialized.codex_home_path.join("desktop-session") - ); - assert_eq!( - result.desktop_session_restore_path.unwrap(), - target_home.join("desktop-session") - ); - assert!(result.desktop_session_restore_exists); - - let backup_files: Vec = std::fs::read_dir(root.join("auth-backups")) - .unwrap() - .map(|entry| entry.unwrap().path()) - .filter(|path| { - path.file_name() - .unwrap() - .to_string_lossy() - .starts_with("ambient-auth-") - }) - .collect(); - assert_eq!(backup_files.len(), 1); - let backup: serde_json::Value = - serde_json::from_slice(&std::fs::read(&backup_files[0]).unwrap()).unwrap(); - assert_eq!(backup["tokens"]["account_id"], old_account_id); - - for file_name in [".codex-global-state.json", ".codex-global-state.json.bak"] { - let payload: serde_json::Value = - serde_json::from_slice(&std::fs::read(ambient_home.join(file_name)).unwrap()) - .unwrap(); - let creator_id = payload["electron-persisted-atom-state"]["environment"]["creator_id"] - .as_str() - .unwrap(); - assert_eq!( - creator_id, - format!("user-e9H3MsspGTF7UZJ8uaXuML55__{new_account_id}") - ); - } - - 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(); - } -} +include!("account_manager/tests.rs"); diff --git a/rust/src/codex_accounts/account_manager/tests.rs b/rust/src/codex_accounts/account_manager/tests.rs new file mode 100644 index 0000000000..74b1b2aa17 --- /dev/null +++ b/rust/src/codex_accounts/account_manager/tests.rs @@ -0,0 +1,412 @@ +#[cfg(test)] +mod tests { + use super::*; + use base64::Engine; + + /// Write an auth.json carrying a JWT identity for the given account id. + fn write_auth(home_path: &Path, email: &str, account_id: &str) { + let payload = serde_json::json!({ + "email": email, + "sub": format!("auth0|{account_id}"), + "https://api.openai.com/auth": { + "chatgpt_plan_type": "team", + "chatgpt_account_id": account_id, + }, + }); + let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(serde_json::to_vec(&payload).unwrap()); + let auth_payload = serde_json::json!({ + "tokens": { + "access_token": format!("access-{account_id}"), + "refresh_token": format!("refresh-{account_id}"), + "id_token": format!("header.{encoded}.signature"), + "account_id": account_id, + }, + "last_refresh": "2026-04-23T00:00:00Z", + }); + std::fs::write( + home_path.join("auth.json"), + serde_json::to_vec_pretty(&auth_payload).unwrap(), + ) + .unwrap(); + } + + fn make_account(home_path: PathBuf, email: &str, account_id: &str) -> CodexAccount { + CodexAccount::new( + Uuid::new_v4(), + None, + Some(email.to_string()), + Some(format!("auth0|{account_id}")), + Some(account_id.to_string()), + home_path, + CodexAccountSource::ManagedByApp, + utc_now(), + utc_now(), + Some(utc_now()), + ) + } + + #[test] + fn remove_managed_account_removes_duplicate_homes_for_same_provider() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + super::super::file_locations::with_app_support_directory(root.to_path_buf()); + + let account_id = "83c5ae92-f5ee-41f8-9528-199110d1d0f9"; + let first_home = root.join("managed-homes").join("first"); + let duplicate_home = root.join("managed-homes").join("duplicate"); + let other_home = root.join("managed-homes").join("other"); + for home in [&first_home, &duplicate_home, &other_home] { + std::fs::create_dir_all(home).unwrap(); + } + write_auth(&first_home, "user@example.com", account_id); + write_auth(&duplicate_home, "user@example.com", account_id); + write_auth(&other_home, "user@example.com", "different-provider"); + + let account = make_account(first_home.clone(), "user@example.com", account_id); + let manager = CodexAccountManager::new(); + manager.remove_managed_files_if_owned(&account).unwrap(); + + assert!(!first_home.exists()); + assert!(!duplicate_home.exists()); + assert!(other_home.exists()); + + super::super::file_locations::clear_app_support_directory_override(); + } + + /// Write an auth.json whose credentials carry an explicit refresh time. + fn write_auth_refreshed_at( + home_path: &Path, + email: &str, + account_id: &str, + last_refresh: &str, + ) { + write_auth(home_path, email, account_id); + let auth_path = home_path.join("auth.json"); + let mut auth: serde_json::Value = + serde_json::from_slice(&std::fs::read(&auth_path).unwrap()).unwrap(); + auth["last_refresh"] = serde_json::Value::String(last_refresh.to_string()); + std::fs::write(&auth_path, serde_json::to_vec_pretty(&auth).unwrap()).unwrap(); + } + + fn managed_home_count(root: &Path) -> usize { + std::fs::read_dir(root.join("managed-homes")) + .map(|entries| entries.filter_map(Result::ok).count()) + .unwrap_or(0) + } + + #[test] + fn materialize_reuses_the_existing_home_for_the_same_account() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + super::super::file_locations::with_app_support_directory(root.to_path_buf()); + + let account_id = "b3f1c0de-0000-4000-8000-00000000cafe"; + let ambient_home = root.join("ambient"); + let existing_home = root.join("managed-homes").join("existing"); + for home in [&ambient_home, &existing_home] { + std::fs::create_dir_all(home).unwrap(); + } + write_auth(&ambient_home, "user@example.com", account_id); + write_auth(&existing_home, "user@example.com", account_id); + + let ambient = make_account(ambient_home, "user@example.com", account_id); + let manager = CodexAccountManager::new(); + + // Repeated switches must not leave a trail of duplicate homes. + let first = manager.materialize_as_managed(&ambient).unwrap(); + let second = manager.materialize_as_managed(&ambient).unwrap(); + + assert_eq!(first.codex_home_path, existing_home); + assert_eq!(second.codex_home_path, existing_home); + assert_eq!(managed_home_count(root), 1); + + super::super::file_locations::clear_app_support_directory_override(); + } + + #[test] + fn materialize_creates_a_home_when_no_managed_copy_matches() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + super::super::file_locations::with_app_support_directory(root.to_path_buf()); + + let ambient_home = root.join("ambient"); + let unrelated_home = root.join("managed-homes").join("unrelated"); + for home in [&ambient_home, &unrelated_home] { + std::fs::create_dir_all(home).unwrap(); + } + write_auth(&ambient_home, "user@example.com", "account-one"); + write_auth(&unrelated_home, "other@example.com", "account-two"); + + let ambient = make_account(ambient_home, "user@example.com", "account-one"); + let materialized = CodexAccountManager::new() + .materialize_as_managed(&ambient) + .unwrap(); + + assert_ne!(materialized.codex_home_path, unrelated_home); + assert!(materialized.codex_home_path.join("auth.json").exists()); + assert_eq!(managed_home_count(root), 2); + + super::super::file_locations::clear_app_support_directory_override(); + } + + #[test] + fn materialize_does_not_overwrite_newer_managed_credentials() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + super::super::file_locations::with_app_support_directory(root.to_path_buf()); + + let account_id = "0ff1ce00-0000-4000-8000-0000000000aa"; + let ambient_home = root.join("ambient"); + let existing_home = root.join("managed-homes").join("existing"); + for home in [&ambient_home, &existing_home] { + std::fs::create_dir_all(home).unwrap(); + } + // The ambient file is the stale one here: expired, left behind by an + // earlier session. Reuse must not copy it over live credentials. + write_auth_refreshed_at( + &ambient_home, + "user@example.com", + account_id, + "2026-01-01T00:00:00Z", + ); + write_auth_refreshed_at( + &existing_home, + "user@example.com", + account_id, + "2026-06-01T00:00:00Z", + ); + let preserved = std::fs::read(existing_home.join("auth.json")).unwrap(); + + let ambient = make_account(ambient_home, "user@example.com", account_id); + let materialized = CodexAccountManager::new() + .materialize_as_managed(&ambient) + .unwrap(); + + assert_eq!(materialized.codex_home_path, existing_home); + assert_eq!( + std::fs::read(existing_home.join("auth.json")).unwrap(), + preserved + ); + + 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(); + let root = dir.path(); + super::super::file_locations::with_app_support_directory(root.to_path_buf()); + + let old_account_id = "1ea93d04-5c50-42e3-857b-3db850785967"; + let new_account_id = "83c5ae92-f5ee-41f8-9528-199110d1d0f9"; + + let ambient_home = root.join(".codex"); + let target_home = root.join("managed-homes").join("target"); + let desktop_session_root = root.join("package-session"); + + std::fs::create_dir_all(&ambient_home).unwrap(); + std::fs::create_dir_all(&target_home).unwrap(); + std::fs::create_dir_all(&desktop_session_root).unwrap(); + + write_auth(&ambient_home, "old@example.com", old_account_id); + write_auth(&target_home, "new@example.com", new_account_id); + let target_session_dir = target_home.join("desktop-session").join("Network"); + std::fs::create_dir_all(&target_session_dir).unwrap(); + std::fs::write(target_session_dir.join("Cookies"), "cookie-data").unwrap(); + + let global_state = serde_json::json!({ + "electron-persisted-atom-state": { + "environment": { + "creator_id": format!("user-e9H3MsspGTF7UZJ8uaXuML55__{old_account_id}"), + } + } + }); + for file_name in [".codex-global-state.json", ".codex-global-state.json.bak"] { + std::fs::write( + ambient_home.join(file_name), + serde_json::to_vec_pretty(&global_state).unwrap(), + ) + .unwrap(); + } + + super::super::file_locations::with_ambient_codex_home(ambient_home.clone()); + super::super::file_locations::with_codex_desktop_session_root(desktop_session_root.clone()); + + let manager = CodexAccountManager::new(); + let target_account = make_account(target_home.clone(), "new@example.com", new_account_id); + let result = manager + .switch_active_account(&target_account, std::slice::from_ref(&target_account)) + .unwrap(); + + let ambient_auth: serde_json::Value = + serde_json::from_slice(&std::fs::read(ambient_home.join("auth.json")).unwrap()) + .unwrap(); + assert_eq!(ambient_auth["tokens"]["account_id"], new_account_id); + assert_eq!( + result + .ambient_account + .unwrap() + .provider_account_id + .as_deref(), + Some(new_account_id) + ); + let materialized = result.materialized_account.unwrap(); + assert_eq!( + materialized.provider_account_id.as_deref(), + Some(old_account_id) + ); + assert_eq!( + result.desktop_session_backup_path.unwrap(), + materialized.codex_home_path.join("desktop-session") + ); + assert_eq!( + result.desktop_session_restore_path.unwrap(), + target_home.join("desktop-session") + ); + assert!(result.desktop_session_restore_exists); + + let backup_files: Vec = std::fs::read_dir(root.join("auth-backups")) + .unwrap() + .map(|entry| entry.unwrap().path()) + .filter(|path| { + path.file_name() + .unwrap() + .to_string_lossy() + .starts_with("ambient-auth-") + }) + .collect(); + assert_eq!(backup_files.len(), 1); + let backup: serde_json::Value = + serde_json::from_slice(&std::fs::read(&backup_files[0]).unwrap()).unwrap(); + assert_eq!(backup["tokens"]["account_id"], old_account_id); + + for file_name in [".codex-global-state.json", ".codex-global-state.json.bak"] { + let payload: serde_json::Value = + serde_json::from_slice(&std::fs::read(ambient_home.join(file_name)).unwrap()) + .unwrap(); + let creator_id = payload["electron-persisted-atom-state"]["environment"]["creator_id"] + .as_str() + .unwrap(); + assert_eq!( + creator_id, + format!("user-e9H3MsspGTF7UZJ8uaXuML55__{new_account_id}") + ); + } + + 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(); + } +} From 824bfc25c1ab256acda7a2cfe4849b6281a32e56 Mon Sep 17 00:00:00 2001 From: NessZerra <90105158+Finesssee@users.noreply.github.com> Date: Sun, 20 Sep 2026 22:54:07 +0700 Subject: [PATCH 3/3] Consolidate managed home matching walks The matching walk existed three times in the file with two different stability notions. One managed_homes_matching helper now returns sorted, managed_home_key-deduplicated matches; removal feeds from it and reuse takes its first element. Unknown credential freshness now keeps the incumbent instead of clobbering it, with the failure mode named in ADR 0003. directory_timestamp returns Option so a failed metadata read no longer invents a timestamp. Adds tests for the both-unreadable and undated freshness cases and for reuse of the lowest-keyed home among several. --- docs/adr/0003-multi-account-codex.md | 11 ++ rust/src/codex_accounts/account_manager.rs | 100 +++++++------- .../codex_accounts/account_manager/tests.rs | 126 ++++++++++++++++++ 3 files changed, 191 insertions(+), 46 deletions(-) diff --git a/docs/adr/0003-multi-account-codex.md b/docs/adr/0003-multi-account-codex.md index dfe8a8ad46..990035dcb3 100644 --- a/docs/adr/0003-multi-account-codex.md +++ b/docs/adr/0003-multi-account-codex.md @@ -72,3 +72,14 @@ review; this series only prepares the ground. - Transitional duplication is accepted: two code paths compute "current Codex usage" until retirement. The `NOTICE` file carries the upstream ademisler/codexcontrol MIT attribution. +- **Managed-home reuse over duplication** (added with the home-reuse fix): + `materialize_as_managed` reuses a managed home that already holds + credentials for the account instead of minting a fresh UUID home per + switch; the first match by `managed_home_key` is the stable reuse target + and all matches are removal targets. On reuse, the stored `auth.json` is + refreshed only when the ambient copy is *known* to be at least as recently + refreshed. Unknown freshness (unreadable JSON, missing `last_refresh`, or + an unrecognized schema) keeps the incumbent credentials rather than + clobbering them: copying on unknown freshness is exactly how a stale + ambient token turned working accounts into dead ones, which is the + duplicate-home failure mode this policy exists to prevent. diff --git a/rust/src/codex_accounts/account_manager.rs b/rust/src/codex_accounts/account_manager.rs index ff36a3dbc7..8ef6c0361f 100644 --- a/rust/src/codex_accounts/account_manager.rs +++ b/rust/src/codex_accounts/account_manager.rs @@ -407,25 +407,47 @@ impl CodexAccountManager { let _written_payload = fs::write(path, format!("{encoded}\n")); } - /// Find an app-managed home that already holds credentials for `account`. + /// Find app-managed homes holding credentials for `account`. /// - /// Returns the lowest-named match so repeated calls are stable, and `None` - /// when the managed-homes directory is unreadable — callers then create a - /// fresh home, matching the previous behaviour. - fn existing_managed_home_matching(&self, account: &CodexAccount) -> Option { - let Ok(entries) = fs::read_dir(managed_homes_directory()) else { - return None; - }; - let mut matches: Vec = entries + /// Results are sorted by `managed_home_key` and deduplicated, so repeated + /// calls return the same order and the same first element regardless of + /// filesystem iteration order. The first entry is the canonical reuse + /// target for `materialize_as_managed`; all entries are removal targets + /// for `remove_managed_files_if_owned`. Returns `Err` when the + /// managed-homes directory is unreadable; `remove` surfaces the error and + /// `materialize` falls back to creating a fresh home. + fn managed_homes_matching( + &self, + account: &CodexAccount, + ) -> Result, CodexAccountManagerError> { + let mut matches: Vec<(String, PathBuf)> = fs::read_dir(managed_homes_directory())? .filter_map(|entry| entry.ok().map(|entry| entry.path())) .filter(|home_path| home_path.is_dir()) .filter(|home_path| { self.discovered_managed_account(home_path, std::slice::from_ref(account)) .is_some_and(|candidate| candidate.matches(account)) }) + .map(|home_path| { + let resolved = + std::path::absolute(&home_path).unwrap_or_else(|_| home_path.clone()); + (managed_home_key(resolved.as_path()), resolved) + }) .collect(); - matches.sort(); - matches.into_iter().next() + matches.sort_by(|a, b| a.0.cmp(&b.0)); + matches.dedup_by(|a, b| a.0 == b.0); + Ok(matches.into_iter().map(|(_, path)| path).collect()) + } + + /// Find an app-managed home that already holds credentials for `account`. + /// + /// Returns `None` when no managed home matches or the directory is + /// unreadable — callers then create a fresh home, matching the previous + /// behaviour. + fn existing_managed_home_matching(&self, account: &CodexAccount) -> Option { + self.managed_homes_matching(account) + .ok()? + .into_iter() + .next() } fn managed_home_paths_matching( @@ -445,35 +467,14 @@ impl CodexAccountManager { std::path::absolute(&account.codex_home_path) .unwrap_or_else(|_| account.codex_home_path.clone()), ]; - let mut seen_keys: std::collections::HashSet = - [managed_home_key(targets[0].as_path())] + // The account's own home is always first; skip the walk's duplicate + // of it so removal never processes the same home twice. + let head_key = managed_home_key(targets[0].as_path()); + targets.extend( + self.managed_homes_matching(account)? .into_iter() - .collect(); - - for entry in fs::read_dir(managed_homes_directory())? { - let Ok(entry) = entry else { - continue; - }; - let home_path = entry.path(); - if !home_path.is_dir() { - continue; - } - let Some(candidate) = - self.discovered_managed_account(&home_path, std::slice::from_ref(account)) - else { - continue; - }; - if !candidate.matches(account) { - continue; - } - let resolved = std::path::absolute(&home_path).unwrap_or_else(|_| home_path.clone()); - let key = managed_home_key(resolved.as_path()); - if seen_keys.contains(&key) { - continue; - } - targets.push(resolved); - seen_keys.insert(key); - } + .filter(|home| managed_home_key(home.as_path()) != head_key), + ); Ok(targets) } @@ -634,8 +635,11 @@ fn build_discovered_account( identity: AuthBackedIdentity, home_path: PathBuf, source: CodexAccountSource, - discovered_at: DateTime, + discovered_at: Option>, ) -> CodexAccount { + // No readable timestamp on a home we just found is a filesystem oddity; + // "now" is the only honest fallback for discovery ordering. + let discovered_at = discovered_at.unwrap_or_else(utc_now); let mut discovered = CodexAccount::new( matched .map(|account| account.id) @@ -667,24 +671,27 @@ fn build_discovered_account( discovered } -fn directory_timestamp(path: &Path) -> DateTime { +fn directory_timestamp(path: &Path) -> Option> { let auth_path = path.join("auth.json"); if auth_path.exists() && let Ok(metadata) = fs::metadata(&auth_path) && let Ok(modified) = metadata.modified() { - return modified.into(); + return Some(modified.into()); } fs::metadata(path) .and_then(|metadata| metadata.modified()) .map(Into::into) - .unwrap_or_else(|_| utc_now()) + .ok() } /// Whether `candidate` holds credentials at least as recently refreshed as /// `incumbent`, so reusing a managed home cannot downgrade a live account to a -/// stale token. Unreadable or undated credentials fall back to `true`, keeping -/// the previous "latest write wins" behaviour. +/// stale token. When either side's freshness is unknown — unreadable JSON, +/// missing `last_refresh`, or an unrecognized schema — this defaults to +/// `false`: the incumbent is kept rather than clobbered. Clobbering on +/// unknown freshness is how a stale ambient token kills a working account +/// (the failure mode that motivated home reuse in the first place). fn credentials_are_at_least_as_fresh(candidate: &Path, incumbent: &Path) -> bool { let last_refresh = |path: &Path| { fs::read_to_string(path) @@ -694,7 +701,8 @@ fn credentials_are_at_least_as_fresh(candidate: &Path, incumbent: &Path) -> bool }; match (last_refresh(candidate), last_refresh(incumbent)) { (Some(candidate), Some(incumbent)) => candidate >= incumbent, - _ => true, + // Unknown freshness must not destroy a possibly-live incumbent. + _ => false, } } diff --git a/rust/src/codex_accounts/account_manager/tests.rs b/rust/src/codex_accounts/account_manager/tests.rs index 74b1b2aa17..bfb8584f6c 100644 --- a/rust/src/codex_accounts/account_manager/tests.rs +++ b/rust/src/codex_accounts/account_manager/tests.rs @@ -31,6 +31,20 @@ mod tests { .unwrap(); } + /// Write an auth.json with no `last_refresh` field, so its freshness is + /// unknown to `credentials_are_at_least_as_fresh`. + fn write_auth_undated(home_path: &Path, email: &str, account_id: &str) { + write_auth(home_path, email, account_id); + let auth_path = home_path.join("auth.json"); + let mut auth: serde_json::Value = + serde_json::from_slice(&std::fs::read(&auth_path).unwrap()).unwrap(); + auth.as_object_mut() + .unwrap() + .remove("last_refresh") + .unwrap(); + std::fs::write(&auth_path, serde_json::to_vec_pretty(&auth).unwrap()).unwrap(); + } + fn make_account(home_path: PathBuf, email: &str, account_id: &str) -> CodexAccount { CodexAccount::new( Uuid::new_v4(), @@ -192,6 +206,118 @@ mod tests { super::super::file_locations::clear_app_support_directory_override(); } + #[test] + fn materialize_does_not_clobber_when_freshness_is_unknown() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + super::super::file_locations::with_app_support_directory(root.to_path_buf()); + + let account_id = "0ff1ce00-0000-4000-8000-0000000000bb"; + let ambient_home = root.join("ambient"); + let existing_home = root.join("managed-homes").join("existing"); + for home in [&ambient_home, &existing_home] { + std::fs::create_dir_all(home).unwrap(); + } + // Neither file carries a `last_refresh`: freshness is unknown on both + // sides. The incumbent managed credentials must survive untouched. + write_auth_undated(&ambient_home, "user@example.com", account_id); + write_auth_undated(&existing_home, "user@example.com", account_id); + let preserved = std::fs::read(existing_home.join("auth.json")).unwrap(); + + let ambient = make_account(ambient_home, "user@example.com", account_id); + let materialized = CodexAccountManager::new() + .materialize_as_managed(&ambient) + .unwrap(); + + assert_eq!(materialized.codex_home_path, existing_home); + assert_eq!( + std::fs::read(existing_home.join("auth.json")).unwrap(), + preserved + ); + + super::super::file_locations::clear_app_support_directory_override(); + } + + #[test] + fn freshness_unknown_when_both_files_are_unreadable() { + let dir = tempfile::tempdir().unwrap(); + let candidate = dir.path().join("candidate").join("auth.json"); + let incumbent = dir.path().join("incumbent").join("auth.json"); + std::fs::create_dir_all(candidate.parent().unwrap()).unwrap(); + std::fs::create_dir_all(incumbent.parent().unwrap()).unwrap(); + std::fs::write(&candidate, b"{not json").unwrap(); + std::fs::write(&incumbent, b"{not json either").unwrap(); + + // Unknown freshness must keep the incumbent, never clobber it. + assert!(!super::credentials_are_at_least_as_fresh( + &candidate, &incumbent + )); + } + + #[test] + fn freshness_unknown_when_last_refresh_is_missing() { + let dir = tempfile::tempdir().unwrap(); + let candidate = dir.path().join("candidate").join("auth.json"); + let incumbent = dir.path().join("incumbent").join("auth.json"); + std::fs::create_dir_all(candidate.parent().unwrap()).unwrap(); + std::fs::create_dir_all(incumbent.parent().unwrap()).unwrap(); + write_auth_undated(candidate.parent().unwrap(), "user@example.com", "cafe"); + write_auth_undated(incumbent.parent().unwrap(), "user@example.com", "cafe"); + + assert!(!super::credentials_are_at_least_as_fresh( + &candidate, &incumbent + )); + } + + #[test] + fn materialize_reuses_the_lowest_keyed_matching_home() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + super::super::file_locations::with_app_support_directory(root.to_path_buf()); + + let account_id = "0ff1ce00-0000-4000-8000-0000000000dd"; + let ambient_home = root.join("ambient"); + let newer_home = root.join("managed-homes").join("aaa-newer"); + let older_home = root.join("managed-homes").join("zzz-older"); + for home in [&ambient_home, &newer_home, &older_home] { + std::fs::create_dir_all(home).unwrap(); + } + // Two managed homes for one account: the lowest-keyed ("aaa-newer") + // holds the fresher credentials. Reuse must pick it, and the ambient + // copy (older) must not downgrade it. + write_auth_refreshed_at( + &ambient_home, + "user@example.com", + account_id, + "2026-03-01T00:00:00Z", + ); + write_auth_refreshed_at( + &newer_home, + "user@example.com", + account_id, + "2026-06-01T00:00:00Z", + ); + write_auth_refreshed_at( + &older_home, + "user@example.com", + account_id, + "2026-01-01T00:00:00Z", + ); + + let ambient = make_account(ambient_home, "user@example.com", account_id); + let materialized = CodexAccountManager::new() + .materialize_as_managed(&ambient) + .unwrap(); + + assert_eq!(materialized.codex_home_path, newer_home); + let reused: serde_json::Value = + serde_json::from_slice(&std::fs::read(newer_home.join("auth.json")).unwrap()) + .unwrap(); + assert_eq!(reused["last_refresh"], "2026-06-01T00:00:00Z"); + + super::super::file_locations::clear_app_support_directory_override(); + } + #[test] fn removing_stale_account_preserves_replacement_credentials() { let dir = tempfile::tempdir().unwrap();