diff --git a/crates/codex-plus-core/src/relay_config.rs b/crates/codex-plus-core/src/relay_config.rs index 9a90373e8..77b75dc16 100644 --- a/crates/codex-plus-core/src/relay_config.rs +++ b/crates/codex-plus-core/src/relay_config.rs @@ -790,6 +790,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()); @@ -2763,18 +2800,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) @@ -2790,6 +2892,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 e366bf2b6..156193214 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_switch.rs b/crates/codex-plus-core/tests/relay_switch.rs index d9170ae6a..9c8c80fb0 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,203 @@ 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() + ); +} + +#[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(); @@ -379,3 +577,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() + } +}