From c54cd6664f0dc5290490e93dde16f8de83ec5e55 Mon Sep 17 00:00:00 2001 From: yanlong832-source Date: Sun, 9 Aug 2026 21:27:04 +0800 Subject: [PATCH 1/2] fix: keep OPENAI_API_KEY in auth.json when switching to aggregate mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 切换聚合供应商时 auth.json 被清空为 0 字节,导致 Codex 桌面版(以 auth.json 的 OPENAI_API_KEY 作为已登录判据)弹出登录界面,纯 API Key 用户无法进入。 聚合模式的请求认证由本地协议代理(127.0.0.1:57321)的 internal token 完成,真实上游 Key 只保存在 settings.json,因此 auth.json 内容不影响请求。切换时保留已有 Key,缺失时写入占位 Key 即可绕过登录界面。 Official/MixedApi 模式行为不变(仍删除 OPENAI_API_KEY),新增三个单元测试覆盖聚合保留、聚合占位、官方删除三条路径。 --- crates/codex-plus-core/src/relay_config.rs | 109 ++++++++++++++++++++- 1 file changed, 107 insertions(+), 2 deletions(-) diff --git a/crates/codex-plus-core/src/relay_config.rs b/crates/codex-plus-core/src/relay_config.rs index 9e9e3de63..e6fd0df91 100644 --- a/crates/codex-plus-core/src/relay_config.rs +++ b/crates/codex-plus-core/src/relay_config.rs @@ -435,7 +435,8 @@ pub fn apply_relay_profile_to_home_with_switch_rules_and_computer_use_guard( preserve_computer_use_guard, ) } else { - let auth_contents = official_profile_auth_for_switch(home, &profile.auth_contents)?; + let auth_contents = + official_profile_auth_for_switch(home, &profile.auth_contents, profile.relay_mode)?; apply_relay_files_to_home_with_computer_use_guard( home, &config_with_catalog, @@ -2091,15 +2092,64 @@ fn sync_profile_mode_from_backfilled_live(profile: &mut RelayProfile) { } } -fn official_profile_auth_for_switch(home: &Path, auth_contents: &str) -> anyhow::Result { +/// 聚合模式写入 auth.json 的占位 Key。 +/// +/// 仅用于满足 Codex 桌面版的登录检查(auth.json 中存在 OPENAI_API_KEY 即视为 +/// 已认证);实际请求认证由本地协议代理(127.0.0.1:57321)的内部 token +/// "codex-plus-aggregate" 完成,与 auth.json 内容无关。 +const AGGREGATE_AUTH_PLACEHOLDER_KEY: &str = "sk-codex-plus-aggregate-placeholder"; + +/// 非 PureApi 模式切换时写入 auth.json 的认证内容。 +/// +/// - Official:Codex 桌面版依赖已登录的 ChatGPT 账号(MSIX 账户体系), +/// auth.json 中不保留 OPENAI_API_KEY。 +/// - Aggregate:聚合模式的请求认证由本地协议代理完成,真实上游 Key 只保存在 +/// Codex++ 的 settings.json 中。但 Codex 桌面版以 auth.json 的 OPENAI_API_KEY +/// 作为“已登录”判据,删除会导致纯 API Key 用户在切换聚合模式后弹出登录界面。 +/// 因此聚合模式保留已有 Key,缺失时写入占位 Key。 +fn official_profile_auth_for_switch( + home: &Path, + auth_contents: &str, + relay_mode: crate::settings::RelayMode, +) -> anyhow::Result { let source = if auth_contents.trim().is_empty() { read_optional_text(&home.join("auth.json"))? } else { auth_contents.to_string() }; + if relay_mode == crate::settings::RelayMode::Aggregate { + return ensure_openai_api_key_in_auth_contents(&source); + } remove_openai_api_key_from_auth_contents(&source) } +/// 聚合模式专用:确保 auth.json 包含 OPENAI_API_KEY。 +/// 已有则原样保留;缺失或内容非法时写入占位 Key。 +fn ensure_openai_api_key_in_auth_contents(auth_contents: &str) -> anyhow::Result { + if !auth_contents.trim().is_empty() { + if let Ok(mut value) = serde_json::from_str::(auth_contents) { + if let Some(object) = value.as_object_mut() { + if object + .get("OPENAI_API_KEY") + .and_then(Value::as_str) + .is_some_and(|key| !key.trim().is_empty()) + { + return Ok(format!("{}\n", serde_json::to_string_pretty(&value)?)); + } + object.insert( + "OPENAI_API_KEY".to_string(), + Value::String(AGGREGATE_AUTH_PLACEHOLDER_KEY.to_string()), + ); + return Ok(format!("{}\n", serde_json::to_string_pretty(&value)?)); + } + } + } + Ok(format!( + "{}\n", + serde_json::to_string_pretty(&json!({ "OPENAI_API_KEY": AGGREGATE_AUTH_PLACEHOLDER_KEY }))? + )) +} + fn codex_auth_api_key(auth_contents: &str) -> Option { let auth: Value = serde_json::from_str(auth_contents).ok()?; auth.get("OPENAI_API_KEY") @@ -2734,6 +2784,61 @@ mod tests { }; assert!(relay_profile_model(&empty).trim().is_empty()); } + + #[test] + fn aggregate_switch_keeps_existing_openai_api_key() { + let temp = tempfile::tempdir().unwrap(); + std::fs::write( + temp.path().join("auth.json"), + "{\n \"OPENAI_API_KEY\": \"sk-existing-key\"\n}\n", + ) + .unwrap(); + + let result = official_profile_auth_for_switch( + temp.path(), + "", + crate::settings::RelayMode::Aggregate, + ) + .unwrap(); + + assert!(result.contains("sk-existing-key")); + assert!(!result.trim().is_empty()); + } + + #[test] + fn aggregate_switch_writes_placeholder_when_auth_missing() { + let temp = tempfile::tempdir().unwrap(); + std::fs::write(temp.path().join("auth.json"), "").unwrap(); + + let result = official_profile_auth_for_switch( + temp.path(), + "", + crate::settings::RelayMode::Aggregate, + ) + .unwrap(); + + assert!(result.contains("OPENAI_API_KEY")); + assert!(result.contains(AGGREGATE_AUTH_PLACEHOLDER_KEY)); + } + + #[test] + fn official_switch_still_removes_openai_api_key() { + let temp = tempfile::tempdir().unwrap(); + std::fs::write( + temp.path().join("auth.json"), + "{\n \"OPENAI_API_KEY\": \"sk-should-be-removed\"\n}\n", + ) + .unwrap(); + + let result = official_profile_auth_for_switch( + temp.path(), + "", + crate::settings::RelayMode::Official, + ) + .unwrap(); + + assert!(!result.contains("OPENAI_API_KEY")); + } } pub fn root_key_string(contents: &str, key: &str) -> Option { From c3655d84ab3c8c2c259479be7826e477f995225b Mon Sep 17 00:00:00 2001 From: 3124516 <2907145367@qq.com> Date: Mon, 10 Aug 2026 13:19:06 +0800 Subject: [PATCH 2/2] fix: preserve aggregate auth state across live sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 统一管理器聚合写入与供应商切换路径,始终基于实时 auth.json 保留认证字段并补齐占位 Key。回填实时配置时保持 Aggregate 模式,避免被占位 Key 误判为 PureApi。 新增核心与管理器回归测试,覆盖旧快照忽略、认证字段保留、本地代理配置和模式回填。 --- .../src-tauri/src/commands.rs | 44 ++++++++++++---- crates/codex-plus-core/src/relay_config.rs | 52 +++++++++++++------ crates/codex-plus-core/tests/relay_config.rs | 21 +++++++- 3 files changed, 90 insertions(+), 27 deletions(-) diff --git a/apps/codex-plus-manager/src-tauri/src/commands.rs b/apps/codex-plus-manager/src-tauri/src/commands.rs index ca3046a2a..81e55f01b 100644 --- a/apps/codex-plus-manager/src-tauri/src/commands.rs +++ b/apps/codex-plus-manager/src-tauri/src/commands.rs @@ -3598,7 +3598,12 @@ pub fn apply_relay_injection() -> CommandResult { let relay = settings.active_relay_profile(); log_relay_apply_request("manager.apply_relay_injection", &settings, &relay); if settings.active_aggregate_relay_profile().is_some() { - let response = apply_aggregate_relay_injection_to_home(&home); + let response = apply_aggregate_relay_injection_to_home( + &home, + &relay, + &relay_combined_common_config(&settings), + settings.computer_use_guard_enabled, + ); if response.status == "ok" { finish_codex_app_state_after_provider_switch( &home, @@ -3707,15 +3712,17 @@ pub fn apply_relay_injection() -> CommandResult { } } -fn apply_aggregate_relay_injection_to_home(home: &Path) -> CommandResult { - match codex_plus_core::relay_config::apply_relay_config_to_home_with_protocol( +fn apply_aggregate_relay_injection_to_home( + home: &Path, + relay: &RelayProfile, + common_config_contents: &str, + preserve_computer_use_guard: bool, +) -> CommandResult { + match codex_plus_core::relay_config::apply_relay_profile_to_home_with_switch_rules_and_computer_use_guard( home, - &codex_plus_core::protocol_proxy::local_responses_proxy_base_url( - codex_plus_core::protocol_proxy::DEFAULT_PROTOCOL_PROXY_PORT, - ), - "codex-plus-aggregate", - codex_plus_core::settings::RelayProtocol::Responses, - codex_plus_core::protocol_proxy::DEFAULT_PROTOCOL_PROXY_PORT, + relay, + common_config_contents, + preserve_computer_use_guard, ) { Ok(result) => { let status = codex_plus_core::relay_config::relay_status_from_home(home); @@ -4809,15 +4816,32 @@ mod tests { #[test] fn aggregate_relay_injection_writes_local_proxy_without_chatgpt_auth() { let temp = tempfile::tempdir().unwrap(); + std::fs::write( + temp.path().join("auth.json"), + r#"{"tokens":{"access_token":"test-access-token"}}"#, + ) + .unwrap(); + let profile = RelayProfile { + relay_mode: codex_plus_core::settings::RelayMode::Aggregate, + ..RelayProfile::default() + }; - let result = apply_aggregate_relay_injection_to_home(temp.path()); + let result = apply_aggregate_relay_injection_to_home(temp.path(), &profile, "", false); let config = std::fs::read_to_string(temp.path().join("config.toml")).unwrap(); + let auth: Value = + serde_json::from_str(&std::fs::read_to_string(temp.path().join("auth.json")).unwrap()) + .unwrap(); assert_eq!(result.status, "ok"); assert!(result.payload.configured); assert!(!result.payload.authenticated); assert!(config.contains(r#"base_url = "http://127.0.0.1:57321/v1""#)); assert!(config.contains(r#"experimental_bearer_token = "codex-plus-aggregate""#)); + assert_eq!( + auth["OPENAI_API_KEY"], + "sk-codex-plus-aggregate-placeholder" + ); + assert_eq!(auth["tokens"]["access_token"], "test-access-token"); } #[test] diff --git a/crates/codex-plus-core/src/relay_config.rs b/crates/codex-plus-core/src/relay_config.rs index e6fd0df91..1a93fd238 100644 --- a/crates/codex-plus-core/src/relay_config.rs +++ b/crates/codex-plus-core/src/relay_config.rs @@ -2073,6 +2073,12 @@ fn sync_profile_mode_from_backfilled_live(profile: &mut RelayProfile) { if profile.relay_mode == crate::settings::RelayMode::Official && !profile.official_mix_api_key { return; } + // 聚合模式保持聚合模式:auth.json 中的占位 Key 仅为满足 Codex 桌面版 + // 的登录检查(auth.json 存在 OPENAI_API_KEY 即视为已认证),实际请求认证 + // 由本地协议代理完成。不能据此回填成 PureApi,否则会丢失聚合轮转语义。 + if profile.relay_mode == crate::settings::RelayMode::Aggregate { + return; + } if codex_auth_api_key(&profile.auth_contents) .as_deref() @@ -2112,7 +2118,11 @@ fn official_profile_auth_for_switch( auth_contents: &str, relay_mode: crate::settings::RelayMode, ) -> anyhow::Result { - let source = if auth_contents.trim().is_empty() { + let source = if relay_mode == crate::settings::RelayMode::Aggregate { + // 聚合配置不拥有认证快照,始终基于实时 auth.json 补齐占位 Key, + // 避免旧 profile 覆盖用户刚更新的登录或 API Key 状态。 + read_optional_text(&home.join("auth.json"))? + } else if auth_contents.trim().is_empty() { read_optional_text(&home.join("auth.json"))? } else { auth_contents.to_string() @@ -2281,9 +2291,9 @@ fn complete_relay_profile_config(profile: &RelayProfile) -> anyhow::Result Option { diff --git a/crates/codex-plus-core/tests/relay_config.rs b/crates/codex-plus-core/tests/relay_config.rs index 5cb0d3edf..a292ece69 100644 --- a/crates/codex-plus-core/tests/relay_config.rs +++ b/crates/codex-plus-core/tests/relay_config.rs @@ -397,22 +397,39 @@ base_url = "https://responses.example.test/v1" #[test] fn apply_aggregate_relay_points_codex_to_local_responses_proxy_without_snapshot() { let temp = tempfile::tempdir().unwrap(); - let profile = RelayProfile { + std::fs::write( + temp.path().join("auth.json"), + r#"{"tokens":{"access_token":"live-token"}}"#, + ) + .unwrap(); + let mut profile = RelayProfile { id: "agg".to_string(), name: "聚合供应商 1".to_string(), relay_mode: RelayMode::Aggregate, config_contents: String::new(), - auth_contents: String::new(), + auth_contents: r#"{"OPENAI_API_KEY":"sk-stale-profile-key"}"#.to_string(), ..RelayProfile::default() }; let result = apply_relay_profile_to_home_with_switch_rules(temp.path(), &profile, "").unwrap(); let updated = std::fs::read_to_string(temp.path().join("config.toml")).unwrap(); + let auth: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(temp.path().join("auth.json")).unwrap()) + .unwrap(); assert!(result.configured); assert!(updated.contains(r#"wire_api = "responses""#)); assert!(updated.contains(r#"base_url = "http://127.0.0.1:57321/v1""#)); assert!(updated.contains(r#"experimental_bearer_token = "codex-plus-aggregate""#)); + assert_eq!( + auth["OPENAI_API_KEY"], + "sk-codex-plus-aggregate-placeholder" + ); + assert_eq!(auth["tokens"]["access_token"], "live-token"); + + let mut common = String::new(); + backfill_relay_profile_from_home_with_common(temp.path(), &mut profile, &mut common).unwrap(); + assert_eq!(profile.relay_mode, RelayMode::Aggregate); } #[test]