Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 127 additions & 11 deletions crates/codex-plus-core/src/relay_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<usize> {
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<String> {
let mut doc = parse_toml_document(config_text)?;
remove_provider_specific_common_keys(doc.as_table_mut());
Expand Down Expand Up @@ -2763,18 +2800,83 @@ fn account_label_from_tokens(tokens: &Value) -> Option<String> {
})
}

fn account_label_from_jwt(token: &str) -> Option<String> {
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<String>,
email: Option<String>,
}

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<ChatGptIdentity> {
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<String> {
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<String> {
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<String> {
let value = jwt_payload(token)?;
value
.get("email")
.and_then(Value::as_str)
Expand All @@ -2790,6 +2892,20 @@ fn account_label_from_jwt(token: &str) -> Option<String> {
.map(ToString::to_string)
}

fn jwt_payload(token: &str) -> Option<Value> {
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::*;
Expand Down
3 changes: 3 additions & 0 deletions crates/codex-plus-core/src/relay_switch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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)
Expand Down
208 changes: 208 additions & 0 deletions crates/codex-plus-core/tests/relay_switch.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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::<serde_json::Value>(&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::<serde_json::Value>(&stored_other.auth_contents).unwrap(),
serde_json::from_str::<serde_json::Value>(&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::<serde_json::Value>(&stored.relay_profiles[0].auth_contents).unwrap(),
serde_json::from_str::<serde_json::Value>(&first.auth_contents).unwrap()
);
assert_eq!(
serde_json::from_str::<serde_json::Value>(&stored.relay_profiles[1].auth_contents).unwrap(),
serde_json::from_str::<serde_json::Value>(&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();
Expand Down Expand Up @@ -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()
}
}
Loading