From af650613b72f7be131ed94a4995ae2583eef65d5 Mon Sep 17 00:00:00 2001 From: Cmochance <3216202644@qq.com> Date: Mon, 8 Jun 2026 14:16:34 +0800 Subject: [PATCH 1/5] fix(switch): identity-guard account write-back to stop profile cross-contamination MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Profile switch and the launch-time bootstrap (`sync_root_state_to_current_profile`) copied the live `~/.codex` state back into whatever profile the `.current_profile` marker named, with no check that the account actually in `~/.codex/auth.json` is the one that profile holds. When the live account drifted away from the marker — a manual `codex login` outside the app, the official Codex app re-authing, or hand-edits to `~/.codex` — the next switch (or merely relaunching the app, since bootstrap runs the same write-back) silently overwrote an unrelated profile's stored credentials with the wrong account ("串号" / cross-contamination), and could lose the overwritten account's auth entirely. Write-back is now gated by `resolve_backup_target` (shared/runtime/profiles.rs), which fingerprints the live account from the stable `tokens.account_id` (falling back to the id_token `email`) via the new `load_account_identity_from_path`, and resolves the real target in five cases: 1. live identity unknown (apikey / placeholder / unreadable) -> marker (legacy) 2. marker slot owns the live account -> marker (happy path) 3. live account owned by a *different* managed profile -> that profile 4. marker slot is an empty placeholder, live account is new -> marker (seat it) 5. live account is unmanaged -> None (refuse the write-back; no contamination) switch_core uses it instead of the blind marker backup. The bootstrap write-back is hoisted into a shared `switch_core::sync_root_state_to_current_profile_with_home` (the macOS / Windows copies were mirror-duplicated; they now delegate) which additionally heals a stale marker when the live account drifted to another managed profile. Tests: +10 (5 resolve_backup_target cases, 3 bootstrap sync cases, 1 full-switch regression proven to fail on the pre-fix baseline, 1 account-identity extraction). 113 -> 114 shared/mac lib tests pass. --- CHANGELOG.md | 4 + src-tauri/mac/runtime/bootstrap.rs | 14 +- src-tauri/shared/runtime/metadata.rs | 69 ++++++++ src-tauri/shared/runtime/profiles.rs | 190 ++++++++++++++++++++++ src-tauri/shared/runtime/switch_core.rs | 202 +++++++++++++++++++++++- src-tauri/win/runtime/bootstrap.rs | 14 +- 6 files changed, 469 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index af25aed..76c315b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased + +- **Critical** — fixed account cross-contamination ("串号") during profile switch. Switching, and the launch-time `sync_root_state_to_current_profile`, used to copy the live `~/.codex` state back into whatever profile the `.current_profile` marker named, with no check that the account actually sitting in `~/.codex/auth.json` is the one that profile holds. If the live account had drifted away from the marker — a manual `codex login` outside the app, the official Codex app re-authing, or hand-edits to `~/.codex` — the next switch (or merely relaunching the app, since bootstrap runs the same write-back) silently overwrote an unrelated profile's stored credentials with the wrong account. Write-back is now gated by an identity check (`resolve_backup_target`): the live account is fingerprinted from the stable `tokens.account_id` (falling back to the id_token `email`) and only saved into the profile that genuinely owns it. A live account that drifted to a *different* managed profile is rerouted to its real owner and the marker is healed; a live account that belongs to no profile is refused rather than blind-copied. apikey / placeholder cards with no resolvable identity keep their previous behavior, so non-OAuth setups are unaffected. macOS + Windows symmetric. + ## 1.5.12 - 2026-05-29 - Settings → Codex CLI path gains an **Auto-detect** button next to "Change". Unlike the existing path self-check (which trusts the cached / override path), it force-rescans every common install location plus PATH and verifies each candidate is actually runnable via `codex --version`. A lone runnable hit is applied immediately; several open the dialog with the verified candidates to pick from; none falls back to the manual dialog. Targets the two cases the self-check can't: auto-detection landed on a wrong / stale path, or the user doesn't know where to point it. Backed by a new `redetect_codex_cli_path` command that runs on the blocking pool (each candidate probe spawns a child) with a per-candidate timeout so a hung binary can't wedge the scan. macOS + Windows symmetric. diff --git a/src-tauri/mac/runtime/bootstrap.rs b/src-tauri/mac/runtime/bootstrap.rs index df60889..a0443b6 100644 --- a/src-tauri/mac/runtime/bootstrap.rs +++ b/src-tauri/mac/runtime/bootstrap.rs @@ -1,7 +1,6 @@ use std::path::{Path, PathBuf}; use crate::errors::AppResult; -use crate::shared::fs_ops::backup_root_state_to_profile; use crate::shared::paths::{get_backup_root, get_codex_home}; use crate::shared::profiles::resolve_current_profile; @@ -20,15 +19,10 @@ const REFRESH_RUNTIME_DEFAULT_CONFIG: &str = concat!( ); pub fn sync_root_state_to_current_profile(codex_home: Option<&Path>) -> AppResult> { - let codex_home = codex_home.map(PathBuf::from).unwrap_or_else(get_codex_home); - let backup_root = get_backup_root(Some(&codex_home)); - let Some(current_profile) = resolve_current_profile(&backup_root) else { - return Ok(None); - }; - - backup_root_state_to_profile(¤t_profile, &codex_home, &backup_root)?; - crate::shared::profiles_index::load_profiles_index(Some(&codex_home))?; - Ok(Some(current_profile)) + // Identity-checked write-back lives in the shared layer so macOS and + // Windows can't drift apart. See + // `switch_core::sync_root_state_to_current_profile_with_home`. + crate::shared::switch_core::sync_root_state_to_current_profile_with_home(codex_home) } pub fn ensure_backup_initialized(codex_home: Option<&Path>) -> AppResult { diff --git a/src-tauri/shared/runtime/metadata.rs b/src-tauri/shared/runtime/metadata.rs index 1c3c67d..e739713 100644 --- a/src-tauri/shared/runtime/metadata.rs +++ b/src-tauri/shared/runtime/metadata.rs @@ -125,6 +125,37 @@ fn load_auth_metadata_from_path(auth_path: &Path) -> Option Some(metadata) } +/// Stable account fingerprint for an on-disk `auth.json`, used to verify +/// *which account* a file actually belongs to before the switch / bootstrap +/// write-back copies it into a profile slot. Prefers the OAuth-stable +/// `tokens.account_id`; falls back to the id_token / access_token `email` +/// claim. +/// +/// Returns `None` when no identifying field is parseable — placeholder cards +/// (`replace-me`), apikey-mode auth (no `tokens`), or an unreadable / absent +/// file. Callers MUST treat `None` as "identity unknown" (preserve legacy +/// behavior) rather than as a mismatch, so apikey / placeholder profiles keep +/// refreshing normally. +/// +/// The result is type-prefixed (`acct:` / `email:`) so an account_id can never +/// collide with an email that happens to share the same text. +pub fn load_account_identity_from_path(auth_path: &Path) -> Option { + let raw = fs::read_to_string(auth_path).ok()?; + let auth = serde_json::from_str::(&raw).ok()?; + let tokens = auth.tokens?; + + if let Some(account_id) = normalized_value(tokens.account_id.clone()) { + return Some(format!("acct:{account_id}")); + } + + let claims = tokens + .id_token + .as_deref() + .and_then(decode_token_claims) + .or_else(|| tokens.access_token.as_deref().and_then(decode_token_claims))?; + normalized_value(claims.email).map(|email| format!("email:{email}")) +} + fn load_auth_metadata( profile_name: &str, codex_home: Option<&Path>, @@ -699,4 +730,42 @@ mod tests { assert_eq!(derived.subscription_expires_at, None); assert!(!derived.has_plan_claims); } + + #[test] + fn account_identity_prefers_account_id_then_email_then_none() { + // account_id present → `acct:` wins even when an email is also present. + let dir = temp_dir("identity-account-id"); + let id_token = synthesize_jwt(r#"{"email":"user@example.com"}"#); + let auth = format!( + "{{\"tokens\":{{\"account_id\":\"acct_123\",\"id_token\":{}}}}}", + serde_json::Value::String(id_token) + ); + std::fs::write(dir.join("auth.json"), auth).unwrap(); + assert_eq!( + load_account_identity_from_path(&dir.join("auth.json")).as_deref(), + Some("acct:acct_123") + ); + + // No account_id → fall back to the id_token email (`email:` prefix). + let dir2 = temp_dir("identity-email-fallback"); + write_auth_with_id_token(&dir2, &synthesize_jwt(r#"{"email":"who@example.com"}"#)); + assert_eq!( + load_account_identity_from_path(&dir2.join("auth.json")).as_deref(), + Some("email:who@example.com") + ); + + // apikey mode (no tokens) → no resolvable identity. + let dir3 = temp_dir("identity-apikey"); + std::fs::write(dir3.join("auth.json"), r#"{"auth_mode":"apikey"}"#).unwrap(); + assert_eq!(load_account_identity_from_path(&dir3.join("auth.json")), None); + + // Placeholder account_id (`replace-me`) with no email → no identity. + let dir4 = temp_dir("identity-placeholder"); + std::fs::write( + dir4.join("auth.json"), + r#"{"tokens":{"account_id":"replace-me"}}"#, + ) + .unwrap(); + assert_eq!(load_account_identity_from_path(&dir4.join("auth.json")), None); + } } diff --git a/src-tauri/shared/runtime/profiles.rs b/src-tauri/shared/runtime/profiles.rs index e193ee4..783247e 100644 --- a/src-tauri/shared/runtime/profiles.rs +++ b/src-tauri/shared/runtime/profiles.rs @@ -3,6 +3,7 @@ use std::path::Path; use chrono::{DateTime, Local, NaiveDate}; use super::fs_ops::read_text_stripped; +use super::metadata::load_account_identity_from_path; use super::paths::{get_current_profile_file, list_profile_dirs, ACTIVE_MARKER_FILE}; pub fn build_display_title(profile_name: &str, account_label: Option<&str>) -> String { @@ -45,3 +46,192 @@ pub fn resolve_current_profile(backup_root: &Path) -> Option { None } + +/// Decide which profile slot the live `~/.codex` state should be written back +/// into, verified against the *actual account identity* in `auth.json` rather +/// than blindly trusting the `.current_profile` marker. +/// +/// The historical bug ("串号" / cross-contamination): switch and the +/// launch-time bootstrap copied the live root state into whatever profile +/// `resolve_current_profile` named, with no check that the account currently +/// in `~/.codex/auth.json` is the one that profile actually holds. If the live +/// account drifted — a manual `codex login`, the official Codex app re-authing, +/// a multi-account user editing `~/.codex` directly — the marker went stale and +/// the next write-back silently overwrote an unrelated profile's credentials +/// with the wrong account. +/// +/// Resolution order: +/// 1. Root identity unknown (placeholder / apikey / missing auth) → fall back +/// to the marker. We can't prove a mismatch, so we must not block a normal +/// apikey / placeholder refresh. +/// 2. The marker slot's identity matches root → write back there (happy path). +/// 3. Root identity matches a *different* managed profile → return that one, so +/// refreshed tokens land in their real owner and never a stranger's slot. +/// 4. The marker slot has no identity (empty placeholder) and root is a +/// brand-new account owned by no profile → seat it into the marker slot. +/// 5. Otherwise (root is an unmanaged account and the marker slot holds a +/// different, identified account) → `None`: refuse the write-back rather +/// than contaminate a slot. +pub fn resolve_backup_target(backup_root: &Path, codex_home: &Path) -> Option { + let marked = resolve_current_profile(backup_root); + + // (1) Can't identify the live account → preserve legacy behavior. + let Some(root_identity) = load_account_identity_from_path(&codex_home.join("auth.json")) else { + return marked; + }; + + let slot_identity = |profile: &str| { + load_account_identity_from_path(&backup_root.join(profile).join("auth.json")) + }; + + // (2) Marker already points at the live account → keep it. + if let Some(marked_profile) = marked.as_deref() { + if slot_identity(marked_profile).as_deref() == Some(root_identity.as_str()) { + return marked; + } + } + + // (3) Live account drifted to a different *managed* profile → route there. + // The marker wins ties (handled by the early return in (2)). + for profile_dir in list_profile_dirs(backup_root) { + let Some(name) = profile_dir.file_name().and_then(|value| value.to_str()) else { + continue; + }; + if slot_identity(name).as_deref() == Some(root_identity.as_str()) { + return Some(name.to_string()); + } + } + + // (4) Marker slot is an empty placeholder and the live account belongs to + // no profile yet → seat the fresh login into the marked card. + if let Some(marked_profile) = marked.as_deref() { + if slot_identity(marked_profile).is_none() { + return marked; + } + } + + // (5) Live account is unmanaged and the marker slot holds a different, + // identified account → refuse to overwrite it. + None +} + +#[cfg(test)] +mod tests { + use super::resolve_backup_target; + use std::fs; + use std::path::{Path, PathBuf}; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn temp_codex_home(name: &str) -> PathBuf { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + std::env::temp_dir().join(format!("codex-switch-resolve-backup-{name}-{unique}")) + } + + /// Minimal real auth.json whose stable identity is `acct:`. + fn auth_with_account(account_id: &str) -> String { + format!("{{\"tokens\":{{\"account_id\":{}}}}}", serde_json::Value::String(account_id.to_string())) + } + + fn write_profile(backup_root: &Path, profile: &str, auth_body: &str) { + let dir = backup_root.join(profile); + fs::create_dir_all(&dir).unwrap(); + fs::write(dir.join("auth.json"), auth_body).unwrap(); + } + + fn set_marker(backup_root: &Path, profile: &str) { + fs::write(backup_root.join(".current_profile"), format!("{profile}\n")).unwrap(); + } + + fn setup(name: &str) -> (PathBuf, PathBuf) { + let codex_home = temp_codex_home(name); + let backup_root = codex_home.join("account_backup"); + fs::create_dir_all(&backup_root).unwrap(); + (codex_home, backup_root) + } + + // (2) Marker matches the live account → write back to the marked slot. + #[test] + fn returns_marked_when_root_identity_matches_marker_slot() { + let (codex_home, backup_root) = setup("happy-path"); + write_profile(&backup_root, "a", &auth_with_account("acct_X")); + write_profile(&backup_root, "b", &auth_with_account("acct_Y")); + set_marker(&backup_root, "a"); + fs::write(codex_home.join("auth.json"), auth_with_account("acct_X")).unwrap(); + + assert_eq!( + resolve_backup_target(&backup_root, &codex_home).as_deref(), + Some("a") + ); + let _ = fs::remove_dir_all(&codex_home); + } + + // (5) The core 串号 guard: live account (Z) differs from the marker slot's + // account (X) and matches no profile → refuse the write-back. + #[test] + fn returns_none_when_root_drifted_to_unmanaged_account() { + let (codex_home, backup_root) = setup("drift-unmanaged"); + write_profile(&backup_root, "a", &auth_with_account("acct_X")); + write_profile(&backup_root, "b", &auth_with_account("acct_Y")); + set_marker(&backup_root, "a"); + // Live root drifted to a brand-new account Z (e.g. a manual codex login). + fs::write(codex_home.join("auth.json"), auth_with_account("acct_Z")).unwrap(); + + assert_eq!(resolve_backup_target(&backup_root, &codex_home), None); + let _ = fs::remove_dir_all(&codex_home); + } + + // (3) Live account drifted to a *different managed* profile → route the + // write-back to that profile, not the stale marker. + #[test] + fn reassigns_to_profile_that_actually_owns_the_live_account() { + let (codex_home, backup_root) = setup("reassign"); + write_profile(&backup_root, "a", &auth_with_account("acct_X")); + write_profile(&backup_root, "b", &auth_with_account("acct_Y")); + set_marker(&backup_root, "a"); + // Marker says "a" but the live account is actually "b"'s. + fs::write(codex_home.join("auth.json"), auth_with_account("acct_Y")).unwrap(); + + assert_eq!( + resolve_backup_target(&backup_root, &codex_home).as_deref(), + Some("b") + ); + let _ = fs::remove_dir_all(&codex_home); + } + + // (4) Marker slot is an empty placeholder and the live account is new → + // seat it into the marked card. + #[test] + fn seats_new_account_into_placeholder_marker_slot() { + let (codex_home, backup_root) = setup("placeholder-seat"); + // Placeholder card has no resolvable identity. + write_profile(&backup_root, "a", r#"{"tokens":{"account_id":"replace-me"}}"#); + set_marker(&backup_root, "a"); + fs::write(codex_home.join("auth.json"), auth_with_account("acct_NEW")).unwrap(); + + assert_eq!( + resolve_backup_target(&backup_root, &codex_home).as_deref(), + Some("a") + ); + let _ = fs::remove_dir_all(&codex_home); + } + + // (1) apikey / unidentifiable live auth → preserve legacy behavior (write + // back to the marker) so non-OAuth cards keep working. + #[test] + fn falls_back_to_marker_when_root_identity_unknown() { + let (codex_home, backup_root) = setup("apikey-fallback"); + write_profile(&backup_root, "a", &auth_with_account("acct_X")); + set_marker(&backup_root, "a"); + // apikey-mode auth carries no tokens → no resolvable identity. + fs::write(codex_home.join("auth.json"), r#"{"auth_mode":"apikey"}"#).unwrap(); + + assert_eq!( + resolve_backup_target(&backup_root, &codex_home).as_deref(), + Some("a") + ); + let _ = fs::remove_dir_all(&codex_home); + } +} diff --git a/src-tauri/shared/runtime/switch_core.rs b/src-tauri/shared/runtime/switch_core.rs index 3a5389b..54744a1 100644 --- a/src-tauri/shared/runtime/switch_core.rs +++ b/src-tauri/shared/runtime/switch_core.rs @@ -9,7 +9,7 @@ use super::fs_ops::{ }; use super::paths::{get_backup_root, get_codex_home, validate_profile_name}; use super::process_lock::{acquire_process_lock, ProcessLockGuard}; -use super::profiles::resolve_current_profile; +use super::profiles::{resolve_backup_target, resolve_current_profile}; use super::profiles_index::load_profiles_index; fn acquire_switch_lock(codex_home: Option<&Path>) -> AppResult { @@ -54,9 +54,13 @@ pub fn switch_profile_with_home( } let app_was_running = hooks.quit_codex_app_if_running()?; - let current_profile = resolve_current_profile(&backup_root); - if let Some(current_profile) = current_profile.as_deref() { - backup_root_state_to_profile(current_profile, &codex_home, &backup_root)?; + // Back up the live root state to the profile it *actually* belongs to, + // verified by account identity — never the `.current_profile` marker + // blindly. If the live account drifted (manual `codex login`, external + // re-auth) the marker can be stale, and a blind copy would overwrite an + // unrelated profile's credentials. `None` = nothing safe to save; skip it. + if let Some(backup_target) = resolve_backup_target(&backup_root, &codex_home) { + backup_root_state_to_profile(&backup_target, &codex_home, &backup_root)?; } autosave_auth(&codex_home)?; @@ -74,6 +78,41 @@ pub fn switch_profile_with_home( }) } +/// Launch-time reconciliation: save the live `~/.codex` state back into the +/// profile that actually owns it (identity-verified), healing a stale +/// `.current_profile` marker when the live account has drifted to a different +/// managed profile. +/// +/// Shared by the macOS and Windows bootstrap so the two platforms can't +/// diverge (previously this body was mirror-copied into each platform's +/// `bootstrap.rs`). Returns the profile the state was synced into, or `None` +/// when the live account matches no managed profile — in which case the +/// write-back is skipped so a drifted account can't contaminate a slot on +/// launch, and the stale marker is intentionally left untouched (no managed +/// profile to point it at). +pub fn sync_root_state_to_current_profile_with_home( + codex_home: Option<&Path>, +) -> AppResult> { + let codex_home = codex_home.map(PathBuf::from).unwrap_or_else(get_codex_home); + let backup_root = get_backup_root(Some(&codex_home)); + + let Some(target) = resolve_backup_target(&backup_root, &codex_home) else { + load_profiles_index(Some(&codex_home))?; + return Ok(None); + }; + + // If the live account drifted to a different managed profile than the + // marker claims, heal the marker so the UI and the next switch agree with + // what is really in ~/.codex. + if resolve_current_profile(&backup_root).as_deref() != Some(target.as_str()) { + set_active_marker(&target, &backup_root)?; + } + + backup_root_state_to_profile(&target, &codex_home, &backup_root)?; + load_profiles_index(Some(&codex_home))?; + Ok(Some(target)) +} + #[cfg(test)] mod tests { use std::fs; @@ -199,4 +238,159 @@ mod tests { assert!(hooks.reopen_calls.lock().unwrap().is_empty()); let _ = fs::remove_dir_all(&codex_home); } + + fn auth_with_account(account_id: &str) -> String { + format!( + "{{\"tokens\":{{\"account_id\":{}}}}}", + serde_json::Value::String(account_id.to_string()) + ) + } + + /// Regression for the "串号" / account cross-contamination bug. When the + /// live `~/.codex/auth.json` has drifted to an account that the + /// `.current_profile` marker does not actually name (e.g. a manual + /// `codex login` outside the app), switching must NOT blind-copy that live + /// account into the stale marker's profile slot. Before the identity guard + /// this test failed: profile "a" got overwritten with account Z. + #[test] + fn switch_does_not_contaminate_stale_marker_profile_with_drifted_account() { + let codex_home = temp_codex_home("drift-no-contaminate"); + let backup_root = codex_home.join("account_backup"); + let profile_a_dir = backup_root.join("a"); + let profile_b_dir = backup_root.join("b"); + fs::create_dir_all(&profile_a_dir).unwrap(); + fs::create_dir_all(&profile_b_dir).unwrap(); + + // Card "a" holds account X, card "b" holds account B. + fs::write(profile_a_dir.join("auth.json"), auth_with_account("acct_X")).unwrap(); + fs::write(profile_b_dir.join("auth.json"), auth_with_account("acct_B")).unwrap(); + // Marker still says "a", but the live root drifted to a stranger Z. + fs::write(get_current_profile_file(Some(&codex_home)), "a\n").unwrap(); + fs::write(codex_home.join("auth.json"), auth_with_account("acct_Z")).unwrap(); + + let hooks = FakeHooks::new(false); + let response = switch_profile_with_home(&hooks, "b", Some(&codex_home)).unwrap(); + + assert!(response.ok); + // The guard must have refused to write Z into card "a". + assert_eq!( + fs::read_to_string(profile_a_dir.join("auth.json")).unwrap(), + auth_with_account("acct_X"), + "stale-marker profile must keep its own account, not the drifted live one" + ); + // Switch still completed: root now reflects card "b". + assert_eq!( + fs::read_to_string(codex_home.join("auth.json")).unwrap(), + auth_with_account("acct_B") + ); + + let _ = fs::remove_dir_all(&codex_home); + } + + // Launch-time bootstrap: live account drifted from the marker ("a") to a + // different *managed* profile ("b"). Sync must route the write-back to "b", + // heal the marker to "b", and leave "a" untouched. + #[test] + fn bootstrap_sync_heals_marker_and_routes_backup_on_drift_to_managed_profile() { + let codex_home = temp_codex_home("bootstrap-heal"); + let backup_root = codex_home.join("account_backup"); + let profile_a_dir = backup_root.join("a"); + let profile_b_dir = backup_root.join("b"); + fs::create_dir_all(&profile_a_dir).unwrap(); + fs::create_dir_all(&profile_b_dir).unwrap(); + fs::write(profile_a_dir.join("auth.json"), auth_with_account("acct_X")).unwrap(); + // "b" genuinely owns account B (stored token is older than the live one). + let b_stale = "{\"tokens\":{\"account_id\":\"acct_B\"},\"last_refresh\":\"old\"}"; + fs::write(profile_b_dir.join("auth.json"), b_stale).unwrap(); + fs::write(get_current_profile_file(Some(&codex_home)), "a\n").unwrap(); + // Live root drifted to account B (same account, freshly refreshed) — e.g. + // the user re-logged b's account outside the app while the marker said "a". + let b_fresh = "{\"tokens\":{\"account_id\":\"acct_B\"},\"last_refresh\":\"new\"}"; + fs::write(codex_home.join("auth.json"), b_fresh).unwrap(); + + let synced = super::sync_root_state_to_current_profile_with_home(Some(&codex_home)).unwrap(); + + assert_eq!(synced.as_deref(), Some("b")); + // Marker healed to the real owner. + assert_eq!( + fs::read_to_string(get_current_profile_file(Some(&codex_home))).unwrap(), + "b\n" + ); + // Live (refreshed) state saved into "b", not the stale marker "a". + assert_eq!( + fs::read_to_string(profile_b_dir.join("auth.json")).unwrap(), + b_fresh + ); + assert_eq!( + fs::read_to_string(profile_a_dir.join("auth.json")).unwrap(), + auth_with_account("acct_X"), + "the stale-marker profile must be left untouched" + ); + + let _ = fs::remove_dir_all(&codex_home); + } + + // Launch-time bootstrap: live account belongs to no managed profile. Sync + // must skip the write-back (no contamination) and leave the marker as-is. + #[test] + fn bootstrap_sync_skips_and_preserves_slots_when_live_account_unmanaged() { + let codex_home = temp_codex_home("bootstrap-skip-unmanaged"); + let backup_root = codex_home.join("account_backup"); + let profile_a_dir = backup_root.join("a"); + fs::create_dir_all(&profile_a_dir).unwrap(); + fs::write(profile_a_dir.join("auth.json"), auth_with_account("acct_X")).unwrap(); + fs::write(get_current_profile_file(Some(&codex_home)), "a\n").unwrap(); + // Live root drifted to a brand-new, unmanaged account Z. + fs::write(codex_home.join("auth.json"), auth_with_account("acct_Z")).unwrap(); + + let synced = super::sync_root_state_to_current_profile_with_home(Some(&codex_home)).unwrap(); + + assert_eq!(synced, None); + // No contamination: "a" keeps its own account. + assert_eq!( + fs::read_to_string(profile_a_dir.join("auth.json")).unwrap(), + auth_with_account("acct_X") + ); + // Marker left as-is (no managed profile to heal it to). + assert_eq!( + fs::read_to_string(get_current_profile_file(Some(&codex_home))).unwrap(), + "a\n" + ); + + let _ = fs::remove_dir_all(&codex_home); + } + + // Launch-time bootstrap happy path: marker already names the live account. + // Sync refreshes that slot and leaves the marker unchanged. + #[test] + fn bootstrap_sync_refreshes_marked_slot_when_identity_matches() { + let codex_home = temp_codex_home("bootstrap-happy"); + let backup_root = codex_home.join("account_backup"); + let profile_a_dir = backup_root.join("a"); + fs::create_dir_all(&profile_a_dir).unwrap(); + fs::write(profile_a_dir.join("auth.json"), "A_STALE\n").unwrap(); + fs::write(get_current_profile_file(Some(&codex_home)), "a\n").unwrap(); + // Live root is account X — the same account "a" represents (token refreshed). + fs::write(codex_home.join("auth.json"), auth_with_account("acct_X")).unwrap(); + // Seed "a" with X's identity so resolve_backup_target's happy path matches. + fs::write(profile_a_dir.join("auth.json"), auth_with_account("acct_X")).unwrap(); + // Now make the live copy differ only in a trailing field to prove it is copied. + let refreshed = "{\"tokens\":{\"account_id\":\"acct_X\"},\"last_refresh\":\"now\"}"; + fs::write(codex_home.join("auth.json"), refreshed).unwrap(); + + let synced = super::sync_root_state_to_current_profile_with_home(Some(&codex_home)).unwrap(); + + assert_eq!(synced.as_deref(), Some("a")); + assert_eq!( + fs::read_to_string(get_current_profile_file(Some(&codex_home))).unwrap(), + "a\n" + ); + assert_eq!( + fs::read_to_string(profile_a_dir.join("auth.json")).unwrap(), + refreshed, + "matched slot should receive the refreshed live auth" + ); + + let _ = fs::remove_dir_all(&codex_home); + } } diff --git a/src-tauri/win/runtime/bootstrap.rs b/src-tauri/win/runtime/bootstrap.rs index 4cea434..96e927c 100644 --- a/src-tauri/win/runtime/bootstrap.rs +++ b/src-tauri/win/runtime/bootstrap.rs @@ -2,7 +2,6 @@ use std::path::{Path, PathBuf}; use crate::errors::AppResult; -use super::fs_ops::backup_root_state_to_profile; use super::paths::{get_backup_root, get_codex_home, get_refresh_runtime_dir}; use super::profiles::resolve_current_profile; @@ -19,15 +18,10 @@ const REFRESH_RUNTIME_DEFAULT_CONFIG: &str = concat!( ); pub fn sync_root_state_to_current_profile(codex_home: Option<&Path>) -> AppResult> { - let codex_home = codex_home.map(PathBuf::from).unwrap_or_else(get_codex_home); - let backup_root = get_backup_root(Some(&codex_home)); - let Some(current_profile) = resolve_current_profile(&backup_root) else { - return Ok(None); - }; - - backup_root_state_to_profile(¤t_profile, &codex_home, &backup_root)?; - super::profiles_index::load_profiles_index(Some(&codex_home))?; - Ok(Some(current_profile)) + // Identity-checked write-back lives in the shared layer so macOS and + // Windows can't drift apart. See + // `switch_core::sync_root_state_to_current_profile_with_home`. + crate::shared::switch_core::sync_root_state_to_current_profile_with_home(codex_home) } pub fn ensure_backup_initialized(codex_home: Option<&Path>) -> AppResult { From 9e9a3db2cd2396c8c43e7fe73cd830b270506980 Mon Sep 17 00:00:00 2001 From: Cmochance <3216202644@qq.com> Date: Mon, 8 Jun 2026 14:35:31 +0800 Subject: [PATCH 2/5] feat(switch): clear stale marker + prompt when live account is unmanaged Follow-up to the identity guard: refine case 5 (live ~/.codex account owned by no managed profile). Previously the launch-time sync just skipped the write-back and left the stale `.current_profile` marker in place, so the dashboard kept showing a wrong card as "current" with no signal to the user. Now: - `detect_unmanaged_live_account` (shared/runtime/profiles.rs) reports the live account's label when it has a resolvable identity that no profile owns (reusing the extracted `find_profile_owning_identity`). - The bootstrap sync clears every active marker via the new `fs_ops::clear_active_markers` when that condition holds, so no card is falsely flagged current. - `load_profiles_snapshot` recomputes the condition each call and carries it as `ProfilesSnapshotResponse.unmanaged_live_account`; the dashboard shows a one-time, account-named toast (`unmanagedAccountToast`, EN + zh) prompting the user to switch to or create the matching card. The prompt de-dupes per distinct account and resets when the live account is managed again. Tests: +3 (detect flags unmanaged / returns None when managed / returns None when unidentifiable); the existing bootstrap-unmanaged test now asserts the marker is cleared. 117 lib tests pass; tsc --noEmit clean. --- CHANGELOG.md | 1 + src-tauri/shared/front/actions.ts | 18 +++++ src-tauri/shared/front/i18n.ts | 4 + src-tauri/shared/front/tauri.ts | 2 + src-tauri/shared/front/types.ts | 3 + src-tauri/shared/runtime/fs_ops.rs | 12 +++ src-tauri/shared/runtime/metadata.rs | 1 - src-tauri/shared/runtime/models.rs | 4 + src-tauri/shared/runtime/profiles.rs | 90 +++++++++++++++++++--- src-tauri/shared/runtime/profiles_index.rs | 10 ++- src-tauri/shared/runtime/switch_core.rs | 35 ++++++--- 11 files changed, 160 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 76c315b..5ba5b29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased - **Critical** — fixed account cross-contamination ("串号") during profile switch. Switching, and the launch-time `sync_root_state_to_current_profile`, used to copy the live `~/.codex` state back into whatever profile the `.current_profile` marker named, with no check that the account actually sitting in `~/.codex/auth.json` is the one that profile holds. If the live account had drifted away from the marker — a manual `codex login` outside the app, the official Codex app re-authing, or hand-edits to `~/.codex` — the next switch (or merely relaunching the app, since bootstrap runs the same write-back) silently overwrote an unrelated profile's stored credentials with the wrong account. Write-back is now gated by an identity check (`resolve_backup_target`): the live account is fingerprinted from the stable `tokens.account_id` (falling back to the id_token `email`) and only saved into the profile that genuinely owns it. A live account that drifted to a *different* managed profile is rerouted to its real owner and the marker is healed; a live account that belongs to no profile is refused rather than blind-copied. apikey / placeholder cards with no resolvable identity keep their previous behavior, so non-OAuth setups are unaffected. macOS + Windows symmetric. +- When the live `~/.codex` account belongs to **no saved card** (e.g. a fresh `codex login` outside the app), the launch-time sync now clears the stale current-profile marker instead of leaving a wrong card flagged as "current", and the dashboard shows a one-time prompt naming the unmanaged account so you can switch to — or create — the matching card. ## 1.5.12 - 2026-05-29 diff --git a/src-tauri/shared/front/actions.ts b/src-tauri/shared/front/actions.ts index 873ccc7..cc74b51 100644 --- a/src-tauri/shared/front/actions.ts +++ b/src-tauri/shared/front/actions.ts @@ -199,6 +199,23 @@ async function refreshActiveQuotaSilently(): Promise { } } +// Tracks the last unmanaged account we prompted about so a single drift event +// shows the toast once, not on every dashboard refresh. Resets to null when the +// live account is managed again, so a later drift re-prompts. +let lastUnmanagedAccountPrompt: string | null = null; + +function maybePromptUnmanagedAccount(account: string | null): void { + if (!account) { + lastUnmanagedAccountPrompt = null; + return; + } + if (account === lastUnmanagedAccountPrompt) { + return; + } + lastUnmanagedAccountPrompt = account; + showToast(t(state.locale, "unmanagedAccountToast", { account }), true); +} + async function refreshAllData(showError = true): Promise { try { const [snapshot, currentQuota] = await Promise.all([ @@ -209,6 +226,7 @@ async function refreshAllData(showError = true): Promise { applySnapshot(snapshot); applyCurrentQuota(currentQuota); rerenderDashboard(); + maybePromptUnmanagedAccount(snapshot.unmanaged_live_account); } catch (error) { if (showError) { showToast(error instanceof Error ? error.message : "Failed to load dashboard.", true); diff --git a/src-tauri/shared/front/i18n.ts b/src-tauri/shared/front/i18n.ts index cf4a369..8cfcea1 100644 --- a/src-tauri/shared/front/i18n.ts +++ b/src-tauri/shared/front/i18n.ts @@ -233,6 +233,8 @@ const enMessages = { codexCliPathSaveFailed: "Failed to save codex CLI path.", codexCliNotFoundToast: "Codex CLI not found. Pick the binary location to continue.", + unmanagedAccountToast: + "The account currently signed in ({account}) isn't saved to any card. Switch to a card, or add it as a new one.", codexCliRetryLogin: "Save & retry login", profileLoginCancelHint: "Login in progress — click to cancel", profileLoginCancelAria: "Cancel login for {profile}", @@ -490,6 +492,8 @@ const messages: Record = { codexCliPathRejected: "这个路径是 Codex Switch 自身的 shim,请选择真正的 codex 二进制。", codexCliPathSaveFailed: "保存 Codex CLI 路径失败。", codexCliNotFoundToast: "找不到 codex CLI,请先指定它的位置。", + unmanagedAccountToast: + "当前登录的账号({account})不在任何卡片中。请切换到某张卡片,或将它新建为一张卡片。", codexCliRetryLogin: "保存并重试登录", profileLoginCancelHint: "登录进行中,点击取消", profileLoginCancelAria: "取消 {profile} 的登录", diff --git a/src-tauri/shared/front/tauri.ts b/src-tauri/shared/front/tauri.ts index 1ff0af9..9a55b5a 100644 --- a/src-tauri/shared/front/tauri.ts +++ b/src-tauri/shared/front/tauri.ts @@ -150,6 +150,7 @@ let previewSnapshot: ProfilesSnapshotResponse = { profiles: clone(previewProfiles), current_card: clone(previewCurrentCard), current_quota_card: clone(previewCurrentQuota), + unmanaged_live_account: null, }; function mockAction(message: string, path: string | null = null): Promise { @@ -166,6 +167,7 @@ function refreshPreviewSnapshot(): void { profiles: clone(previewSnapshot.profiles), current_card: clone(previewCurrentCard), current_quota_card: clone(previewCurrentQuota), + unmanaged_live_account: null, }; } diff --git a/src-tauri/shared/front/types.ts b/src-tauri/shared/front/types.ts index 5314c1f..75f88bd 100644 --- a/src-tauri/shared/front/types.ts +++ b/src-tauri/shared/front/types.ts @@ -55,6 +55,9 @@ export interface ProfilesSnapshotResponse { profiles: ProfileCard[]; current_card: CurrentCard | null; current_quota_card: QuotaSummary | null; + /** Label of the live `~/.codex` account when it belongs to no saved card + * (drift to an unmanaged account); `null` in the normal case. */ + unmanaged_live_account: string | null; } export interface CurrentQuotaResponse { diff --git a/src-tauri/shared/runtime/fs_ops.rs b/src-tauri/shared/runtime/fs_ops.rs index 7512ad6..85c2fde 100644 --- a/src-tauri/shared/runtime/fs_ops.rs +++ b/src-tauri/shared/runtime/fs_ops.rs @@ -239,3 +239,15 @@ pub fn set_active_marker(profile: &str, backup_root: &Path) -> AppResult<()> { ) }) } + +/// Clear every active-profile marker and the `.current_profile` pointer, +/// leaving no profile flagged as current. Used when the live `~/.codex` +/// account has drifted to an account no managed profile owns: keeping a stale +/// marker would make the dashboard show a wrong "current" card, so we drop the +/// pointer entirely and let the UI surface the unmanaged-account prompt. +pub fn clear_active_markers(backup_root: &Path) -> AppResult<()> { + for profile_dir in list_profile_dirs(backup_root) { + remove_path(&profile_dir.join(ACTIVE_MARKER_FILE))?; + } + remove_path(&get_current_profile_file(backup_root.parent())) +} diff --git a/src-tauri/shared/runtime/metadata.rs b/src-tauri/shared/runtime/metadata.rs index e739713..7d246b0 100644 --- a/src-tauri/shared/runtime/metadata.rs +++ b/src-tauri/shared/runtime/metadata.rs @@ -166,7 +166,6 @@ fn load_auth_metadata( load_auth_metadata_from_path(&auth_path) } -#[allow(dead_code)] pub fn load_root_auth_metadata(codex_home: Option<&Path>) -> Option { let auth_path = codex_home .map(Path::to_path_buf) diff --git a/src-tauri/shared/runtime/models.rs b/src-tauri/shared/runtime/models.rs index 6fc5431..af96676 100644 --- a/src-tauri/shared/runtime/models.rs +++ b/src-tauri/shared/runtime/models.rs @@ -131,6 +131,10 @@ pub struct ProfilesSnapshotResponse { pub profiles: Vec, pub current_card: Option, pub current_quota_card: Option, + /// Set when the live `~/.codex` account has a resolvable identity that no + /// managed profile owns (drift to an unmanaged account) — carries a label + /// for the dashboard prompt. `None` in the normal case. + pub unmanaged_live_account: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src-tauri/shared/runtime/profiles.rs b/src-tauri/shared/runtime/profiles.rs index 783247e..5c84edf 100644 --- a/src-tauri/shared/runtime/profiles.rs +++ b/src-tauri/shared/runtime/profiles.rs @@ -3,7 +3,7 @@ use std::path::Path; use chrono::{DateTime, Local, NaiveDate}; use super::fs_ops::read_text_stripped; -use super::metadata::load_account_identity_from_path; +use super::metadata::{load_account_identity_from_path, load_root_auth_metadata}; use super::paths::{get_current_profile_file, list_profile_dirs, ACTIVE_MARKER_FILE}; pub fn build_display_title(profile_name: &str, account_label: Option<&str>) -> String { @@ -93,13 +93,8 @@ pub fn resolve_backup_target(backup_root: &Path, codex_home: &Path) -> Option Option Option { + for profile_dir in list_profile_dirs(backup_root) { + let Some(name) = profile_dir.file_name().and_then(|value| value.to_str()) else { + continue; + }; + if load_account_identity_from_path(&profile_dir.join("auth.json")).as_deref() + == Some(identity) + { + return Some(name.to_string()); + } + } + None +} + +/// Detect the "drifted to an unmanaged account" condition: the live `~/.codex` +/// account has a resolvable identity, but no managed profile owns it. Returns a +/// human-facing label (email when available, else the bare id) for the +/// dashboard prompt, or `None` when the live account is unidentifiable +/// (apikey / placeholder / missing) or already owned by a profile. +pub fn detect_unmanaged_live_account(backup_root: &Path, codex_home: &Path) -> Option { + let root_identity = load_account_identity_from_path(&codex_home.join("auth.json"))?; + if find_profile_owning_identity(backup_root, &root_identity).is_some() { + return None; + } + + let label = load_root_auth_metadata(Some(codex_home)) + .and_then(|metadata| metadata.account_label) + .unwrap_or_else(|| { + // Strip the `acct:` / `email:` type prefix for display. + root_identity + .split_once(':') + .map(|(_, value)| value.to_string()) + .unwrap_or_else(|| root_identity.clone()) + }); + Some(label) +} + #[cfg(test)] mod tests { - use super::resolve_backup_target; + use super::{detect_unmanaged_live_account, resolve_backup_target}; use std::fs; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -234,4 +269,41 @@ mod tests { ); let _ = fs::remove_dir_all(&codex_home); } + + // detect_unmanaged_live_account: live account owned by no profile → Some(label). + #[test] + fn detect_flags_unmanaged_live_account_with_label() { + let (codex_home, backup_root) = setup("detect-unmanaged"); + write_profile(&backup_root, "a", &auth_with_account("acct_X")); + fs::write(codex_home.join("auth.json"), auth_with_account("acct_Z")).unwrap(); + + assert_eq!( + detect_unmanaged_live_account(&backup_root, &codex_home).as_deref(), + Some("acct_Z"), + "an identified live account owned by no profile must be flagged" + ); + let _ = fs::remove_dir_all(&codex_home); + } + + // detect_unmanaged_live_account: live account owned by a profile → None. + #[test] + fn detect_returns_none_when_live_account_is_managed() { + let (codex_home, backup_root) = setup("detect-managed"); + write_profile(&backup_root, "a", &auth_with_account("acct_X")); + fs::write(codex_home.join("auth.json"), auth_with_account("acct_X")).unwrap(); + + assert_eq!(detect_unmanaged_live_account(&backup_root, &codex_home), None); + let _ = fs::remove_dir_all(&codex_home); + } + + // detect_unmanaged_live_account: unidentifiable live auth (apikey) → None. + #[test] + fn detect_returns_none_when_live_account_unidentifiable() { + let (codex_home, backup_root) = setup("detect-apikey"); + write_profile(&backup_root, "a", &auth_with_account("acct_X")); + fs::write(codex_home.join("auth.json"), r#"{"auth_mode":"apikey"}"#).unwrap(); + + assert_eq!(detect_unmanaged_live_account(&backup_root, &codex_home), None); + let _ = fs::remove_dir_all(&codex_home); + } } diff --git a/src-tauri/shared/runtime/profiles_index.rs b/src-tauri/shared/runtime/profiles_index.rs index 759f419..558f258 100644 --- a/src-tauri/shared/runtime/profiles_index.rs +++ b/src-tauri/shared/runtime/profiles_index.rs @@ -15,7 +15,8 @@ use super::paths::{ DEFAULT_PAGE_SIZE, }; use super::profiles::{ - build_display_title, compute_subscription_days_left, resolve_current_profile, + build_display_title, compute_subscription_days_left, detect_unmanaged_live_account, + resolve_current_profile, }; use super::session_usage::{load_latest_local_quota_snapshot, normalize_quota_summary}; @@ -377,6 +378,12 @@ pub fn load_profiles_snapshot(codex_home: Option<&Path>) -> AppResult) -> AppResult) -> AppResult { @@ -97,6 +100,14 @@ pub fn sync_root_state_to_current_profile_with_home( let backup_root = get_backup_root(Some(&codex_home)); let Some(target) = resolve_backup_target(&backup_root, &codex_home) else { + // No identity-verified target. If the live account is a real account + // that no profile owns (drift to an unmanaged account), clear the stale + // marker so the dashboard stops showing a wrong "current" card and + // surfaces the unmanaged-account prompt instead. Otherwise (no / + // unparseable auth) leave the markers untouched. + if detect_unmanaged_live_account(&backup_root, &codex_home).is_some() { + clear_active_markers(&backup_root)?; + } load_profiles_index(Some(&codex_home))?; return Ok(None); }; @@ -331,15 +342,17 @@ mod tests { } // Launch-time bootstrap: live account belongs to no managed profile. Sync - // must skip the write-back (no contamination) and leave the marker as-is. + // must skip the write-back (no contamination) AND clear the stale marker so + // the dashboard stops showing a wrong "current" card. #[test] - fn bootstrap_sync_skips_and_preserves_slots_when_live_account_unmanaged() { - let codex_home = temp_codex_home("bootstrap-skip-unmanaged"); + fn bootstrap_sync_clears_marker_and_preserves_slots_when_live_account_unmanaged() { + let codex_home = temp_codex_home("bootstrap-clear-unmanaged"); let backup_root = codex_home.join("account_backup"); let profile_a_dir = backup_root.join("a"); fs::create_dir_all(&profile_a_dir).unwrap(); fs::write(profile_a_dir.join("auth.json"), auth_with_account("acct_X")).unwrap(); fs::write(get_current_profile_file(Some(&codex_home)), "a\n").unwrap(); + fs::write(profile_a_dir.join(".active_profile"), "x\n").unwrap(); // Live root drifted to a brand-new, unmanaged account Z. fs::write(codex_home.join("auth.json"), auth_with_account("acct_Z")).unwrap(); @@ -351,10 +364,14 @@ mod tests { fs::read_to_string(profile_a_dir.join("auth.json")).unwrap(), auth_with_account("acct_X") ); - // Marker left as-is (no managed profile to heal it to). - assert_eq!( - fs::read_to_string(get_current_profile_file(Some(&codex_home))).unwrap(), - "a\n" + // Stale marker cleared (no managed profile owns the live account). + assert!( + !get_current_profile_file(Some(&codex_home)).exists(), + ".current_profile must be cleared on unmanaged drift" + ); + assert!( + !profile_a_dir.join(".active_profile").exists(), + ".active_profile markers must be cleared on unmanaged drift" ); let _ = fs::remove_dir_all(&codex_home); From e29460c4523805b46d4a74c895f2936f1b105953 Mon Sep 17 00:00:00 2001 From: Cmochance <3216202644@qq.com> Date: Mon, 8 Jun 2026 14:41:59 +0800 Subject: [PATCH 3/5] fix(switch): don't seat a drifted OAuth login onto an API-key marker slot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses a codex review P2 on resolve_backup_target case 4. The placeholder- seating path used `slot_identity(marked).is_none()`, but a `None` identity means "no OAuth identity" — which is also true for an API-key card (and for a malformed / unreadable auth.json), not just an empty placeholder. So when the marker pointed at an API-key card and the live root had drifted to an OAuth account owned by no card, the write-back seated the OAuth state on top of the API-key card and destroyed its credentials — the very contamination this PR fixes, just shifted onto API-key users. Case 4 now gates on the new `metadata::auth_is_empty_placeholder`, which returns true only when auth.json parses and carries no usable credentials of any kind (no OAuth tokens beyond the `replace-me` seed, no `auth_mode = "apikey"`, no non-empty `OPENAI_API_KEY`). API-key, malformed, unreadable, and real-OAuth slots are all rejected, so the live account falls through to case 5 (refuse + prompt) instead of overwriting real credentials. Tests: +2 (apikey marker slot is never seated and keeps its key; direct auth_is_empty_placeholder coverage across placeholder / empty / apikey / raw key / real-oauth / malformed / missing). 119 lib tests pass. --- src-tauri/shared/runtime/metadata.rs | 91 ++++++++++++++++++++++++++++ src-tauri/shared/runtime/profiles.rs | 35 +++++++++-- 2 files changed, 122 insertions(+), 4 deletions(-) diff --git a/src-tauri/shared/runtime/metadata.rs b/src-tauri/shared/runtime/metadata.rs index 7d246b0..d42b5fe 100644 --- a/src-tauri/shared/runtime/metadata.rs +++ b/src-tauri/shared/runtime/metadata.rs @@ -156,6 +156,60 @@ pub fn load_account_identity_from_path(auth_path: &Path) -> Option { normalized_value(claims.email).map(|email| format!("email:{email}")) } +/// True only when `auth.json` is a genuine empty placeholder — it parses and +/// carries no usable credentials of any kind (no OAuth tokens, no API key). The +/// switch / bootstrap write-back uses this to decide whether a marked slot may +/// receive a drifted login. +/// +/// Conservative by design: a missing / unreadable / malformed file, an API-key +/// card (`auth_mode = "apikey"` or a non-empty `OPENAI_API_KEY`), or any real +/// OAuth auth all return `false`. This is what keeps a drifted OAuth account +/// from being seated on top of an API-key card's real credentials — `None` +/// identity means "no OAuth identity," which is NOT the same as "empty slot". +pub fn auth_is_empty_placeholder(auth_path: &Path) -> bool { + let Ok(raw) = fs::read_to_string(auth_path) else { + return false; + }; + let trimmed = raw.trim(); + if trimmed.is_empty() { + return true; + } + let Ok(value) = serde_json::from_str::(trimmed) else { + return false; + }; + + // API-key card → real, non-OAuth credentials. Never seatable. + if value.get("auth_mode").and_then(serde_json::Value::as_str) == Some("apikey") { + return false; + } + if value + .get("OPENAI_API_KEY") + .and_then(serde_json::Value::as_str) + .is_some_and(|key| !key.trim().is_empty()) + { + return false; + } + + // Any usable OAuth token material → real card. Placeholder seeds use the + // `replace-me` sentinel, which doesn't count. + if let Some(tokens) = value.get("tokens") { + let has_real_token = ["access_token", "id_token", "refresh_token", "account_id"] + .iter() + .any(|field| { + tokens + .get(field) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .is_some_and(|value| !value.is_empty() && !value.eq_ignore_ascii_case("replace-me")) + }); + if has_real_token { + return false; + } + } + + true +} + fn load_auth_metadata( profile_name: &str, codex_home: Option<&Path>, @@ -767,4 +821,41 @@ mod tests { .unwrap(); assert_eq!(load_account_identity_from_path(&dir4.join("auth.json")), None); } + + #[test] + fn auth_is_empty_placeholder_only_for_credential_free_slots() { + let dir = temp_dir("placeholder-detect"); + let path = dir.join("auth.json"); + + // Genuine placeholder: `replace-me` tokens only. + std::fs::write( + &path, + r#"{"tokens":{"access_token":"replace-me","account_id":"replace-me"}}"#, + ) + .unwrap(); + assert!(auth_is_empty_placeholder(&path), "replace-me seed is seatable"); + + // Empty file → seatable. + std::fs::write(&path, " \n").unwrap(); + assert!(auth_is_empty_placeholder(&path), "empty file is seatable"); + + // API-key card (auth_mode) → NOT a placeholder. + std::fs::write(&path, r#"{"auth_mode":"apikey","OPENAI_API_KEY":"sk-x"}"#).unwrap(); + assert!(!auth_is_empty_placeholder(&path), "apikey card is not seatable"); + + // Bare OPENAI_API_KEY without auth_mode → NOT a placeholder. + std::fs::write(&path, r#"{"OPENAI_API_KEY":"sk-y"}"#).unwrap(); + assert!(!auth_is_empty_placeholder(&path), "raw api key is not seatable"); + + // Real OAuth token material → NOT a placeholder. + std::fs::write(&path, r#"{"tokens":{"account_id":"acct_real"}}"#).unwrap(); + assert!(!auth_is_empty_placeholder(&path), "real oauth is not seatable"); + + // Malformed JSON → conservative false (don't overwrite the unknown). + std::fs::write(&path, "{not json").unwrap(); + assert!(!auth_is_empty_placeholder(&path), "malformed is not seatable"); + + // Missing file → false. + assert!(!auth_is_empty_placeholder(&dir.join("nope.json"))); + } } diff --git a/src-tauri/shared/runtime/profiles.rs b/src-tauri/shared/runtime/profiles.rs index 5c84edf..c6f172d 100644 --- a/src-tauri/shared/runtime/profiles.rs +++ b/src-tauri/shared/runtime/profiles.rs @@ -3,7 +3,9 @@ use std::path::Path; use chrono::{DateTime, Local, NaiveDate}; use super::fs_ops::read_text_stripped; -use super::metadata::{load_account_identity_from_path, load_root_auth_metadata}; +use super::metadata::{ + auth_is_empty_placeholder, load_account_identity_from_path, load_root_auth_metadata, +}; use super::paths::{get_current_profile_file, list_profile_dirs, ACTIVE_MARKER_FILE}; pub fn build_display_title(profile_name: &str, account_label: Option<&str>) -> String { @@ -97,10 +99,14 @@ pub fn resolve_backup_target(backup_root: &Path, codex_home: &Path) -> Option Date: Mon, 8 Jun 2026 14:47:18 +0800 Subject: [PATCH 4/5] fix(switch): suppress stale current card when live account is unmanaged MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses a second codex review P2 on load_profiles_snapshot. The unmanaged detection was independent of current_card, so a single snapshot could both flag `unmanaged_live_account` AND return the old marked card as `current` (plus its quota) — contradictory UI state. Bootstrap only clears the marker on launch, so mid-session drift (an external `codex login` while the app is open) hit exactly this case. The snapshot now suppresses the current card when `unmanaged_live_account` is `Some`: `current_profile` is forced to `None` before building the response, so no card is shown as current (in the list or the header) while the prompt says the live account is unmanaged. `load_current_live_quota` gets the same guard so its quota response stays consistent. Tests: +2 (snapshot shows current card on identity match; snapshot suppresses current card + quota + list "current" status on unmanaged drift). 121 lib tests pass. --- src-tauri/shared/runtime/profiles_index.rs | 105 +++++++++++++++++++-- 1 file changed, 98 insertions(+), 7 deletions(-) diff --git a/src-tauri/shared/runtime/profiles_index.rs b/src-tauri/shared/runtime/profiles_index.rs index 558f258..831c4e1 100644 --- a/src-tauri/shared/runtime/profiles_index.rs +++ b/src-tauri/shared/runtime/profiles_index.rs @@ -370,13 +370,6 @@ fn select_current_quota( pub fn load_profiles_snapshot(codex_home: Option<&Path>) -> AppResult { let codex_home = codex_home.map(PathBuf::from).unwrap_or_else(get_codex_home); let index = load_profiles_index(Some(&codex_home))?; - let current_profile = index.current_profile.as_deref(); - let current_entry = current_profile.and_then(|profile_name| { - index - .profiles - .iter() - .find(|entry| entry.folder_name == profile_name) - }); // Surface "the live ~/.codex account isn't saved to any card" so the // dashboard can prompt the user. Recomputed every snapshot so it reflects @@ -384,6 +377,23 @@ pub fn load_profiles_snapshot(codex_home: Option<&Path>) -> AppResult) -> AppResult) -> AppResult { let codex_home = codex_home.map(PathBuf::from).unwrap_or_else(get_codex_home); let index = load_profiles_index(Some(&codex_home))?; + + // Mirror load_profiles_snapshot: when the live account is unmanaged the + // `.current_profile` marker is stale, so report no current quota rather than + // the drifted-away card's numbers. + if detect_unmanaged_live_account(&get_backup_root(Some(&codex_home)), &codex_home).is_some() { + return Ok(CurrentQuotaResponse { + profile: None, + quota: None, + }); + } + let Some(current_profile) = index.current_profile.clone() else { return Ok(CurrentQuotaResponse { profile: None, @@ -436,3 +457,73 @@ pub fn load_current_live_quota(codex_home: Option<&Path>) -> AppResult PathBuf { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + std::env::temp_dir().join(format!("codex-switch-snapshot-{name}-{unique}")) + } + + fn auth_with_account(account_id: &str) -> String { + format!( + "{{\"tokens\":{{\"account_id\":{}}}}}", + serde_json::Value::String(account_id.to_string()) + ) + } + + /// codex_home with one managed card "a" (account X) marked current. + fn seed(name: &str) -> PathBuf { + let codex_home = temp_codex_home(name); + let profile_a = codex_home.join("account_backup").join("a"); + fs::create_dir_all(&profile_a).unwrap(); + fs::write(profile_a.join("auth.json"), auth_with_account("acct_X")).unwrap(); + fs::write(get_current_profile_file(Some(&codex_home)), "a\n").unwrap(); + codex_home + } + + #[test] + fn snapshot_shows_current_card_when_live_account_matches_marker() { + let codex_home = seed("snapshot-managed"); + fs::write(codex_home.join("auth.json"), auth_with_account("acct_X")).unwrap(); + + let snapshot = load_profiles_snapshot(Some(&codex_home)).unwrap(); + + assert_eq!(snapshot.unmanaged_live_account, None); + assert_eq!( + snapshot.current_card.as_ref().map(|card| card.folder_name.as_str()), + Some("a") + ); + let _ = fs::remove_dir_all(&codex_home); + } + + #[test] + fn snapshot_suppresses_current_card_when_live_account_unmanaged() { + let codex_home = seed("snapshot-unmanaged"); + // Live root drifted to an account no card owns (mid-session external login). + fs::write(codex_home.join("auth.json"), auth_with_account("acct_Z")).unwrap(); + + let snapshot = load_profiles_snapshot(Some(&codex_home)).unwrap(); + + assert!(snapshot.unmanaged_live_account.is_some()); + assert!( + snapshot.current_card.is_none(), + "stale current card must be suppressed when the live account is unmanaged" + ); + assert!(snapshot.current_quota_card.is_none()); + assert!( + snapshot.profiles.iter().all(|card| card.status != "current"), + "no card in the list should be flagged current" + ); + let _ = fs::remove_dir_all(&codex_home); + } +} From b3ba82bbe9b76c9bd3f0ff0c207c8129089de2f0 Mon Sep 17 00:00:00 2001 From: Cmochance <3216202644@qq.com> Date: Mon, 8 Jun 2026 20:49:15 +0800 Subject: [PATCH 5/5] fix(switch): match accounts by account_id OR email, not a single fingerprint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses a third codex review P2. Identity was a single prefixed fingerprint (`acct:` preferred, else `email:`) compared by exact equality. But one account can present an email-only auth.json before a refresh writes `account_id`: the slot's stored identity stays `email:…` while the live root becomes `acct:…`, and the exact match then treats the same account as unmanaged — refusing/rerouting the write-back and clearing/suppressing the current card, so legacy email-only slots stop receiving refreshed root state. Identity is now an `AccountIdentity { account_id, email }` and two identities are the same account when they share a non-empty account_id OR a non-empty email. Each field is globally unique to one account, so OR-matching never merges two distinct accounts, but it keeps an email-only slot matching the same account once an id appears. resolve_backup_target, find_profile_owning_identity, and detect_unmanaged_live_account all match via `same_account`. Tests: +2 (AccountIdentity::same_account matrix; an email-only slot still matches an account_id-bearing live root and is neither refused nor flagged unmanaged); existing identity test updated to assert both fields are captured. 123 lib tests pass. --- CHANGELOG.md | 2 +- src-tauri/shared/runtime/metadata.rs | 129 +++++++++++++++++++++------ src-tauri/shared/runtime/profiles.rs | 72 ++++++++++----- 3 files changed, 152 insertions(+), 51 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ba5b29..696b015 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unreleased -- **Critical** — fixed account cross-contamination ("串号") during profile switch. Switching, and the launch-time `sync_root_state_to_current_profile`, used to copy the live `~/.codex` state back into whatever profile the `.current_profile` marker named, with no check that the account actually sitting in `~/.codex/auth.json` is the one that profile holds. If the live account had drifted away from the marker — a manual `codex login` outside the app, the official Codex app re-authing, or hand-edits to `~/.codex` — the next switch (or merely relaunching the app, since bootstrap runs the same write-back) silently overwrote an unrelated profile's stored credentials with the wrong account. Write-back is now gated by an identity check (`resolve_backup_target`): the live account is fingerprinted from the stable `tokens.account_id` (falling back to the id_token `email`) and only saved into the profile that genuinely owns it. A live account that drifted to a *different* managed profile is rerouted to its real owner and the marker is healed; a live account that belongs to no profile is refused rather than blind-copied. apikey / placeholder cards with no resolvable identity keep their previous behavior, so non-OAuth setups are unaffected. macOS + Windows symmetric. +- **Critical** — fixed account cross-contamination ("串号") during profile switch. Switching, and the launch-time `sync_root_state_to_current_profile`, used to copy the live `~/.codex` state back into whatever profile the `.current_profile` marker named, with no check that the account actually sitting in `~/.codex/auth.json` is the one that profile holds. If the live account had drifted away from the marker — a manual `codex login` outside the app, the official Codex app re-authing, or hand-edits to `~/.codex` — the next switch (or merely relaunching the app, since bootstrap runs the same write-back) silently overwrote an unrelated profile's stored credentials with the wrong account. Write-back is now gated by an identity check (`resolve_backup_target`): the live account is identified by its `tokens.account_id` and/or id_token `email` — matched on *either*, so a legacy email-only card still matches the same account after a later refresh adds an id — and only saved into the profile that genuinely owns it. A live account that drifted to a *different* managed profile is rerouted to its real owner and the marker is healed; a live account that belongs to no profile is refused rather than blind-copied. apikey / placeholder cards with no resolvable identity keep their previous behavior, so non-OAuth setups are unaffected. macOS + Windows symmetric. - When the live `~/.codex` account belongs to **no saved card** (e.g. a fresh `codex login` outside the app), the launch-time sync now clears the stale current-profile marker instead of leaving a wrong card flagged as "current", and the dashboard shows a one-time prompt naming the unmanaged account so you can switch to — or create — the matching card. ## 1.5.12 - 2026-05-29 diff --git a/src-tauri/shared/runtime/metadata.rs b/src-tauri/shared/runtime/metadata.rs index d42b5fe..8cced7c 100644 --- a/src-tauri/shared/runtime/metadata.rs +++ b/src-tauri/shared/runtime/metadata.rs @@ -125,35 +125,65 @@ fn load_auth_metadata_from_path(auth_path: &Path) -> Option Some(metadata) } -/// Stable account fingerprint for an on-disk `auth.json`, used to verify -/// *which account* a file actually belongs to before the switch / bootstrap -/// write-back copies it into a profile slot. Prefers the OAuth-stable -/// `tokens.account_id`; falls back to the id_token / access_token `email` -/// claim. +/// Stable account identity for an on-disk `auth.json`: the OAuth `account_id` +/// and the id_token / access_token `email` claim, whichever are present. /// -/// Returns `None` when no identifying field is parseable — placeholder cards +/// Carries both (rather than a single fingerprint) because one account can +/// present an email-only auth before a refresh adds `account_id` — matching on +/// a single prefixed string would then treat the same account as a stranger. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct AccountIdentity { + pub account_id: Option, + pub email: Option, +} + +impl AccountIdentity { + /// Two identities refer to the same OpenAI account when they share a + /// non-empty `account_id` OR a non-empty `email`. Each field is globally + /// unique to one account, so OR-matching can never merge two distinct + /// accounts; but it *does* keep a legacy email-only slot matching the same + /// account after a later refresh writes `account_id`. + pub fn same_account(&self, other: &AccountIdentity) -> bool { + if let (Some(left), Some(right)) = (&self.account_id, &other.account_id) { + if left == right { + return true; + } + } + if let (Some(left), Some(right)) = (&self.email, &other.email) { + if left.eq_ignore_ascii_case(right) { + return true; + } + } + false + } + + /// Human label for prompts: email preferred, else the account id. + pub fn label(&self) -> Option { + self.email.clone().or_else(|| self.account_id.clone()) + } +} + +/// Load the account identity from an `auth.json`. Returns `None` only when +/// neither `account_id` nor `email` is resolvable — placeholder cards /// (`replace-me`), apikey-mode auth (no `tokens`), or an unreadable / absent /// file. Callers MUST treat `None` as "identity unknown" (preserve legacy /// behavior) rather than as a mismatch, so apikey / placeholder profiles keep /// refreshing normally. -/// -/// The result is type-prefixed (`acct:` / `email:`) so an account_id can never -/// collide with an email that happens to share the same text. -pub fn load_account_identity_from_path(auth_path: &Path) -> Option { +pub fn load_account_identity_from_path(auth_path: &Path) -> Option { let raw = fs::read_to_string(auth_path).ok()?; let auth = serde_json::from_str::(&raw).ok()?; let tokens = auth.tokens?; - if let Some(account_id) = normalized_value(tokens.account_id.clone()) { - return Some(format!("acct:{account_id}")); - } - - let claims = tokens + let account_id = normalized_value(tokens.account_id.clone()); + let email = tokens .id_token .as_deref() .and_then(decode_token_claims) - .or_else(|| tokens.access_token.as_deref().and_then(decode_token_claims))?; - normalized_value(claims.email).map(|email| format!("email:{email}")) + .or_else(|| tokens.access_token.as_deref().and_then(decode_token_claims)) + .and_then(|claims| normalized_value(claims.email)); + + let identity = AccountIdentity { account_id, email }; + (identity != AccountIdentity::default()).then_some(identity) } /// True only when `auth.json` is a genuine empty placeholder — it parses and @@ -220,6 +250,7 @@ fn load_auth_metadata( load_auth_metadata_from_path(&auth_path) } +#[allow(dead_code)] pub fn load_root_auth_metadata(codex_home: Option<&Path>) -> Option { let auth_path = codex_home .map(Path::to_path_buf) @@ -785,8 +816,8 @@ mod tests { } #[test] - fn account_identity_prefers_account_id_then_email_then_none() { - // account_id present → `acct:` wins even when an email is also present. + fn account_identity_captures_account_id_and_email() { + // Both account_id and the id_token email are captured. let dir = temp_dir("identity-account-id"); let id_token = synthesize_jwt(r#"{"email":"user@example.com"}"#); let auth = format!( @@ -794,18 +825,16 @@ mod tests { serde_json::Value::String(id_token) ); std::fs::write(dir.join("auth.json"), auth).unwrap(); - assert_eq!( - load_account_identity_from_path(&dir.join("auth.json")).as_deref(), - Some("acct:acct_123") - ); + let id = load_account_identity_from_path(&dir.join("auth.json")).unwrap(); + assert_eq!(id.account_id.as_deref(), Some("acct_123")); + assert_eq!(id.email.as_deref(), Some("user@example.com")); - // No account_id → fall back to the id_token email (`email:` prefix). + // No account_id → email-only identity. let dir2 = temp_dir("identity-email-fallback"); write_auth_with_id_token(&dir2, &synthesize_jwt(r#"{"email":"who@example.com"}"#)); - assert_eq!( - load_account_identity_from_path(&dir2.join("auth.json")).as_deref(), - Some("email:who@example.com") - ); + let id2 = load_account_identity_from_path(&dir2.join("auth.json")).unwrap(); + assert_eq!(id2.account_id, None); + assert_eq!(id2.email.as_deref(), Some("who@example.com")); // apikey mode (no tokens) → no resolvable identity. let dir3 = temp_dir("identity-apikey"); @@ -822,6 +851,50 @@ mod tests { assert_eq!(load_account_identity_from_path(&dir4.join("auth.json")), None); } + #[test] + fn account_identity_same_account_matches_on_either_field() { + // Email-only identity vs. account_id+email for the same account → same. + let email_only = AccountIdentity { + account_id: None, + email: Some("user@example.com".to_string()), + }; + let with_account_id = AccountIdentity { + account_id: Some("acct_1".to_string()), + email: Some("user@example.com".to_string()), + }; + assert!(email_only.same_account(&with_account_id)); + assert!(with_account_id.same_account(&email_only)); + + // Same account_id, different/absent email → still same. + let id_a = AccountIdentity { + account_id: Some("acct_1".to_string()), + email: None, + }; + let id_b = AccountIdentity { + account_id: Some("acct_1".to_string()), + email: Some("x@y.com".to_string()), + }; + assert!(id_a.same_account(&id_b)); + + // Different account_id and different email → distinct accounts. + let other = AccountIdentity { + account_id: Some("acct_2".to_string()), + email: Some("other@example.com".to_string()), + }; + assert!(!with_account_id.same_account(&other)); + + // No shared identifiable field → cannot prove same; treat as distinct. + let acct_only = AccountIdentity { + account_id: Some("acct_3".to_string()), + email: None, + }; + let mail_only = AccountIdentity { + account_id: None, + email: Some("z@z.com".to_string()), + }; + assert!(!acct_only.same_account(&mail_only)); + } + #[test] fn auth_is_empty_placeholder_only_for_credential_free_slots() { let dir = temp_dir("placeholder-detect"); diff --git a/src-tauri/shared/runtime/profiles.rs b/src-tauri/shared/runtime/profiles.rs index c6f172d..02da591 100644 --- a/src-tauri/shared/runtime/profiles.rs +++ b/src-tauri/shared/runtime/profiles.rs @@ -3,9 +3,7 @@ use std::path::Path; use chrono::{DateTime, Local, NaiveDate}; use super::fs_ops::read_text_stripped; -use super::metadata::{ - auth_is_empty_placeholder, load_account_identity_from_path, load_root_auth_metadata, -}; +use super::metadata::{auth_is_empty_placeholder, load_account_identity_from_path, AccountIdentity}; use super::paths::{get_current_profile_file, list_profile_dirs, ACTIVE_MARKER_FILE}; pub fn build_display_title(profile_name: &str, account_label: Option<&str>) -> String { @@ -88,7 +86,7 @@ pub fn resolve_backup_target(backup_root: &Path, codex_home: &Path) -> Option Option Option { +/// First managed profile whose stored `auth.json` is the *same account* as +/// `identity` (shared account_id or email), or `None` if no profile owns it. +fn find_profile_owning_identity(backup_root: &Path, identity: &AccountIdentity) -> Option { for profile_dir in list_profile_dirs(backup_root) { let Some(name) = profile_dir.file_name().and_then(|value| value.to_str()) else { continue; }; - if load_account_identity_from_path(&profile_dir.join("auth.json")).as_deref() - == Some(identity) + if load_account_identity_from_path(&profile_dir.join("auth.json")) + .is_some_and(|slot| slot.same_account(identity)) { return Some(name.to_string()); } @@ -135,7 +132,7 @@ fn find_profile_owning_identity(backup_root: &Path, identity: &str) -> Option Option { @@ -143,17 +140,7 @@ pub fn detect_unmanaged_live_account(backup_root: &Path, codex_home: &Path) -> O if find_profile_owning_identity(backup_root, &root_identity).is_some() { return None; } - - let label = load_root_auth_metadata(Some(codex_home)) - .and_then(|metadata| metadata.account_label) - .unwrap_or_else(|| { - // Strip the `acct:` / `email:` type prefix for display. - root_identity - .split_once(':') - .map(|(_, value)| value.to_string()) - .unwrap_or_else(|| root_identity.clone()) - }); - Some(label) + root_identity.label() } #[cfg(test)] @@ -297,6 +284,47 @@ mod tests { let _ = fs::remove_dir_all(&codex_home); } + // A legacy email-only slot must still match the same account after a later + // refresh adds account_id — matched via the shared email, not refused. + #[test] + fn matches_email_only_slot_against_account_id_identity() { + use base64::{engine::general_purpose, Engine as _}; + let id_token = |email: &str| { + let payload = + general_purpose::URL_SAFE_NO_PAD.encode(format!("{{\"email\":\"{email}\"}}")); + format!("h.{payload}.s") + }; + let (codex_home, backup_root) = setup("email-then-account-id"); + // Card "a" was created from an email-only auth (no account_id). + write_profile( + &backup_root, + "a", + &format!( + "{{\"tokens\":{{\"id_token\":\"{}\"}}}}", + id_token("user@example.com") + ), + ); + set_marker(&backup_root, "a"); + // Live root for the same account now also carries account_id. + fs::write( + codex_home.join("auth.json"), + format!( + "{{\"tokens\":{{\"account_id\":\"acct_new\",\"id_token\":\"{}\"}}}}", + id_token("user@example.com") + ), + ) + .unwrap(); + + // Same account (matched by email) → write back to "a", not refused… + assert_eq!( + resolve_backup_target(&backup_root, &codex_home).as_deref(), + Some("a") + ); + // …and not flagged unmanaged. + assert_eq!(detect_unmanaged_live_account(&backup_root, &codex_home), None); + let _ = fs::remove_dir_all(&codex_home); + } + // detect_unmanaged_live_account: live account owned by no profile → Some(label). #[test] fn detect_flags_unmanaged_live_account_with_label() {