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();