diff --git a/apps/codex-plus-manager/src-tauri/src/commands.rs b/apps/codex-plus-manager/src-tauri/src/commands.rs index 3d4f047e7..a8748fc6f 100644 --- a/apps/codex-plus-manager/src-tauri/src/commands.rs +++ b/apps/codex-plus-manager/src-tauri/src/commands.rs @@ -4375,7 +4375,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, @@ -4483,15 +4488,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); @@ -5689,15 +5696,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"); } fn launch_request(sync_active_relay: bool) -> LaunchRequest { diff --git a/crates/codex-plus-core/src/relay_config.rs b/crates/codex-plus-core/src/relay_config.rs index a781f83d1..b9e5a5071 100644 --- a/crates/codex-plus-core/src/relay_config.rs +++ b/crates/codex-plus-core/src/relay_config.rs @@ -406,8 +406,14 @@ pub fn apply_relay_profile_to_home_with_switch_rules( if profile.relay_mode == crate::settings::RelayMode::PureApi { apply_relay_files_to_home(home, &compatible_config, &profile.auth_contents) } else { - let auth_contents = official_profile_auth_for_switch(home, &profile.auth_contents)?; - apply_relay_files_to_home(home, &compatible_config, &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, + &auth_contents, + preserve_computer_use_guard, + ) } } @@ -2190,6 +2196,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() @@ -2209,15 +2221,68 @@ 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() { +/// 聚合模式写入 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 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() }; + 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") @@ -2920,6 +2985,73 @@ mod tests { }; assert!(relay_profile_model(&empty).trim().is_empty()); } + + #[test] + fn aggregate_switch_prefers_live_auth_over_profile_snapshot() { + let temp = tempfile::tempdir().unwrap(); + std::fs::write( + temp.path().join("auth.json"), + "{\n \"OPENAI_API_KEY\": \"sk-live-key\",\n \"tokens\": {\"access_token\": \"live-token\"}\n}\n", + ) + .unwrap(); + + let result = official_profile_auth_for_switch( + temp.path(), + r#"{"OPENAI_API_KEY":"sk-stale-profile-key"}"#, + crate::settings::RelayMode::Aggregate, + ) + .unwrap(); + let auth: Value = serde_json::from_str(&result).unwrap(); + + assert_eq!(auth["OPENAI_API_KEY"], "sk-live-key"); + assert_eq!(auth["tokens"]["access_token"], "live-token"); + assert!(!result.contains("sk-stale-profile-key")); + } + + #[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")); + } + + #[test] + fn aggregate_backfill_keeps_mode_when_auth_has_placeholder_key() { + let mut profile = RelayProfile { + relay_mode: crate::settings::RelayMode::Aggregate, + auth_contents: format!("{{\"OPENAI_API_KEY\":\"{AGGREGATE_AUTH_PLACEHOLDER_KEY}\"}}"), + ..RelayProfile::default() + }; + + sync_profile_mode_from_backfilled_live(&mut profile); + + assert_eq!(profile.relay_mode, crate::settings::RelayMode::Aggregate); + } } pub fn root_key_string(contents: &str, key: &str) -> Option { diff --git a/crates/codex-plus-core/tests/relay_config.rs b/crates/codex-plus-core/tests/relay_config.rs index 1ecbb5807..51470382b 100644 --- a/crates/codex-plus-core/tests/relay_config.rs +++ b/crates/codex-plus-core/tests/relay_config.rs @@ -453,22 +453,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]