Skip to content

Commit 155389a

Browse files
authored
fix(switch): identity-guard account write-back to stop profile cross-contamination
Profile switch and the launch-time bootstrap blind-copied the live ~/.codex state into whatever profile the .current_profile marker named, with no check that the account actually in auth.json is the one that profile holds. A drifted live account (manual codex login, official app re-auth, hand-edits) then overwrote an unrelated profile's credentials — "串号" / cross-contamination, including on a plain relaunch. Write-back is now gated by resolve_backup_target, which fingerprints the live account (account_id and/or id_token email, matched on either) and only saves it into the profile that owns it; a drift to another managed profile is rerouted + the marker healed; an unmanaged account is refused, the stale marker cleared, the current card suppressed, and the dashboard prompts the user. API-key / placeholder / malformed slots are never overwritten. Identity-checked bootstrap is shared across macOS/Windows. 123 lib tests; CI green; three Codex review P2s addressed.
1 parent 70631ef commit 155389a

13 files changed

Lines changed: 922 additions & 27 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
- **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.
6+
- 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.
7+
38
## 1.5.12 - 2026-05-29
49

510
- 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.

src-tauri/mac/runtime/bootstrap.rs

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
use std::path::{Path, PathBuf};
22

33
use crate::errors::AppResult;
4-
use crate::shared::fs_ops::backup_root_state_to_profile;
54
use crate::shared::paths::{get_backup_root, get_codex_home};
65
use crate::shared::profiles::resolve_current_profile;
76

@@ -20,15 +19,10 @@ const REFRESH_RUNTIME_DEFAULT_CONFIG: &str = concat!(
2019
);
2120

2221
pub fn sync_root_state_to_current_profile(codex_home: Option<&Path>) -> AppResult<Option<String>> {
23-
let codex_home = codex_home.map(PathBuf::from).unwrap_or_else(get_codex_home);
24-
let backup_root = get_backup_root(Some(&codex_home));
25-
let Some(current_profile) = resolve_current_profile(&backup_root) else {
26-
return Ok(None);
27-
};
28-
29-
backup_root_state_to_profile(&current_profile, &codex_home, &backup_root)?;
30-
crate::shared::profiles_index::load_profiles_index(Some(&codex_home))?;
31-
Ok(Some(current_profile))
22+
// Identity-checked write-back lives in the shared layer so macOS and
23+
// Windows can't drift apart. See
24+
// `switch_core::sync_root_state_to_current_profile_with_home`.
25+
crate::shared::switch_core::sync_root_state_to_current_profile_with_home(codex_home)
3226
}
3327

3428
pub fn ensure_backup_initialized(codex_home: Option<&Path>) -> AppResult<bool> {

src-tauri/shared/front/actions.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,23 @@ async function refreshActiveQuotaSilently(): Promise<void> {
199199
}
200200
}
201201

202+
// Tracks the last unmanaged account we prompted about so a single drift event
203+
// shows the toast once, not on every dashboard refresh. Resets to null when the
204+
// live account is managed again, so a later drift re-prompts.
205+
let lastUnmanagedAccountPrompt: string | null = null;
206+
207+
function maybePromptUnmanagedAccount(account: string | null): void {
208+
if (!account) {
209+
lastUnmanagedAccountPrompt = null;
210+
return;
211+
}
212+
if (account === lastUnmanagedAccountPrompt) {
213+
return;
214+
}
215+
lastUnmanagedAccountPrompt = account;
216+
showToast(t(state.locale, "unmanagedAccountToast", { account }), true);
217+
}
218+
202219
async function refreshAllData(showError = true): Promise<void> {
203220
try {
204221
const [snapshot, currentQuota] = await Promise.all([
@@ -209,6 +226,7 @@ async function refreshAllData(showError = true): Promise<void> {
209226
applySnapshot(snapshot);
210227
applyCurrentQuota(currentQuota);
211228
rerenderDashboard();
229+
maybePromptUnmanagedAccount(snapshot.unmanaged_live_account);
212230
} catch (error) {
213231
if (showError) {
214232
showToast(error instanceof Error ? error.message : "Failed to load dashboard.", true);

src-tauri/shared/front/i18n.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,8 @@ const enMessages = {
233233
codexCliPathSaveFailed: "Failed to save codex CLI path.",
234234
codexCliNotFoundToast:
235235
"Codex CLI not found. Pick the binary location to continue.",
236+
unmanagedAccountToast:
237+
"The account currently signed in ({account}) isn't saved to any card. Switch to a card, or add it as a new one.",
236238
codexCliRetryLogin: "Save & retry login",
237239
profileLoginCancelHint: "Login in progress — click to cancel",
238240
profileLoginCancelAria: "Cancel login for {profile}",
@@ -490,6 +492,8 @@ const messages: Record<Locale, Messages> = {
490492
codexCliPathRejected: "这个路径是 Codex Switch 自身的 shim,请选择真正的 codex 二进制。",
491493
codexCliPathSaveFailed: "保存 Codex CLI 路径失败。",
492494
codexCliNotFoundToast: "找不到 codex CLI,请先指定它的位置。",
495+
unmanagedAccountToast:
496+
"当前登录的账号({account})不在任何卡片中。请切换到某张卡片,或将它新建为一张卡片。",
493497
codexCliRetryLogin: "保存并重试登录",
494498
profileLoginCancelHint: "登录进行中,点击取消",
495499
profileLoginCancelAria: "取消 {profile} 的登录",

src-tauri/shared/front/tauri.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ let previewSnapshot: ProfilesSnapshotResponse = {
150150
profiles: clone(previewProfiles),
151151
current_card: clone(previewCurrentCard),
152152
current_quota_card: clone(previewCurrentQuota),
153+
unmanaged_live_account: null,
153154
};
154155

155156
function mockAction(message: string, path: string | null = null): Promise<ActionResponse> {
@@ -166,6 +167,7 @@ function refreshPreviewSnapshot(): void {
166167
profiles: clone(previewSnapshot.profiles),
167168
current_card: clone(previewCurrentCard),
168169
current_quota_card: clone(previewCurrentQuota),
170+
unmanaged_live_account: null,
169171
};
170172
}
171173

src-tauri/shared/front/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@ export interface ProfilesSnapshotResponse {
5555
profiles: ProfileCard[];
5656
current_card: CurrentCard | null;
5757
current_quota_card: QuotaSummary | null;
58+
/** Label of the live `~/.codex` account when it belongs to no saved card
59+
* (drift to an unmanaged account); `null` in the normal case. */
60+
unmanaged_live_account: string | null;
5861
}
5962

6063
export interface CurrentQuotaResponse {

src-tauri/shared/runtime/fs_ops.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,3 +239,15 @@ pub fn set_active_marker(profile: &str, backup_root: &Path) -> AppResult<()> {
239239
)
240240
})
241241
}
242+
243+
/// Clear every active-profile marker and the `.current_profile` pointer,
244+
/// leaving no profile flagged as current. Used when the live `~/.codex`
245+
/// account has drifted to an account no managed profile owns: keeping a stale
246+
/// marker would make the dashboard show a wrong "current" card, so we drop the
247+
/// pointer entirely and let the UI surface the unmanaged-account prompt.
248+
pub fn clear_active_markers(backup_root: &Path) -> AppResult<()> {
249+
for profile_dir in list_profile_dirs(backup_root) {
250+
remove_path(&profile_dir.join(ACTIVE_MARKER_FILE))?;
251+
}
252+
remove_path(&get_current_profile_file(backup_root.parent()))
253+
}

src-tauri/shared/runtime/metadata.rs

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,121 @@ fn load_auth_metadata_from_path(auth_path: &Path) -> Option<AuthDerivedMetadata>
125125
Some(metadata)
126126
}
127127

128+
/// Stable account identity for an on-disk `auth.json`: the OAuth `account_id`
129+
/// and the id_token / access_token `email` claim, whichever are present.
130+
///
131+
/// Carries both (rather than a single fingerprint) because one account can
132+
/// present an email-only auth before a refresh adds `account_id` — matching on
133+
/// a single prefixed string would then treat the same account as a stranger.
134+
#[derive(Clone, Debug, Default, PartialEq, Eq)]
135+
pub struct AccountIdentity {
136+
pub account_id: Option<String>,
137+
pub email: Option<String>,
138+
}
139+
140+
impl AccountIdentity {
141+
/// Two identities refer to the same OpenAI account when they share a
142+
/// non-empty `account_id` OR a non-empty `email`. Each field is globally
143+
/// unique to one account, so OR-matching can never merge two distinct
144+
/// accounts; but it *does* keep a legacy email-only slot matching the same
145+
/// account after a later refresh writes `account_id`.
146+
pub fn same_account(&self, other: &AccountIdentity) -> bool {
147+
if let (Some(left), Some(right)) = (&self.account_id, &other.account_id) {
148+
if left == right {
149+
return true;
150+
}
151+
}
152+
if let (Some(left), Some(right)) = (&self.email, &other.email) {
153+
if left.eq_ignore_ascii_case(right) {
154+
return true;
155+
}
156+
}
157+
false
158+
}
159+
160+
/// Human label for prompts: email preferred, else the account id.
161+
pub fn label(&self) -> Option<String> {
162+
self.email.clone().or_else(|| self.account_id.clone())
163+
}
164+
}
165+
166+
/// Load the account identity from an `auth.json`. Returns `None` only when
167+
/// neither `account_id` nor `email` is resolvable — placeholder cards
168+
/// (`replace-me`), apikey-mode auth (no `tokens`), or an unreadable / absent
169+
/// file. Callers MUST treat `None` as "identity unknown" (preserve legacy
170+
/// behavior) rather than as a mismatch, so apikey / placeholder profiles keep
171+
/// refreshing normally.
172+
pub fn load_account_identity_from_path(auth_path: &Path) -> Option<AccountIdentity> {
173+
let raw = fs::read_to_string(auth_path).ok()?;
174+
let auth = serde_json::from_str::<AuthFile>(&raw).ok()?;
175+
let tokens = auth.tokens?;
176+
177+
let account_id = normalized_value(tokens.account_id.clone());
178+
let email = tokens
179+
.id_token
180+
.as_deref()
181+
.and_then(decode_token_claims)
182+
.or_else(|| tokens.access_token.as_deref().and_then(decode_token_claims))
183+
.and_then(|claims| normalized_value(claims.email));
184+
185+
let identity = AccountIdentity { account_id, email };
186+
(identity != AccountIdentity::default()).then_some(identity)
187+
}
188+
189+
/// True only when `auth.json` is a genuine empty placeholder — it parses and
190+
/// carries no usable credentials of any kind (no OAuth tokens, no API key). The
191+
/// switch / bootstrap write-back uses this to decide whether a marked slot may
192+
/// receive a drifted login.
193+
///
194+
/// Conservative by design: a missing / unreadable / malformed file, an API-key
195+
/// card (`auth_mode = "apikey"` or a non-empty `OPENAI_API_KEY`), or any real
196+
/// OAuth auth all return `false`. This is what keeps a drifted OAuth account
197+
/// from being seated on top of an API-key card's real credentials — `None`
198+
/// identity means "no OAuth identity," which is NOT the same as "empty slot".
199+
pub fn auth_is_empty_placeholder(auth_path: &Path) -> bool {
200+
let Ok(raw) = fs::read_to_string(auth_path) else {
201+
return false;
202+
};
203+
let trimmed = raw.trim();
204+
if trimmed.is_empty() {
205+
return true;
206+
}
207+
let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed) else {
208+
return false;
209+
};
210+
211+
// API-key card → real, non-OAuth credentials. Never seatable.
212+
if value.get("auth_mode").and_then(serde_json::Value::as_str) == Some("apikey") {
213+
return false;
214+
}
215+
if value
216+
.get("OPENAI_API_KEY")
217+
.and_then(serde_json::Value::as_str)
218+
.is_some_and(|key| !key.trim().is_empty())
219+
{
220+
return false;
221+
}
222+
223+
// Any usable OAuth token material → real card. Placeholder seeds use the
224+
// `replace-me` sentinel, which doesn't count.
225+
if let Some(tokens) = value.get("tokens") {
226+
let has_real_token = ["access_token", "id_token", "refresh_token", "account_id"]
227+
.iter()
228+
.any(|field| {
229+
tokens
230+
.get(field)
231+
.and_then(serde_json::Value::as_str)
232+
.map(str::trim)
233+
.is_some_and(|value| !value.is_empty() && !value.eq_ignore_ascii_case("replace-me"))
234+
});
235+
if has_real_token {
236+
return false;
237+
}
238+
}
239+
240+
true
241+
}
242+
128243
fn load_auth_metadata(
129244
profile_name: &str,
130245
codex_home: Option<&Path>,
@@ -699,4 +814,121 @@ mod tests {
699814
assert_eq!(derived.subscription_expires_at, None);
700815
assert!(!derived.has_plan_claims);
701816
}
817+
818+
#[test]
819+
fn account_identity_captures_account_id_and_email() {
820+
// Both account_id and the id_token email are captured.
821+
let dir = temp_dir("identity-account-id");
822+
let id_token = synthesize_jwt(r#"{"email":"user@example.com"}"#);
823+
let auth = format!(
824+
"{{\"tokens\":{{\"account_id\":\"acct_123\",\"id_token\":{}}}}}",
825+
serde_json::Value::String(id_token)
826+
);
827+
std::fs::write(dir.join("auth.json"), auth).unwrap();
828+
let id = load_account_identity_from_path(&dir.join("auth.json")).unwrap();
829+
assert_eq!(id.account_id.as_deref(), Some("acct_123"));
830+
assert_eq!(id.email.as_deref(), Some("user@example.com"));
831+
832+
// No account_id → email-only identity.
833+
let dir2 = temp_dir("identity-email-fallback");
834+
write_auth_with_id_token(&dir2, &synthesize_jwt(r#"{"email":"who@example.com"}"#));
835+
let id2 = load_account_identity_from_path(&dir2.join("auth.json")).unwrap();
836+
assert_eq!(id2.account_id, None);
837+
assert_eq!(id2.email.as_deref(), Some("who@example.com"));
838+
839+
// apikey mode (no tokens) → no resolvable identity.
840+
let dir3 = temp_dir("identity-apikey");
841+
std::fs::write(dir3.join("auth.json"), r#"{"auth_mode":"apikey"}"#).unwrap();
842+
assert_eq!(load_account_identity_from_path(&dir3.join("auth.json")), None);
843+
844+
// Placeholder account_id (`replace-me`) with no email → no identity.
845+
let dir4 = temp_dir("identity-placeholder");
846+
std::fs::write(
847+
dir4.join("auth.json"),
848+
r#"{"tokens":{"account_id":"replace-me"}}"#,
849+
)
850+
.unwrap();
851+
assert_eq!(load_account_identity_from_path(&dir4.join("auth.json")), None);
852+
}
853+
854+
#[test]
855+
fn account_identity_same_account_matches_on_either_field() {
856+
// Email-only identity vs. account_id+email for the same account → same.
857+
let email_only = AccountIdentity {
858+
account_id: None,
859+
email: Some("user@example.com".to_string()),
860+
};
861+
let with_account_id = AccountIdentity {
862+
account_id: Some("acct_1".to_string()),
863+
email: Some("user@example.com".to_string()),
864+
};
865+
assert!(email_only.same_account(&with_account_id));
866+
assert!(with_account_id.same_account(&email_only));
867+
868+
// Same account_id, different/absent email → still same.
869+
let id_a = AccountIdentity {
870+
account_id: Some("acct_1".to_string()),
871+
email: None,
872+
};
873+
let id_b = AccountIdentity {
874+
account_id: Some("acct_1".to_string()),
875+
email: Some("x@y.com".to_string()),
876+
};
877+
assert!(id_a.same_account(&id_b));
878+
879+
// Different account_id and different email → distinct accounts.
880+
let other = AccountIdentity {
881+
account_id: Some("acct_2".to_string()),
882+
email: Some("other@example.com".to_string()),
883+
};
884+
assert!(!with_account_id.same_account(&other));
885+
886+
// No shared identifiable field → cannot prove same; treat as distinct.
887+
let acct_only = AccountIdentity {
888+
account_id: Some("acct_3".to_string()),
889+
email: None,
890+
};
891+
let mail_only = AccountIdentity {
892+
account_id: None,
893+
email: Some("z@z.com".to_string()),
894+
};
895+
assert!(!acct_only.same_account(&mail_only));
896+
}
897+
898+
#[test]
899+
fn auth_is_empty_placeholder_only_for_credential_free_slots() {
900+
let dir = temp_dir("placeholder-detect");
901+
let path = dir.join("auth.json");
902+
903+
// Genuine placeholder: `replace-me` tokens only.
904+
std::fs::write(
905+
&path,
906+
r#"{"tokens":{"access_token":"replace-me","account_id":"replace-me"}}"#,
907+
)
908+
.unwrap();
909+
assert!(auth_is_empty_placeholder(&path), "replace-me seed is seatable");
910+
911+
// Empty file → seatable.
912+
std::fs::write(&path, " \n").unwrap();
913+
assert!(auth_is_empty_placeholder(&path), "empty file is seatable");
914+
915+
// API-key card (auth_mode) → NOT a placeholder.
916+
std::fs::write(&path, r#"{"auth_mode":"apikey","OPENAI_API_KEY":"sk-x"}"#).unwrap();
917+
assert!(!auth_is_empty_placeholder(&path), "apikey card is not seatable");
918+
919+
// Bare OPENAI_API_KEY without auth_mode → NOT a placeholder.
920+
std::fs::write(&path, r#"{"OPENAI_API_KEY":"sk-y"}"#).unwrap();
921+
assert!(!auth_is_empty_placeholder(&path), "raw api key is not seatable");
922+
923+
// Real OAuth token material → NOT a placeholder.
924+
std::fs::write(&path, r#"{"tokens":{"account_id":"acct_real"}}"#).unwrap();
925+
assert!(!auth_is_empty_placeholder(&path), "real oauth is not seatable");
926+
927+
// Malformed JSON → conservative false (don't overwrite the unknown).
928+
std::fs::write(&path, "{not json").unwrap();
929+
assert!(!auth_is_empty_placeholder(&path), "malformed is not seatable");
930+
931+
// Missing file → false.
932+
assert!(!auth_is_empty_placeholder(&dir.join("nope.json")));
933+
}
702934
}

src-tauri/shared/runtime/models.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,10 @@ pub struct ProfilesSnapshotResponse {
131131
pub profiles: Vec<ProfileCard>,
132132
pub current_card: Option<CurrentCard>,
133133
pub current_quota_card: Option<QuotaSummary>,
134+
/// Set when the live `~/.codex` account has a resolvable identity that no
135+
/// managed profile owns (drift to an unmanaged account) — carries a label
136+
/// for the dashboard prompt. `None` in the normal case.
137+
pub unmanaged_live_account: Option<String>,
134138
}
135139

136140
#[derive(Debug, Clone, Serialize, Deserialize)]

0 commit comments

Comments
 (0)