From 4fcd8a3fadf3882f873c7cd5a0294085882c8f91 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 25 Aug 2026 15:38:43 +0000 Subject: [PATCH] fix(import): skip re-importing a saved account instead of duplicating it (#59) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit import is create-only, so re-importing an already-saved account wrote a second profile for it. Because OpenAI refresh tokens are single-use, the two copies then raced: whichever refreshed first rotated the token and the other died with refresh_token_reused, forcing a full re-login. Worse, the usage-service validation that runs during import rotates the token itself, so the duplicate was created with a fresh token while the source file (and any existing profile sharing that token) was left holding a dead one. Add profile::existing_import_target: a read-only check that returns the saved profile these credentials already belong to — byte-identical to a stored profile, or the same account_id AND email. import now consults it before the token-rotating validation and skips the file (action "unchanged") when a match is found, so no duplicate is written and the single-use token is never spent. The check only declines, never overwrites, so it cannot hand credentials to the wrong profile and keeps the deliberate create-only safety of save_imported_auth_value intact. Docs updated; unit test covers identity match and a distinct-account miss. Co-authored-by: xJoker --- docs/wiki/Command-Reference.md | 2 +- docs/wiki/Feature-Guide.md | 2 +- src/commands/import.rs | 31 +++++++++++++++++++++ src/profile.rs | 50 ++++++++++++++++++++++++++++++++++ 4 files changed, 83 insertions(+), 2 deletions(-) diff --git a/docs/wiki/Command-Reference.md b/docs/wiki/Command-Reference.md index e0c64f8..f7e1f7f 100644 --- a/docs/wiki/Command-Reference.md +++ b/docs/wiki/Command-Reference.md @@ -7,7 +7,7 @@ The installed binary remains authoritative: use `codex-switch --help` and `codex | Command | Purpose | |---|---| | `login [--device] [alias]` | Add or reauthorize a profile through browser PKCE or device-code login. If the alias already exists, it is reauthorized; otherwise a new profile is created. | -| `import [alias]` | Validate and import one `auth.json`, or recursively scan a directory for JSON files. The alias applies to single-file imports only; directories auto-assign aliases. | +| `import [alias]` | Validate and import one `auth.json`, or recursively scan a directory for JSON files. The alias applies to single-file imports only; directories auto-assign aliases. An account that is already saved (same file, or same `account_id` and email) is skipped instead of duplicated, so its single-use refresh token is not spent. | | `list [-f]` | Show profiles, usage, and availability; `-f` / `--force` bypasses the cache. | | `use [alias] [--consume-card]` | Switch explicitly, or omit the alias to auto-select with the unified scoring algorithm. When the pool is exhausted, `--consume-card` consumes the earliest-expiring reset card to revive an account (auto-select only; ignored when an alias is given). | | `launch [alias] [--consume-card] -- [args]` | Start Codex with the best (or specified) profile's auth. Everything after `--` is passed through to Codex. | diff --git a/docs/wiki/Feature-Guide.md b/docs/wiki/Feature-Guide.md index ceeeba3..1376105 100644 --- a/docs/wiki/Feature-Guide.md +++ b/docs/wiki/Feature-Guide.md @@ -19,7 +19,7 @@ Existing `auth.json` files can be imported individually or from a directory. Imp codex-switch import ~/auth-backups ``` -Interactive login deduplicates local profiles by `account_id` first and falls back to email when safe. Import is deliberately create-only and never updates an existing profile: Usage API validation proves that the bearer can access a workspace, but a Team workspace ID can be shared by several users and cannot authorize overwriting another saved credential. +Interactive login deduplicates local profiles by `account_id` first and falls back to email when safe. Import is deliberately create-only and never updates an existing profile: Usage API validation proves that the bearer can access a workspace, but a Team workspace ID can be shared by several users and cannot authorize overwriting another saved credential. For the same reason, import will not write a *second* profile for an account you already have: when the incoming file is byte-identical to a saved profile, or carries the same `account_id` **and** email, the import is skipped before validation so its single-use refresh token is never spent. Use `login ` to refresh an existing profile. Profile deletion is recoverable. An inactive profile is moved under `deleted-profiles/` after confirmation; the active profile cannot be deleted. See [recovery instructions](Troubleshooting#recover-a-deleted-profile). diff --git a/src/commands/import.rs b/src/commands/import.rs index 42b8f1c..7fb7c6e 100644 --- a/src/commands/import.rs +++ b/src/commands/import.rs @@ -112,6 +112,17 @@ pub(crate) async fn import_cmd(path: &str, alias: Option<&str>, json: bool) -> R alias: imported.alias, action: imported.action.to_string(), }); + } else if imported.action == "unchanged" { + println!( + "{}", + color::success(&format!( + "Already saved as profile '{}'; skipped {} to protect its single-use refresh \ + token. Run `codex-switch login {}` to refresh that profile.", + imported.alias, + imported.source.display(), + imported.alias + )) + ); } else { println!( "{}", @@ -280,6 +291,26 @@ async fn import_one_file( .as_deref() .map(profile::alias_from_email); + // Refuse to duplicate an account that is already saved. `import` is + // create-only, so validating (which rotates the single-use refresh token) + // and then writing a second profile would race the two copies into + // `refresh_token_reused`. This runs *before* validation so the source + // token is never spent on a re-import. It only declines — it never + // overwrites — so a conservative match cannot hand credentials to the + // wrong profile. + if let Some(existing) = profile::existing_import_target(source, &val) { + let mut account = source_account; + cache::apply_workspace_name(&mut account); + let usage = cache::get(&existing).unwrap_or_default(); + return Ok(profile::ImportSuccess { + source: source.to_path_buf(), + alias: existing, + action: "unchanged", + account, + usage, + }); + } + let usage::ImportValidation { refreshed, validated_account_id, diff --git a/src/profile.rs b/src/profile.rs index c2b80ee..02007e9 100644 --- a/src/profile.rs +++ b/src/profile.rs @@ -507,6 +507,27 @@ pub fn find_profile_by_identity(identity: &AccountIdentity) -> Option { exact.or_else(|| email_only.into_iter().next()) } +/// The saved profile that these to-be-imported credentials already belong to, +/// if any. +/// +/// `import` is deliberately create-only (see [`save_imported_auth_value`]), so a +/// re-import of an already-saved account would otherwise write a *second* +/// profile for it. Because OpenAI refresh tokens are single-use, the two copies +/// then race: whichever refreshes first rotates the token and the other dies +/// with `refresh_token_reused`, forcing a full re-login. Callers use this to +/// skip such a re-import instead of duplicating the account. +/// +/// Detection is intentionally conservative and read-only — it never writes and +/// never overwrites, so a false positive can only decline an import, never hand +/// an account to the wrong profile: +/// - byte-identical to a stored profile ([`find_matching_profile`]), or +/// - the exact same `account_id` **and** `email` as a stored profile +/// ([`find_profile_by_identity_exact`]). Requiring the email too means a +/// shared Team `account_id` alone never matches a different member. +pub fn existing_import_target(source: &Path, val: &serde_json::Value) -> Option { + find_matching_profile(source).or_else(|| find_profile_by_identity_exact(&extract_identity(val))) +} + pub fn alias_from_email(email: &str) -> String { let base = email.split('@').next().unwrap_or(email); let alias = base @@ -1762,6 +1783,35 @@ mod tests { )); } + #[test] + fn existing_import_target_matches_saved_account_and_ignores_others() { + let env = TestEnv::new(); + // Save a profile for alice / acct_1. + let val = realistic_auth_json("alice@example.com", "acct_1", "acc_a", "ref_a"); + let live = crate::auth::codex_auth_path().unwrap(); + crate::auth::write_auth(&live, &val).unwrap(); + super::cmd_save(Some("alice")).unwrap(); + + // A fresh dump of the SAME account with rotated tokens (so the bytes, + // and thus the file hash, differ) is still detected by account_id + + // email — this is exactly the re-import that would otherwise duplicate + // the account and spend its single-use refresh token. + let reimport = realistic_auth_json("alice@example.com", "acct_1", "acc_new", "ref_new"); + let src = env._home.path().join("incoming.json"); + std::fs::write(&src, serde_json::to_vec(&reimport).unwrap()).unwrap(); + assert_eq!( + super::existing_import_target(&src, &reimport).as_deref(), + Some("alice"), + "a re-import of a saved account must be detected so it can be skipped" + ); + + // A genuinely different account is not matched, so it still imports. + let other = realistic_auth_json("bob@example.com", "acct_2", "acc_b", "ref_b"); + let other_src = env._home.path().join("other.json"); + std::fs::write(&other_src, serde_json::to_vec(&other).unwrap()).unwrap(); + assert_eq!(super::existing_import_target(&other_src, &other), None); + } + #[test] fn detect_new_account_when_no_profiles_exist() { let _env = TestEnv::new();