diff --git a/src-tauri/src/codex_config.rs b/src-tauri/src/codex_config.rs index 61764425c..60399bf75 100644 --- a/src-tauri/src/codex_config.rs +++ b/src-tauri/src/codex_config.rs @@ -417,6 +417,93 @@ pub fn codex_auth_has_oauth_login_material(auth: &Value) -> bool { }) } +/// True only when the auth carries material Codex itself authenticates with +/// ahead of the API-key fallback: OAuth tokens or another first-class login +/// carrier. Unlike `codex_auth_has_oauth_login_material`, pure metadata such +/// as `last_refresh` or `tokens.account_id` does NOT count — metadata must not +/// shield a stale third-party `OPENAI_API_KEY` from post-switch cleanup. +pub fn codex_auth_has_credential_login_material(auth: &Value) -> bool { + let Some(obj) = auth.as_object() else { + return false; + }; + + let value_present = |value: &Value| match value { + Value::Null => false, + Value::String(text) => !text.trim().is_empty(), + Value::Array(items) => !items.is_empty(), + Value::Object(map) => !map.is_empty(), + _ => true, + }; + + if ["personal_access_token", "agent_identity", "bedrock_api_key"] + .iter() + .any(|key| obj.get(*key).is_some_and(value_present)) + { + return true; + } + + obj.get("tokens") + .and_then(Value::as_object) + .is_some_and(|tokens| { + ["id_token", "access_token", "refresh_token"] + .iter() + .any(|key| tokens.get(*key).is_some_and(value_present)) + }) +} + +/// True when live `auth.json` is the shape a preserve-off third-party switch +/// leaves behind: an `OPENAI_API_KEY` (possibly alongside metadata like +/// `auth_mode` / `last_refresh`) with no real login credential next to it. +pub fn codex_live_auth_is_stale_third_party_residue(live_auth: &Value) -> bool { + if codex_auth_has_credential_login_material(live_auth) { + return false; + } + live_auth + .get("OPENAI_API_KEY") + .and_then(Value::as_str) + .map(str::trim) + .is_some_and(|key| !key.is_empty()) +} + +/// After a normal switch to an official provider that carries no login +/// material of its own, delete a live `auth.json` that only holds a stale +/// third-party API key, so Codex shows its login screen instead of sending +/// the wrong key to the official endpoint (401 with no way to re-login). +/// +/// Deleting the file — not writing `{}` — is deliberate: Codex resolves an +/// empty object to ChatGPT mode without tokens and errors at bootstrap, +/// while a missing file yields NotAuthenticated and the login screen, +/// matching Codex's own logout. +/// +/// Callers must only invoke this after the outgoing provider was +/// successfully backfilled into the DB — that backfill holds the only other +/// copy of the third-party key. The switch backfill intentionally lacks the +/// proxy-side "no credentials in the builtin official row" guard +/// (`services/proxy.rs` `sync_live_config_to_provider`): that asymmetry is +/// what heals official API-key logins into the DB row, and this cleanup's +/// safety depends on it — do not align the two guards. +/// +/// Returns Ok(true) when the file was deleted. +pub fn clear_stale_codex_live_auth_after_official_switch( + db_auth: &Value, +) -> Result { + if codex_auth_has_login_material(db_auth) { + // A material-carrying official provider gets a full auth write; + // nothing stale can remain. + return Ok(false); + } + let auth_path = get_codex_auth_path(); + if !auth_path.exists() { + return Ok(false); + } + let live_auth: Value = read_json_file(&auth_path)?; + if !codex_live_auth_is_stale_third_party_residue(&live_auth) { + return Ok(false); + } + delete_file(&auth_path)?; + Ok(true) +} + pub fn should_restore_codex_provider_token_for_backfill( category: Option<&str>, template_settings: &Value, @@ -1424,7 +1511,9 @@ fn remove_codex_experimental_bearer_token(config_text: &str) -> Result Result { let auth_path = get_codex_auth_path(); let auth_present = auth_path.exists(); @@ -1434,7 +1523,7 @@ pub fn read_codex_live_settings() -> Result { json!({}) }; let cfg_text = read_and_validate_codex_config_text()?; - if !auth_present && cfg_text.trim().is_empty() { + if !auth_present && !get_codex_config_path().exists() { return Err(AppError::localized( "codex.live.missing", "Codex 配置文件不存在", @@ -2403,6 +2492,65 @@ experimental_bearer_token = "stale-table-key" ); } + #[test] + fn credential_login_material_only_counts_real_credentials() { + assert!(codex_auth_has_credential_login_material(&json!({ + "tokens": { "access_token": "t" } + }))); + assert!(codex_auth_has_credential_login_material(&json!({ + "tokens": { "refresh_token": "r" } + }))); + assert!(codex_auth_has_credential_login_material(&json!({ + "personal_access_token": "pat" + }))); + + // API key and pure metadata are not credentials in this predicate's + // sense — they must not shield a stale key from cleanup. + assert!(!codex_auth_has_credential_login_material(&json!({ + "OPENAI_API_KEY": "sk-x" + }))); + assert!(!codex_auth_has_credential_login_material(&json!({ + "OPENAI_API_KEY": "sk-x", + "last_refresh": "2026-01-01T00:00:00Z", + "tokens": { "account_id": "acct-meta-only" } + }))); + assert!(!codex_auth_has_credential_login_material(&json!({}))); + } + + #[test] + fn stale_third_party_residue_detection() { + // Shapes a preserve-off third-party switch leaves behind: cleared. + assert!(codex_live_auth_is_stale_third_party_residue(&json!({ + "OPENAI_API_KEY": "sk-third-party" + }))); + assert!(codex_live_auth_is_stale_third_party_residue(&json!({ + "auth_mode": "apikey", + "OPENAI_API_KEY": "sk-third-party" + }))); + assert!(codex_live_auth_is_stale_third_party_residue(&json!({ + "OPENAI_API_KEY": "sk-third-party", + "last_refresh": "2026-01-01T00:00:00Z", + "tokens": { "account_id": "acct-meta-only" } + }))); + + // Anything carrying a real credential must survive untouched. + assert!(!codex_live_auth_is_stale_third_party_residue(&json!({ + "OPENAI_API_KEY": "sk-x", + "tokens": { "access_token": "t" } + }))); + assert!(!codex_live_auth_is_stale_third_party_residue(&json!({ + "auth_mode": "chatgpt", + "OPENAI_API_KEY": null, + "tokens": { "access_token": "official-oauth-token" } + }))); + + // Nothing to clear. + assert!(!codex_live_auth_is_stale_third_party_residue(&json!({}))); + assert!(!codex_live_auth_is_stale_third_party_residue(&json!({ + "OPENAI_API_KEY": "" + }))); + } + #[test] fn prepare_provider_live_config_does_not_create_incomplete_provider_table() { let input = r#"model_provider = "vendor_x" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 39edcf098..9e8d0c99e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -39,7 +39,9 @@ mod usage_events; mod usage_script; pub use app_config::{AppType, InstalledSkill, McpApps, McpServer, MultiAppConfig, SkillApps}; -pub use codex_config::{get_codex_auth_path, get_codex_config_path, write_codex_live_atomic}; +pub use codex_config::{ + get_codex_auth_path, get_codex_config_path, read_codex_live_settings, write_codex_live_atomic, +}; pub use commands::open_provider_terminal; pub use commands::*; pub use config::{get_claude_mcp_path, get_claude_settings_path, read_json_file}; diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index 7be078dc0..4058af709 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -3090,6 +3090,7 @@ impl ProviderService { // Use effective current provider (validated existence) to ensure backfill targets valid provider let current_id = crate::settings::get_effective_current_provider(&state.db, &app_type)?; + let mut backfill_completed = false; if let Some(current_id) = current_id { if current_id != id { // Additive mode apps - all providers coexist in the same file, @@ -3123,6 +3124,8 @@ impl ProviderService { result .warnings .push(format!("backfill_failed:{current_id}")); + } else { + backfill_completed = true; } } } @@ -3142,6 +3145,30 @@ impl ProviderService { // Sync to live (write_gemini_live handles security flag internally for Gemini) write_live_with_common_config(state.db.as_ref(), &app_type, provider)?; + // A material-less official Codex provider gets a config-only live + // write, which can leave the previous third-party key in + // ~/.codex/auth.json and strand the user on a 401 with no login + // screen. Only clean up after a successful backfill — the DB copy + // made above is what keeps that key recoverable. Failures degrade to + // a log entry: config.toml and is_current are already committed, so + // failing the switch here would report a switch that in fact happened. + if matches!(app_type, AppType::Codex) + && backfill_completed + && provider.category.as_deref() == Some("official") + { + let db_auth = provider.settings_config.get("auth"); + match crate::codex_config::clear_stale_codex_live_auth_after_official_switch( + db_auth.unwrap_or(&serde_json::Value::Null), + ) { + Ok(true) => log::info!( + "Removed stale third-party auth.json after switching to official Codex provider '{}'", + provider.id + ), + Ok(false) => {} + Err(e) => log::warn!("Failed to clean stale Codex auth.json: {e}"), + } + } + // Hermes is additive, so "switching" doesn't overwrite a live config file // — we instead update the top-level `model:` section to point at this // provider's first declared model. Without this, clicking "switch" would diff --git a/src-tauri/tests/provider_service.rs b/src-tauri/tests/provider_service.rs index bbb11cf02..5cb09e0f5 100644 --- a/src-tauri/tests/provider_service.rs +++ b/src-tauri/tests/provider_service.rs @@ -882,6 +882,168 @@ requires_openai_auth = true ); } +#[test] +fn provider_service_switch_codex_official_clears_stale_third_party_auth() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + // preservation stays OFF (default): switching to the third-party provider + // wrote its key into live auth.json, and that residue is what this test + // expects the official switch to clean up. + let _home = ensure_test_home(); + + let third_party_config = r#"model_provider = "aihubmix" +model = "gpt-5.4" + +[model_providers.aihubmix] +name = "AiHubMix" +base_url = "https://aihubmix.example/v1" +wire_api = "responses" +requires_openai_auth = true +"#; + // Live key intentionally differs from the DB row so the assertion below + // proves the backfill preserved the live copy before it was deleted. + let live_auth = json!({ "OPENAI_API_KEY": "stale-live-key" }); + write_codex_live_atomic(&live_auth, Some(third_party_config)) + .expect("seed third-party live config"); + + let mut initial_config = MultiAppConfig::default(); + { + let manager = initial_config + .get_manager_mut(&AppType::Codex) + .expect("codex manager"); + manager.current = "third-party".to_string(); + manager.providers.insert( + "third-party".to_string(), + Provider::with_id( + "third-party".to_string(), + "AiHubMix".to_string(), + json!({ + "auth": {"OPENAI_API_KEY": "old-db-key"}, + "config": third_party_config + }), + None, + ), + ); + let mut official_provider = Provider::with_id( + "official-provider".to_string(), + "OpenAI Official".to_string(), + json!({ + "auth": {}, + "config": "" + }), + None, + ); + official_provider.category = Some("official".to_string()); + manager + .providers + .insert("official-provider".to_string(), official_provider); + } + + let state = create_test_state_with_config(&initial_config).expect("create test state"); + + ProviderService::switch(&state, AppType::Codex, "official-provider") + .expect("switch to official provider should succeed"); + + assert!( + !cc_switch_lib::get_codex_auth_path().exists(), + "switching to a material-less official provider must delete the stale \ + third-party auth.json so Codex shows its login screen" + ); + + let providers = state + .db + .get_all_providers(AppType::Codex.as_str()) + .expect("read providers after switch"); + assert_eq!( + providers + .get("third-party") + .expect("third-party provider exists") + .settings_config + .pointer("/auth/OPENAI_API_KEY") + .and_then(|v| v.as_str()), + Some("stale-live-key"), + "the live key must be backfilled into the outgoing provider before deletion" + ); + + let live_config = + std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect("read config.toml"); + assert!( + !live_config.contains("experimental_bearer_token"), + "official provider has no API key to inject" + ); +} + +#[test] +fn provider_service_reswitch_current_official_keeps_live_auth() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let _home = ensure_test_home(); + + // Re-selecting the already-current provider performs no backfill, so the + // cleanup must not run either: without a fresh DB copy of whatever sits + // in live auth.json, deleting it would destroy the only copy. + let live_auth = json!({ "OPENAI_API_KEY": "residue-key" }); + write_codex_live_atomic(&live_auth, Some("")).expect("seed live config"); + + let mut initial_config = MultiAppConfig::default(); + { + let manager = initial_config + .get_manager_mut(&AppType::Codex) + .expect("codex manager"); + manager.current = "official-provider".to_string(); + let mut official_provider = Provider::with_id( + "official-provider".to_string(), + "OpenAI Official".to_string(), + json!({ + "auth": {}, + "config": "" + }), + None, + ); + official_provider.category = Some("official".to_string()); + manager + .providers + .insert("official-provider".to_string(), official_provider); + } + + let state = create_test_state_with_config(&initial_config).expect("create test state"); + + ProviderService::switch(&state, AppType::Codex, "official-provider") + .expect("re-switch to current official provider should succeed"); + + let auth_value: serde_json::Value = + read_json_file(&cc_switch_lib::get_codex_auth_path()).expect("auth.json must survive"); + assert_eq!( + auth_value.get("OPENAI_API_KEY").and_then(|v| v.as_str()), + Some("residue-key"), + "no backfill happened, so live auth.json must be left untouched" + ); +} + +#[test] +fn read_codex_live_settings_tolerates_missing_auth_when_config_file_exists() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let _home = ensure_test_home(); + + assert!( + cc_switch_lib::read_codex_live_settings().is_err(), + "both files missing is still 'no live install'" + ); + + // auth.json deleted + empty config.toml is the exact state the official + // switch cleanup leaves behind; it must stay readable or the next + // backfill / hot switch would treat Codex as uninstalled. + let config_path = cc_switch_lib::get_codex_config_path(); + std::fs::create_dir_all(config_path.parent().expect("codex dir")).expect("create codex dir"); + std::fs::write(&config_path, "").expect("write empty config.toml"); + + let live = cc_switch_lib::read_codex_live_settings() + .expect("config file present but empty must be readable"); + assert_eq!(live.get("auth"), Some(&json!({}))); + assert_eq!(live.get("config").and_then(|v| v.as_str()), Some("")); +} + #[test] fn reapply_codex_official_live_resyncs_mcp_servers() { let _guard = test_mutex().lock().expect("acquire test mutex");