diff --git a/apps/codex-plus-manager/src-tauri/src/commands.rs b/apps/codex-plus-manager/src-tauri/src/commands.rs index 0d0401f22..efec31e4e 100644 --- a/apps/codex-plus-manager/src-tauri/src/commands.rs +++ b/apps/codex-plus-manager/src-tauri/src/commands.rs @@ -5880,6 +5880,34 @@ mod tests { assert!(auth.contains("auth_mode")); } + #[test] + fn active_official_sync_preserves_newer_live_auth_for_same_account() { + let temp = tempfile::tempdir().unwrap(); + std::fs::write(temp.path().join("config.toml"), "").unwrap(); + std::fs::write( + temp.path().join("auth.json"), + r#"{"auth_mode":"chatgpt","tokens":{"account_id":"account-a","access_token":"live-new"}}"#, + ) + .unwrap(); + let settings = BackendSettings { + active_relay_id: "official".to_string(), + relay_profiles: vec![RelayProfile { + id: "official".to_string(), + relay_mode: codex_plus_core::settings::RelayMode::Official, + auth_contents: r#"{"auth_mode":"chatgpt","tokens":{"account_id":"account-a","access_token":"stored-old"}}"#.to_string(), + ..RelayProfile::default() + }], + ..BackendSettings::default() + }; + + sync_active_relay_to_home(&settings, temp.path()).unwrap(); + + let auth: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(temp.path().join("auth.json")).unwrap()) + .unwrap(); + assert_eq!(auth["tokens"]["access_token"], "live-new"); + } + #[test] fn active_aggregate_sync_writes_local_proxy() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/codex-plus-core/src/relay_config.rs b/crates/codex-plus-core/src/relay_config.rs index 368a66e97..768c7bbfd 100644 --- a/crates/codex-plus-core/src/relay_config.rs +++ b/crates/codex-plus-core/src/relay_config.rs @@ -725,8 +725,17 @@ pub fn clear_relay_config_to_home_with_auth( auth_contents: Option<&str>, ) -> anyhow::Result { std::fs::create_dir_all(home)?; - let auth_bytes = match auth_contents { - Some(contents) if !contents.trim().is_empty() => Some(contents.as_bytes().to_vec()), + let resolved_auth = match auth_contents { + Some(contents) + if !contents.trim().is_empty() && auth_contents_looks_like_chatgpt_auth(contents) => + { + Some(official_profile_auth_for_switch(home, contents)?) + } + Some(contents) if !contents.trim().is_empty() => Some(contents.to_string()), + _ => None, + }; + let auth_bytes = match resolved_auth.as_deref() { + Some(contents) => Some(contents.as_bytes().to_vec()), _ => pure_api_auth_json_removed(home)?, }; let config_path = home.join("config.toml"); @@ -844,6 +853,43 @@ pub fn backfill_relay_profile_from_home_with_common( Ok(()) } +/// Syncs the current ChatGPT login into official profiles that share the same account. +/// +/// Profiles whose auth identifies a different account are preserved so that +/// manually-bound provider accounts are not overwritten. Unknown identities are +/// not matched because treating two missing identities as equal could cross accounts. +pub fn sync_official_auth_from_live( + home: &Path, + profiles: &mut [RelayProfile], +) -> anyhow::Result { + let auth = read_optional_text(&home.join("auth.json"))?; + if !auth_contents_looks_like_chatgpt_auth(&auth) { + return Ok(0); + } + let auth = remove_openai_api_key_from_auth_contents(&auth)?; + if auth.trim().is_empty() { + return Ok(0); + } + let Some(live_identity) = auth_contents_chatgpt_identity(&auth) else { + return Ok(0); + }; + + let mut updated = 0; + for profile in profiles { + if profile.relay_mode == crate::settings::RelayMode::Official + && !profile.auth_contents.trim().is_empty() + && auth_contents_looks_like_chatgpt_auth(&profile.auth_contents) + && auth_contents_chatgpt_identity(&profile.auth_contents) + .is_some_and(|identity| identity.can_refresh_from(&live_identity)) + && profile.auth_contents != auth + { + profile.auth_contents = auth.clone(); + updated += 1; + } + } + Ok(updated) +} + pub fn extract_common_config_from_config(config_text: &str) -> anyhow::Result { let mut doc = parse_toml_document(config_text)?; remove_provider_specific_common_keys(doc.as_table_mut()); @@ -2316,12 +2362,28 @@ fn sync_profile_mode_from_backfilled_live(profile: &mut RelayProfile) { } fn official_profile_auth_for_switch(home: &Path, auth_contents: &str) -> anyhow::Result { - let source = if auth_contents.trim().is_empty() { - read_optional_text(&home.join("auth.json"))? - } else { - auth_contents.to_string() + let profile_auth = remove_openai_api_key_from_auth_contents(auth_contents)?; + let live_auth = + remove_openai_api_key_from_auth_contents(&read_optional_text(&home.join("auth.json"))?)?; + if profile_auth.trim().is_empty() { + return Ok(live_auth); + } + if !auth_contents_looks_like_chatgpt_auth(&profile_auth) + || !auth_contents_looks_like_chatgpt_auth(&live_auth) + { + return Ok(profile_auth); + } + let Some(profile_identity) = auth_contents_chatgpt_identity(&profile_auth) else { + return Ok(profile_auth); + }; + let Some(live_identity) = auth_contents_chatgpt_identity(&live_auth) else { + return Ok(profile_auth); }; - remove_openai_api_key_from_auth_contents(&source) + if profile_identity.can_refresh_from(&live_identity) { + Ok(live_auth) + } else { + Ok(profile_auth) + } } fn codex_auth_api_key(auth_contents: &str) -> Option { @@ -2941,18 +3003,83 @@ fn account_label_from_tokens(tokens: &Value) -> Option { }) } -fn account_label_from_jwt(token: &str) -> Option { - let payload = token.split('.').nth(1)?; - use base64::Engine; - let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD - .decode(payload.as_bytes()) - .ok() +#[derive(Debug, Clone, PartialEq, Eq)] +struct ChatGptIdentity { + account_id: Option, + email: Option, +} + +impl ChatGptIdentity { + fn can_refresh_from(&self, live: &Self) -> bool { + if let Some(profile_account_id) = self.account_id.as_deref() { + return live.account_id.as_deref() == Some(profile_account_id); + } + match (self.email.as_deref(), live.email.as_deref()) { + (Some(profile_email), Some(live_email)) => { + profile_email.eq_ignore_ascii_case(live_email) + } + _ => false, + } + } +} + +fn auth_contents_chatgpt_identity(contents: &str) -> Option { + let value: Value = serde_json::from_str(contents).ok()?; + let tokens = value.get("tokens")?; + let account_id = tokens + .get("account_id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) .or_else(|| { - base64::engine::general_purpose::URL_SAFE - .decode(payload.as_bytes()) - .ok() - })?; - let value: Value = serde_json::from_slice(&decoded).ok()?; + ["id_token", "access_token"].iter().find_map(|key| { + tokens + .get(*key) + .and_then(Value::as_str) + .and_then(jwt_chatgpt_account_id) + }) + }); + let email = ["id_token", "access_token"].iter().find_map(|key| { + tokens + .get(*key) + .and_then(Value::as_str) + .and_then(jwt_account_email) + }); + if account_id.is_none() && email.is_none() { + return None; + } + Some(ChatGptIdentity { account_id, email }) +} + +fn jwt_chatgpt_account_id(token: &str) -> Option { + jwt_payload(token)? + .get("https://api.openai.com/auth") + .and_then(|auth| auth.get("chatgpt_account_id")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) +} + +fn jwt_account_email(token: &str) -> Option { + let value = jwt_payload(token)?; + value + .get("email") + .and_then(Value::as_str) + .or_else(|| { + value + .get("https://api.openai.com/profile") + .and_then(|profile| profile.get("email")) + .and_then(Value::as_str) + }) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) +} + +fn account_label_from_jwt(token: &str) -> Option { + let value = jwt_payload(token)?; value .get("email") .and_then(Value::as_str) @@ -2968,6 +3095,20 @@ fn account_label_from_jwt(token: &str) -> Option { .map(ToString::to_string) } +fn jwt_payload(token: &str) -> Option { + let payload = token.split('.').nth(1)?; + use base64::Engine; + let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(payload.as_bytes()) + .ok() + .or_else(|| { + base64::engine::general_purpose::URL_SAFE + .decode(payload.as_bytes()) + .ok() + })?; + serde_json::from_slice(&decoded).ok() +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/codex-plus-core/src/relay_switch.rs b/crates/codex-plus-core/src/relay_switch.rs index 149c1fdb0..1a4e129b2 100644 --- a/crates/codex-plus-core/src/relay_switch.rs +++ b/crates/codex-plus-core/src/relay_switch.rs @@ -5,6 +5,7 @@ use anyhow::Context; use crate::relay_config::{ backfill_relay_profile_from_home_with_common, relay_config_status_from_home, + sync_official_auth_from_live, }; use crate::settings::{BackendSettings, RelayMode, SettingsStore}; @@ -34,6 +35,8 @@ pub fn switch_relay_profile_in_home( { backfill_profile_before_switch(home, &mut selected_settings, previous_active_relay_id)?; } + sync_official_auth_from_live(home, &mut selected_settings.relay_profiles) + .context("同步官方登录状态到供应商配置失败")?; store .save(&selected_settings) diff --git a/crates/codex-plus-core/tests/relay_config.rs b/crates/codex-plus-core/tests/relay_config.rs index 639568e58..d4f7dde98 100644 --- a/crates/codex-plus-core/tests/relay_config.rs +++ b/crates/codex-plus-core/tests/relay_config.rs @@ -3481,7 +3481,7 @@ fn apply_official_mix_profile_clears_live_auth_api_key_and_keeps_login() { let temp = tempfile::tempdir().unwrap(); std::fs::write( temp.path().join("auth.json"), - r#"{"OPENAI_API_KEY":"sk-pure-api","auth_mode":"chatgpt","tokens":{"access_token":"official"}}"#, + r#"{"OPENAI_API_KEY":"sk-pure-api","auth_mode":"chatgpt","tokens":{"account_id":"account-a","access_token":"live-official"}}"#, ) .unwrap(); let profile = RelayProfile { @@ -3500,7 +3500,7 @@ base_url = "https://relay.example/v1" experimental_bearer_token = "sk-official-mix" "# .to_string(), - auth_contents: r#"{"auth_mode":"chatgpt","tokens":{"access_token":"official"}}"# + auth_contents: r#"{"auth_mode":"chatgpt","tokens":{"account_id":"account-a","access_token":"stale-official"}}"# .to_string(), ..RelayProfile::default() }; @@ -3511,7 +3511,7 @@ experimental_bearer_token = "sk-official-mix" let auth: serde_json::Value = serde_json::from_str(&auth).unwrap(); assert!(auth.get("OPENAI_API_KEY").is_none()); assert_eq!(auth["auth_mode"], "chatgpt"); - assert_eq!(auth["tokens"]["access_token"], "official"); + assert_eq!(auth["tokens"]["access_token"], "live-official"); let config = std::fs::read_to_string(temp.path().join("config.toml")).unwrap(); assert!(config.contains(r#"experimental_bearer_token = "sk-official-mix""#)); diff --git a/crates/codex-plus-core/tests/relay_switch.rs b/crates/codex-plus-core/tests/relay_switch.rs index cdff19452..158e284d8 100644 --- a/crates/codex-plus-core/tests/relay_switch.rs +++ b/crates/codex-plus-core/tests/relay_switch.rs @@ -1,3 +1,4 @@ +use codex_plus_core::relay_config::sync_official_auth_from_live; use codex_plus_core::relay_switch::switch_relay_profile_in_home; use codex_plus_core::settings::{ AggregateRelayMember, AggregateRelayProfile, AggregateRelayStrategy, BackendSettings, @@ -190,6 +191,256 @@ base_url = "https://edited-a.example/v1" assert_eq!(stored.launch_mode, LaunchMode::Patch); } +#[test] +fn switch_syncs_live_chatgpt_auth_to_all_official_profiles() { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().join("codex"); + std::fs::create_dir(&home).unwrap(); + std::fs::write(home.join("config.toml"), "").unwrap(); + let live_auth = r#"{ + "auth_mode": "chatgpt", + "OPENAI_API_KEY": "must-not-be-copied", + "tokens": { + "access_token": "new-access", + "id_token": "x.eyJlbWFpbCI6InVzZXJAZXhhbXBsZS5jb20iLCJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoiYWNjb3VudC1hIn19.y", + "account_id": "account-a", + "refresh_token": "new-refresh" + } +}"#; + std::fs::write(home.join("auth.json"), live_auth).unwrap(); + let expected_auth = serde_json::json!({ + "auth_mode": "chatgpt", + "tokens": { + "access_token": "new-access", + "id_token": "x.eyJlbWFpbCI6InVzZXJAZXhhbXBsZS5jb20iLCJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoiYWNjb3VudC1hIn19.y", + "account_id": "account-a", + "refresh_token": "new-refresh" + } + }); + + let store = SettingsStore::new(temp.path().join("settings.json")); + let official_a = official_profile( + "a", + r#"{"auth_mode":"chatgpt","tokens":{"access_token":"old-a","account_id":"account-a"}}"#, + ); + // Legacy snapshots without account_id still match by email. + let official_b = official_profile( + "b", + r#"{"auth_mode":"chatgpt","tokens":{"access_token":"old-b","id_token":"x.eyJlbWFpbCI6InVzZXJAZXhhbXBsZS5jb20ifQ.y"}}"#, + ); + let official_mix = RelayProfile { + id: "mixed".to_string(), + name: "Mixed".to_string(), + relay_mode: RelayMode::Official, + official_mix_api_key: true, + config_contents: r#"model_provider = "custom" + +[model_providers.custom] +name = "custom" +wire_api = "responses" +requires_openai_auth = true +base_url = "https://mixed.example/v1" +"# + .to_string(), + auth_contents: r#"{"auth_mode":"chatgpt","tokens":{"access_token":"old-mixed","id_token":"x.eyJlbWFpbCI6InVzZXJAZXhhbXBsZS5jb20iLCJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoiYWNjb3VudC1hIn19.y"}}"# + .to_string(), + ..RelayProfile::default() + }; + let other_account = official_profile( + "other", + r#"{"auth_mode":"chatgpt","tokens":{"account_id":"account-b","id_token":"x.eyJlbWFpbCI6InVzZXJAZXhhbXBsZS5jb20iLCJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoiYWNjb3VudC1iIn19.y"}}"#, + ); + let pure = pure_profile("api", "https://api.example/v1", "sk-api"); + let original = BackendSettings { + active_relay_id: "a".to_string(), + relay_profiles: vec![ + official_a.clone(), + official_b.clone(), + official_mix.clone(), + other_account.clone(), + pure.clone(), + ], + ..BackendSettings::default() + }; + store.save(&original).unwrap(); + let next = BackendSettings { + active_relay_id: "b".to_string(), + relay_profiles: vec![ + official_a, + official_b, + official_mix, + other_account.clone(), + pure.clone(), + ], + ..BackendSettings::default() + }; + + switch_relay_profile_in_home(&store, &home, next, "a").unwrap(); + + let stored = store.load().unwrap(); + for profile in stored + .relay_profiles + .iter() + .filter(|profile| matches!(profile.id.as_str(), "a" | "b" | "mixed")) + { + assert_eq!( + serde_json::from_str::(&profile.auth_contents).unwrap(), + expected_auth, + "official profile {} should carry live auth", + profile.id + ); + } + let stored_pure = stored + .relay_profiles + .iter() + .find(|profile| profile.id == "api") + .unwrap(); + assert_eq!(stored_pure.auth_contents, pure.auth_contents); + let stored_other = stored + .relay_profiles + .iter() + .find(|profile| profile.id == "other") + .unwrap(); + assert_eq!( + serde_json::from_str::(&stored_other.auth_contents).unwrap(), + serde_json::from_str::(&other_account.auth_contents).unwrap() + ); + assert_eq!( + serde_json::from_str::( + &std::fs::read_to_string(home.join("auth.json")).unwrap() + ) + .unwrap(), + expected_auth + ); +} + +#[test] +fn switch_to_different_official_account_keeps_target_auth() { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().join("codex"); + std::fs::create_dir(&home).unwrap(); + std::fs::write(home.join("config.toml"), "").unwrap(); + std::fs::write( + home.join("auth.json"), + r#"{"auth_mode":"chatgpt","tokens":{"account_id":"account-a","access_token":"live-a"}}"#, + ) + .unwrap(); + let account_a = official_profile( + "a", + r#"{"auth_mode":"chatgpt","tokens":{"account_id":"account-a","access_token":"old-a"}}"#, + ); + let account_b = official_profile( + "b", + r#"{"auth_mode":"chatgpt","tokens":{"account_id":"account-b","access_token":"stored-b"}}"#, + ); + let store = SettingsStore::new(temp.path().join("settings.json")); + store + .save(&BackendSettings { + active_relay_id: "a".to_string(), + relay_profiles: vec![account_a.clone(), account_b.clone()], + ..BackendSettings::default() + }) + .unwrap(); + + switch_relay_profile_in_home( + &store, + &home, + BackendSettings { + active_relay_id: "b".to_string(), + relay_profiles: vec![account_a, account_b], + ..BackendSettings::default() + }, + "a", + ) + .unwrap(); + + let live: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(home.join("auth.json")).unwrap()).unwrap(); + assert_eq!(live["tokens"]["account_id"], "account-b"); + assert_eq!(live["tokens"]["access_token"], "stored-b"); +} + +#[test] +fn switch_preserves_official_profiles_when_account_identity_is_unknown() { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().join("codex"); + std::fs::create_dir(&home).unwrap(); + std::fs::write(home.join("config.toml"), "").unwrap(); + std::fs::write( + home.join("auth.json"), + r#"{"auth_mode":"chatgpt","tokens":{"refresh_token":"live-refresh"}}"#, + ) + .unwrap(); + + let first = official_profile( + "first", + r#"{"auth_mode":"chatgpt","tokens":{"refresh_token":"first-refresh"}}"#, + ); + let second = official_profile( + "second", + r#"{"auth_mode":"chatgpt","tokens":{"refresh_token":"second-refresh"}}"#, + ); + let store = SettingsStore::new(temp.path().join("settings.json")); + let original = BackendSettings { + active_relay_id: "first".to_string(), + relay_profiles: vec![first.clone(), second.clone()], + ..BackendSettings::default() + }; + store.save(&original).unwrap(); + let next = BackendSettings { + active_relay_id: "second".to_string(), + relay_profiles: vec![first.clone(), second.clone()], + ..BackendSettings::default() + }; + + switch_relay_profile_in_home(&store, &home, next, "").unwrap(); + + let stored = store.load().unwrap(); + assert_eq!( + serde_json::from_str::(&stored.relay_profiles[0].auth_contents).unwrap(), + serde_json::from_str::(&first.auth_contents).unwrap() + ); + assert_eq!( + serde_json::from_str::(&stored.relay_profiles[1].auth_contents).unwrap(), + serde_json::from_str::(&second.auth_contents).unwrap() + ); +} + +#[test] +fn auth_sync_ignores_non_chatgpt_profile_auth() { + let temp = tempfile::tempdir().unwrap(); + std::fs::write( + temp.path().join("auth.json"), + r#"{"auth_mode":"chatgpt","tokens":{"account_id":"account-a","access_token":"live"}}"#, + ) + .unwrap(); + let original_auth = + r#"{"auth_mode":"apikey","tokens":{"account_id":"account-a","access_token":"keep-me"}}"#; + let mut profiles = vec![official_profile("api-auth", original_auth)]; + + let updated = sync_official_auth_from_live(temp.path(), &mut profiles).unwrap(); + + assert_eq!(updated, 0); + assert_eq!(profiles[0].auth_contents, original_auth); +} + +#[test] +fn auth_sync_requires_live_account_id_for_bound_profile() { + let temp = tempfile::tempdir().unwrap(); + std::fs::write( + temp.path().join("auth.json"), + r#"{"auth_mode":"chatgpt","tokens":{"id_token":"x.eyJlbWFpbCI6InVzZXJAZXhhbXBsZS5jb20ifQ.y","access_token":"live"}}"#, + ) + .unwrap(); + let original_auth = r#"{"auth_mode":"chatgpt","tokens":{"account_id":"bound-account","id_token":"x.eyJlbWFpbCI6InVzZXJAZXhhbXBsZS5jb20ifQ.y","access_token":"stored"}}"#; + let mut profiles = vec![official_profile("bound", original_auth)]; + + let updated = sync_official_auth_from_live(temp.path(), &mut profiles).unwrap(); + + assert_eq!(updated, 0); + assert_eq!(profiles[0].auth_contents, original_auth); +} + #[test] fn switch_to_aggregate_relay_allows_empty_config_snapshot() { let temp = tempfile::tempdir().unwrap(); @@ -381,3 +632,13 @@ base_url = "{base_url}" ..RelayProfile::default() } } + +fn official_profile(id: &str, auth_contents: &str) -> RelayProfile { + RelayProfile { + id: id.to_string(), + name: id.to_uppercase(), + relay_mode: RelayMode::Official, + auth_contents: auth_contents.to_string(), + ..RelayProfile::default() + } +}