diff --git a/CHANGELOG.md b/CHANGELOG.md index af25aed..696b015 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # 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 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 - 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/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 1c3c67d..8cced7c 100644 --- a/src-tauri/shared/runtime/metadata.rs +++ b/src-tauri/shared/runtime/metadata.rs @@ -125,6 +125,121 @@ fn load_auth_metadata_from_path(auth_path: &Path) -> Option Some(metadata) } +/// Stable account identity for an on-disk `auth.json`: the OAuth `account_id` +/// and the id_token / access_token `email` claim, whichever are present. +/// +/// 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. +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?; + + 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)) + .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 +/// 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>, @@ -699,4 +814,121 @@ mod tests { assert_eq!(derived.subscription_expires_at, None); assert!(!derived.has_plan_claims); } + + #[test] + 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!( + "{{\"tokens\":{{\"account_id\":\"acct_123\",\"id_token\":{}}}}}", + serde_json::Value::String(id_token) + ); + std::fs::write(dir.join("auth.json"), auth).unwrap(); + 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 → email-only identity. + let dir2 = temp_dir("identity-email-fallback"); + write_auth_with_id_token(&dir2, &synthesize_jwt(r#"{"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"); + 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); + } + + #[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"); + 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/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 e193ee4..02da591 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::{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 { @@ -45,3 +46,319 @@ 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).is_some_and(|slot| slot.same_account(&root_identity)) { + return marked; + } + } + + // (3) Live account drifted to a different *managed* profile → route there. + // The marker wins ties (handled by the early return in (2)). + if let Some(owner) = find_profile_owning_identity(backup_root, &root_identity) { + return Some(owner); + } + + // (4) Marker slot is a *genuine* empty placeholder (no creds of any kind) + // and the live account belongs to no profile yet → seat the fresh login + // into the marked card. An API-key / malformed / unreadable slot is NOT + // a placeholder (its `None` identity means "no OAuth identity", not + // "empty") and must never be overwritten, or an API-key card would lose + // its credentials. + if let Some(marked_profile) = marked.as_deref() { + if auth_is_empty_placeholder(&backup_root.join(marked_profile).join("auth.json")) { + return marked; + } + } + + // (5) Live account is unmanaged and the marker slot holds a different, + // identified account → refuse to overwrite it. + None +} + +/// 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")) + .is_some_and(|slot| slot.same_account(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 account 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; + } + root_identity.label() +} + +#[cfg(test)] +mod tests { + use super::{detect_unmanaged_live_account, 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); + } + + // (4 guard) Marker points at an API-key card (no OAuth identity, but real + // credentials). A drifted OAuth root must NOT be seated on top of it. + #[test] + fn does_not_seat_drifted_oauth_account_onto_apikey_marker_slot() { + let (codex_home, backup_root) = setup("apikey-not-seatable"); + let apikey = r#"{"auth_mode":"apikey","OPENAI_API_KEY":"sk-real-key"}"#; + write_profile(&backup_root, "a", apikey); + set_marker(&backup_root, "a"); + // Live root drifted to an OAuth account owned by no card. + fs::write(codex_home.join("auth.json"), auth_with_account("acct_OAUTH")).unwrap(); + + // Must refuse (case 5), not seat into the API-key card (case 4)… + assert_eq!(resolve_backup_target(&backup_root, &codex_home), None); + // …and the API-key card's credentials stay intact. + assert_eq!( + fs::read_to_string(backup_root.join("a").join("auth.json")).unwrap(), + apikey + ); + 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() { + 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..831c4e1 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}; @@ -369,7 +370,23 @@ 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(); + + // Surface "the live ~/.codex account isn't saved to any card" so the + // dashboard can prompt the user. Recomputed every snapshot so it reflects + // reality even between launches (e.g. an external `codex login` mid-session). + let unmanaged_live_account = + detect_unmanaged_live_account(&get_backup_root(Some(&codex_home)), &codex_home); + + // When the live account is unmanaged, the `.current_profile` marker is stale + // (it names a card the live account doesn't belong to). Bootstrap clears it + // on launch, but for mid-session drift it hasn't run yet — so suppress the + // current card here too, or this snapshot would both flag the live account + // as unmanaged AND show an old card as "current". + let current_profile = if unmanaged_live_account.is_some() { + None + } else { + index.current_profile.as_deref() + }; let current_entry = current_profile.and_then(|profile_name| { index .profiles @@ -392,6 +409,7 @@ 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, @@ -428,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); + } +} diff --git a/src-tauri/shared/runtime/switch_core.rs b/src-tauri/shared/runtime/switch_core.rs index 3a5389b..cee5bb2 100644 --- a/src-tauri/shared/runtime/switch_core.rs +++ b/src-tauri/shared/runtime/switch_core.rs @@ -5,11 +5,14 @@ use crate::models::SwitchResponse; use crate::platform::hooks::PlatformHooks; use super::fs_ops::{ - autosave_auth, backup_root_state_to_profile, overlay_directory_contents, set_active_marker, + autosave_auth, backup_root_state_to_profile, clear_active_markers, overlay_directory_contents, + set_active_marker, }; 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::{ + detect_unmanaged_live_account, resolve_backup_target, resolve_current_profile, +}; use super::profiles_index::load_profiles_index; fn acquire_switch_lock(codex_home: Option<&Path>) -> AppResult { @@ -54,9 +57,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 +81,49 @@ 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 { + // 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); + }; + + // 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 +249,165 @@ 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 clear the stale marker so + // the dashboard stops showing a wrong "current" card. + #[test] + 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(); + + 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") + ); + // 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); + } + + // 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 {