Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
14 changes: 4 additions & 10 deletions src-tauri/mac/runtime/bootstrap.rs
Original file line number Diff line number Diff line change
@@ -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;

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

pub fn sync_root_state_to_current_profile(codex_home: Option<&Path>) -> AppResult<Option<String>> {
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(&current_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<bool> {
Expand Down
18 changes: 18 additions & 0 deletions src-tauri/shared/front/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,23 @@ async function refreshActiveQuotaSilently(): Promise<void> {
}
}

// 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<void> {
try {
const [snapshot, currentQuota] = await Promise.all([
Expand All @@ -209,6 +226,7 @@ async function refreshAllData(showError = true): Promise<void> {
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);
Expand Down
4 changes: 4 additions & 0 deletions src-tauri/shared/front/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}",
Expand Down Expand Up @@ -490,6 +492,8 @@ const messages: Record<Locale, Messages> = {
codexCliPathRejected: "这个路径是 Codex Switch 自身的 shim,请选择真正的 codex 二进制。",
codexCliPathSaveFailed: "保存 Codex CLI 路径失败。",
codexCliNotFoundToast: "找不到 codex CLI,请先指定它的位置。",
unmanagedAccountToast:
"当前登录的账号({account})不在任何卡片中。请切换到某张卡片,或将它新建为一张卡片。",
codexCliRetryLogin: "保存并重试登录",
profileLoginCancelHint: "登录进行中,点击取消",
profileLoginCancelAria: "取消 {profile} 的登录",
Expand Down
2 changes: 2 additions & 0 deletions src-tauri/shared/front/tauri.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ActionResponse> {
Expand All @@ -166,6 +167,7 @@ function refreshPreviewSnapshot(): void {
profiles: clone(previewSnapshot.profiles),
current_card: clone(previewCurrentCard),
current_quota_card: clone(previewCurrentQuota),
unmanaged_live_account: null,
};
}

Expand Down
3 changes: 3 additions & 0 deletions src-tauri/shared/front/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
12 changes: 12 additions & 0 deletions src-tauri/shared/runtime/fs_ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()))
}
232 changes: 232 additions & 0 deletions src-tauri/shared/runtime/metadata.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,121 @@ fn load_auth_metadata_from_path(auth_path: &Path) -> Option<AuthDerivedMetadata>
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<String>,
pub email: Option<String>,
}

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<String> {
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<AccountIdentity> {
let raw = fs::read_to_string(auth_path).ok()?;
let auth = serde_json::from_str::<AuthFile>(&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::<serde_json::Value>(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>,
Expand Down Expand Up @@ -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")));
}
}
4 changes: 4 additions & 0 deletions src-tauri/shared/runtime/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,10 @@ pub struct ProfilesSnapshotResponse {
pub profiles: Vec<ProfileCard>,
pub current_card: Option<CurrentCard>,
pub current_quota_card: Option<QuotaSummary>,
/// 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<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
Expand Down
Loading
Loading