diff --git a/src-tauri/src/codex_config.rs b/src-tauri/src/codex_config.rs index 9355adc02..a4f277465 100644 --- a/src-tauri/src/codex_config.rs +++ b/src-tauri/src/codex_config.rs @@ -739,7 +739,10 @@ fn set_codex_experimental_bearer_token(config_text: &str, token: &str) -> Result Ok(doc.to_string()) } -fn remove_codex_experimental_bearer_token(config_text: &str) -> Result { +pub fn remove_codex_experimental_bearer_token_if( + config_text: &str, + predicate: impl Fn(&str) -> bool, +) -> Result { if config_text.trim().is_empty() || !config_text.contains("experimental_bearer_token") { return Ok(config_text.to_string()); } @@ -755,14 +758,32 @@ fn remove_codex_experimental_bearer_token(config_text: &str) -> Result Result { + remove_codex_experimental_bearer_token_if(config_text, |_| true) +} + /// Read the current Codex live settings as a `{ auth, config }` object. /// /// Missing `auth.json` collapses to `{}` so a config-only third-party install diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index 682e8edb5..78b894a53 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -1242,22 +1242,19 @@ impl ProxyService { .get("config") .and_then(|v| v.as_str()) .unwrap_or(""); - let codex_provider = self - .get_current_provider_for_app(&AppType::Codex) - .ok() - .flatten(); + let codex_provider = self.require_current_provider_for_app(&AppType::Codex)?; let updated_config = Self::apply_codex_proxy_toml_config_for_provider( config_str, &proxy_codex_base_url, - codex_provider.as_ref(), + Some(&codex_provider), ); live_config["config"] = json!(updated_config); Self::attach_codex_model_catalog_from_provider( &mut live_config, - codex_provider.as_ref(), + Some(&codex_provider), ); - self.write_codex_live_for_provider(&live_config, codex_provider.as_ref())?; + self.write_codex_live_for_provider(&live_config, Some(&codex_provider))?; log::info!("Codex Live 配置已接管,代理地址: {proxy_codex_base_url}"); } AppType::Gemini => { @@ -1602,6 +1599,11 @@ impl ProxyService { if let Some(cfg_str) = config.get("config").and_then(|v| v.as_str()) { let updated = Self::remove_local_toml_base_url(cfg_str); + let updated = + crate::codex_config::remove_codex_experimental_bearer_token_if(&updated, |token| { + token == PROXY_TOKEN_PLACEHOLDER + }) + .map_err(|e| format!("清理 Codex 接管占位符失败: {e}"))?; config["config"] = json!(updated); } @@ -1715,11 +1717,22 @@ impl ProxyService { } fn is_codex_live_taken_over(config: &Value) -> bool { - let auth = match config.get("auth").and_then(|v| v.as_object()) { - Some(auth) => auth, - None => return false, - }; - auth.get("OPENAI_API_KEY").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) + if config + .get("auth") + .and_then(|v| v.as_object()) + .and_then(|auth| auth.get("OPENAI_API_KEY")) + .and_then(|v| v.as_str()) + == Some(PROXY_TOKEN_PLACEHOLDER) + { + return true; + } + + config + .get("config") + .and_then(|v| v.as_str()) + .and_then(crate::codex_config::extract_codex_experimental_bearer_token) + .as_deref() + == Some(PROXY_TOKEN_PLACEHOLDER) } fn is_gemini_live_taken_over(config: &Value) -> bool { @@ -2055,6 +2068,25 @@ impl ProxyService { provider: Option<&Provider>, ) -> Result<(), String> { let Some(provider) = provider else { + if crate::settings::preserve_codex_official_auth_on_switch() { + if let (Some(auth), Some(config_str)) = ( + config.get("auth"), + config.get("config").and_then(|v| v.as_str()), + ) { + if auth.get("OPENAI_API_KEY").and_then(|v| v.as_str()) + == Some(PROXY_TOKEN_PLACEHOLDER) + { + let live_config = crate::codex_config::prepare_codex_provider_live_config( + auth, config_str, + ) + .map_err(|e| format!("写入 Codex 配置失败: {e}"))?; + crate::codex_config::write_codex_live_config_atomic(Some(&live_config)) + .map_err(|e| format!("写入 Codex 配置失败: {e}"))?; + return Ok(()); + } + } + } + return self.write_codex_live_verbatim(config); }; @@ -2645,6 +2677,152 @@ wire_api = "responses" .expect("reset settings"); } + #[tokio::test] + #[serial] + async fn codex_takeover_preserves_oauth_auth_json_when_preserve_enabled() { + let _home = TempHome::new(); + crate::settings::reload_settings().expect("reload settings"); + crate::settings::update_settings(crate::settings::AppSettings { + preserve_codex_official_auth_on_switch: true, + ..Default::default() + }) + .expect("enable Codex official auth preservation"); + + let db = Arc::new(Database::memory().expect("init db")); + let service = ProxyService::new(db.clone()); + let oauth_auth = json!({ + "auth_mode": "chatgpt", + "tokens": { + "id_token": "oauth-id", + "access_token": "oauth-access" + } + }); + let deepseek_live_config = r#"model_provider = "deepseek" +model = "deepseek-v4-flash" + +[model_providers.deepseek] +name = "DeepSeek" +base_url = "https://api.deepseek.com/v1" +wire_api = "responses" +experimental_bearer_token = "deepseek-key" +"#; + crate::codex_config::write_codex_live_atomic(&oauth_auth, Some(deepseek_live_config)) + .expect("seed live OAuth auth with DeepSeek config"); + + let mut provider = Provider::with_id( + "deepseek".to_string(), + "DeepSeek".to_string(), + json!({ + "auth": { + "OPENAI_API_KEY": "deepseek-key" + }, + "config": r#"model_provider = "deepseek" +model = "deepseek-v4-flash" + +[model_providers.deepseek] +name = "DeepSeek" +base_url = "https://api.deepseek.com/v1" +wire_api = "responses" +"# + }), + None, + ); + provider.category = Some("cn_official".to_string()); + db.save_provider("codex", &provider) + .expect("save DeepSeek provider"); + db.set_current_provider("codex", "deepseek") + .expect("set current provider"); + crate::settings::set_current_provider(&AppType::Codex, Some("deepseek")) + .expect("set local current provider"); + + service + .takeover_live_config_strict(&AppType::Codex) + .await + .expect("take over Codex live config"); + + let live_auth: Value = + crate::config::read_json_file(&crate::codex_config::get_codex_auth_path()) + .expect("read live auth"); + assert_eq!( + live_auth, oauth_auth, + "Codex takeover should not overwrite ChatGPT OAuth auth when preservation is enabled" + ); + + let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path()) + .expect("read live config"); + assert!( + live_config.contains(PROXY_TOKEN_PLACEHOLDER), + "takeover placeholder should move into config.toml" + ); + assert!( + service.detect_takeover_in_live_config_for_app(&AppType::Codex), + "Codex takeover detection should recognize config.toml placeholders" + ); + + crate::settings::update_settings(crate::settings::AppSettings::default()) + .expect("reset settings"); + } + + #[test] + #[serial] + fn codex_takeover_cleanup_removes_config_placeholder_without_touching_oauth_auth() { + let _home = TempHome::new(); + crate::settings::reload_settings().expect("reload settings"); + + let db = Arc::new(Database::memory().expect("init db")); + let service = ProxyService::new(db); + let oauth_auth = json!({ + "auth_mode": "chatgpt", + "tokens": { + "id_token": "oauth-id", + "access_token": "oauth-access" + } + }); + crate::codex_config::write_codex_live_atomic( + &oauth_auth, + Some( + r#"model_provider = "deepseek" +model = "deepseek-v4-flash" + +[model_providers.deepseek] +name = "DeepSeek" +base_url = "http://127.0.0.1:15721/v1" +wire_api = "responses" +experimental_bearer_token = "PROXY_MANAGED" +"#, + ), + ) + .expect("seed taken-over Codex live config"); + + assert!( + service.detect_takeover_in_live_config_for_app(&AppType::Codex), + "config.toml placeholder should be detected before cleanup" + ); + + service + .cleanup_codex_takeover_placeholders_in_live() + .expect("cleanup Codex takeover placeholders"); + + let live_auth: Value = + crate::config::read_json_file(&crate::codex_config::get_codex_auth_path()) + .expect("read live auth"); + assert_eq!( + live_auth, oauth_auth, + "cleanup should preserve ChatGPT OAuth auth" + ); + + let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path()) + .expect("read live config"); + assert!( + !live_config.contains(PROXY_TOKEN_PLACEHOLDER), + "cleanup should remove config.toml proxy bearer placeholder" + ); + assert!( + !live_config.contains("http://127.0.0.1:15721"), + "cleanup should remove local proxy base_url" + ); + } + #[test] #[serial] fn codex_custom_provider_live_write_can_overwrite_auth_when_preserve_disabled() { diff --git a/src/components/settings/SettingsPage.tsx b/src/components/settings/SettingsPage.tsx index bfc69d316..9a4734a25 100644 --- a/src/components/settings/SettingsPage.tsx +++ b/src/components/settings/SettingsPage.tsx @@ -242,11 +242,11 @@ export function SettingsPage({ handleAutoSave({ skillSyncMethod: method }) } /> - - diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 0e2a109fb..f4499ce70 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -562,9 +562,9 @@ "enableClaudePluginIntegrationDescription": "When enabled, the VS Code Claude Code extension provider will switch with this app", "skipClaudeOnboarding": "Skip Claude Code first-run confirmation", "skipClaudeOnboardingDescription": "When enabled, Claude Code will skip the first-run confirmation", - "codexAuth": "Codex Authentication", + "codexAuth": "Codex App Enhancements", "preserveCodexOfficialAuthOnSwitch": "Keep official login when switching third-party providers", - "preserveCodexOfficialAuthOnSwitchDescription": "When enabled, third-party Codex switches update only config.toml and keep the ChatGPT login in auth.json. When disabled, auth.json is overwritten with the active provider auth.", + "preserveCodexOfficialAuthOnSwitchDescription": "When enabled, you can use the Codex app's official plugins, mobile remote control, and other features while using third-party APIs.", "appVisibility": { "title": "Homepage Display", "description": "Choose which apps to show on the homepage", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index d1d1956a1..92cbc4f6d 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -562,9 +562,9 @@ "enableClaudePluginIntegrationDescription": "オンにすると VS Code の Claude Code 拡張のプロバイダーも同期します", "skipClaudeOnboarding": "Claude Code の初回確認をスキップ", "skipClaudeOnboardingDescription": "オンにすると Claude Code の初回インストール確認をスキップします", - "codexAuth": "Codex 認証", + "codexAuth": "Codex アプリ拡張", "preserveCodexOfficialAuthOnSwitch": "サードパーティ切替時に公式ログインを保持", - "preserveCodexOfficialAuthOnSwitchDescription": "オンにするとサードパーティ Codex への切替では config.toml のみ更新し、auth.json の ChatGPT ログインを保持します。オフにすると auth.json を現在のプロバイダー認証で上書きします。", + "preserveCodexOfficialAuthOnSwitchDescription": "オンにすると、サードパーティ API を使用する際に Codex アプリの公式プラグインやスマホからのリモート操作などの機能を利用できます。", "appVisibility": { "title": "ホームページ表示", "description": "ホームページに表示するアプリを選択", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 45997bb7f..7d2410c38 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -562,9 +562,9 @@ "enableClaudePluginIntegrationDescription": "開啟後 Vscode Claude Code 外掛程式的供應商將隨本軟體切換", "skipClaudeOnboarding": "跳過 Claude Code 初次安裝確認", "skipClaudeOnboardingDescription": "開啟後跳過 Claude Code 初次安裝確認", - "codexAuth": "Codex 認證", + "codexAuth": "Codex 應用增強", "preserveCodexOfficialAuthOnSwitch": "切換第三方時保留官方登入", - "preserveCodexOfficialAuthOnSwitchDescription": "開啟後切換第三方 Codex 供應商只更新 config.toml,並保留 auth.json 中的 ChatGPT 登入狀態;關閉後會用目前供應商 auth 覆寫 auth.json。", + "preserveCodexOfficialAuthOnSwitchDescription": "開啟後可以在使用第三方 API 的時候使用 Codex 應用的官方外掛、手機遠端操作等功能", "appVisibility": { "title": "主頁面顯示", "description": "選擇在主頁面顯示的應用程式", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 58aac2f8f..0afb08854 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -562,9 +562,9 @@ "enableClaudePluginIntegrationDescription": "开启后 Vscode Claude Code 插件的供应商将随本软件切换", "skipClaudeOnboarding": "跳过 Claude Code 初次安装确认", "skipClaudeOnboardingDescription": "开启后跳过 Claude Code 初次安装确认", - "codexAuth": "Codex 认证", + "codexAuth": "Codex 应用增强", "preserveCodexOfficialAuthOnSwitch": "切换第三方时保留官方登录", - "preserveCodexOfficialAuthOnSwitchDescription": "开启后切换第三方 Codex 供应商只更新 config.toml,并保留 auth.json 中的 ChatGPT 登录态;关闭后会用当前供应商 auth 覆盖 auth.json。", + "preserveCodexOfficialAuthOnSwitchDescription": "开启后可以在使用第三方 API 的时候使用 Codex 应用的官方插件、手机远程操作等功能", "appVisibility": { "title": "主页面显示", "description": "选择在主页面显示的应用",