diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d07bc006..ed5770bf7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +Development since v3.16.0 focuses on preserving Codex OAuth auth and model catalogs during provider switches / proxy takeover, improving Codex proxy error diagnostics, adding DeepSeek routing documentation, and fixing Windows tool version probing. + +**Stats**: 12 commits | 41 files changed | +1,992 insertions | -739 deletions + +### Added + +- **Codex Official Auth Preservation Setting**: Added an opt-in setting that keeps the official ChatGPT / Codex OAuth login in `auth.json` when switching third-party Codex providers, while moving third-party provider tokens into `config.toml` when enabled. +- **Codex DeepSeek Routing Guides**: Added localized DeepSeek routing guides for Codex in English, Chinese, and Japanese, with screenshots covering provider routing requirements, Codex provider setup, and Local Routing takeover. + +### Changed + +- **Codex Auth Preservation Is Opt-In**: The new official-auth preservation setting now defaults to off, so third-party Codex switches keep the legacy behavior of writing the active provider auth unless users explicitly enable preservation. +- **Codex Provider Switch Restart Hint**: Successful Codex provider switches now tell users to restart the Codex client so catalog and config changes take effect. +- **Sponsor Ordering**: Swapped the Shengsuanyun and AICodeMirror sponsor blocks across README locales. +- **Docs Organization**: Moved the Chinese proxy guide under `docs/guides/` and removed the obsolete working-directory plan document. + +### Fixed + +- **Codex Provider Edit Dialog Under Takeover**: The Codex provider edit form now shows an explicit notice and storage-aware auth / config hints clarifying that it displays the stored provider config (not the proxy-managed live `auth.json` / `config.toml`), so the official OAuth token is no longer mistaken as lost while takeover is active. The dialog also treats takeover as active regardless of whether the proxy server is currently running. +- **Codex OAuth Auth During Proxy Takeover**: Fixed multiple preserve-mode takeover paths that could clear or overwrite the official ChatGPT / Codex OAuth `auth.json`. Takeover detection now recognizes `PROXY_MANAGED` in `config.toml`, cleanup only removes placeholder bearer tokens, config-only takeover writes are used consistently, and mis-categorized third-party providers no longer trigger the official-provider auth overwrite path. Provider sync and switching now treat the restore backup and live placeholders as the takeover-ownership signal (instead of the lagging `enabled` / proxy-running flags) and serialize switch/takeover per app, so a just-activated or proxy-stopped takeover can no longer be overwritten by a normal live write. +- **Codex Model Catalog Data Loss**: Fixed cases where Codex `modelCatalog` could be wiped by live-config backfill, active-provider edit dialogs, or proxy takeover-off restore. Snapshot backups now keep existing `model_catalog_json` pointers, while provider-rebuilt backups regenerate the catalog projection from the database source of truth. +- **Codex Proxy Error Diagnostics**: Codex forwarding failures now return richer JSON errors with provider, model, endpoint, upstream status, stable `cc_switch_*` codes, and HTTP statuses aligned with the canonical `ProxyError` response mapping. +- **Windows Tool Version Probing**: Fixed Windows version checks that could misquote `.cmd` / `.bat` commands and decode localized command output as mojibake, causing working tools to appear as "installed but not runnable". + ## [3.16.0] - 2026-05-29 Development since v3.15.0 focuses on making third-party Codex providers work like first-class citizens through Chat Completions routing, stabilizing Codex provider identity and history, adding an in-app managed CLI tool lifecycle, expanding the partner preset catalog, refreshing the default model / pricing matrix around GPT-5.5 and Claude Opus 4.8, and improving usage observability, localization, docs, and proxy robustness. diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs index 90b5a4214..8a6b53807 100644 --- a/src-tauri/src/services/provider/live.rs +++ b/src-tauri/src/services/provider/live.rs @@ -923,6 +923,48 @@ pub(crate) fn sync_current_provider_for_app_to_live( Ok(()) } +fn sync_current_provider_for_app_respecting_takeover( + state: &AppState, + app_type: &AppType, +) -> Result<(), AppError> { + let current_id = match crate::settings::get_effective_current_provider(&state.db, app_type)? { + Some(id) => id, + None => return Ok(()), + }; + + let providers = state.db.get_all_providers(app_type.as_str())?; + let Some(provider) = providers.get(¤t_id) else { + return Ok(()); + }; + + let has_live_backup = futures::executor::block_on(state.db.get_live_backup(app_type.as_str())) + .ok() + .flatten() + .is_some(); + let live_taken_over = state + .proxy_service + .detect_takeover_in_live_config_for_app(app_type); + + // `enabled` is set only after takeover writes complete. During that + // activation window, backup/live placeholders are the authoritative signal + // that normal provider sync must not rewrite the managed live file. + if has_live_backup || live_taken_over { + if matches!(app_type, AppType::ClaudeDesktop) { + write_live_with_common_config(state.db.as_ref(), app_type, provider)?; + } else { + futures::executor::block_on( + state + .proxy_service + .update_live_backup_from_provider(app_type.as_str(), provider), + ) + .map_err(|e| AppError::Message(format!("更新 Live 备份失败: {e}")))?; + } + return Ok(()); + } + + write_live_with_common_config(state.db.as_ref(), app_type, provider) +} + /// Sync current provider to live configuration /// /// 使用有效的当前供应商 ID(验证过存在性)。 @@ -937,19 +979,10 @@ pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> { // Additive mode: sync ALL providers sync_all_providers_to_live(state, &app_type)?; } else { - // Switch mode: sync only current provider - let current_id = - match crate::settings::get_effective_current_provider(&state.db, &app_type)? { - Some(id) => id, - None => continue, - }; - - let providers = state.db.get_all_providers(app_type.as_str())?; - if let Some(provider) = providers.get(¤t_id) { - write_live_with_common_config(state.db.as_ref(), &app_type, provider)?; - } - // Note: get_effective_current_provider already validates existence, - // so providers.get() should always succeed here + // Switch mode: sync only current provider. During proxy takeover, + // update the restore backup instead of rewriting the taken-over + // live file. + sync_current_provider_for_app_respecting_takeover(state, &app_type)?; } } diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index 6978bb641..ba95c9b38 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -1391,11 +1391,13 @@ impl ProviderService { .ok() .flatten() .is_some(); - let is_proxy_running = futures::executor::block_on(state.proxy_service.is_running()); let live_taken_over = state .proxy_service .detect_takeover_in_live_config_for_app(&app_type); - let should_sync_via_proxy = is_proxy_running && (has_live_backup || live_taken_over); + // Backup or live placeholders mean the live file is currently owned + // by proxy takeover, including the short activation window before + // proxy_config.enabled is committed. + let should_sync_via_proxy = has_live_backup || live_taken_over; if should_sync_via_proxy { if matches!(app_type, AppType::ClaudeDesktop) { @@ -1409,7 +1411,9 @@ impl ProviderService { .map_err(|e| AppError::Message(format!("更新 Live 备份失败: {e}")))?; } - if matches!(app_type, AppType::Claude) { + if matches!(app_type, AppType::Claude) + && futures::executor::block_on(state.proxy_service.is_running()) + { futures::executor::block_on( state .proxy_service @@ -1589,21 +1593,32 @@ impl ProviderService { return Self::switch_normal(state, app_type, id, &providers); } - // Check if proxy takeover mode is active AND proxy server is actually running - // Both conditions must be true to use hot-switch mode - // Use blocking wait since this is a sync function + // Provider switches and takeover toggles both mutate live config and the + // restore backup. Serialize them per app, then decide from the locked + // current state so a just-started takeover cannot be overwritten by a + // normal live write. + let _switch_guard = + if matches!(app_type, AppType::Claude | AppType::Codex | AppType::Gemini) { + Some(futures::executor::block_on( + state.proxy_service.lock_switch_for_app(app_type.as_str()), + )) + } else { + None + }; + + // Backup or live placeholders mean the live file is owned by proxy + // takeover, even if the proxy server is temporarily stopped or is in the + // activation window before enabled=true is committed. let is_app_taken_over = futures::executor::block_on(state.db.get_live_backup(app_type.as_str())) .ok() .flatten() .is_some(); - let is_proxy_running = futures::executor::block_on(state.proxy_service.is_running()); let live_taken_over = state .proxy_service .detect_takeover_in_live_config_for_app(&app_type); - // Hot-switch only when BOTH: this app is taken over AND proxy server is actually running - let should_hot_switch = (is_app_taken_over || live_taken_over) && is_proxy_running; + let should_hot_switch = is_app_taken_over || live_taken_over; // Block switching to official providers when proxy takeover is active. // Using a proxy with official APIs (Anthropic/OpenAI/Google) may cause account bans. @@ -1626,7 +1641,7 @@ impl ProviderService { futures::executor::block_on( state .proxy_service - .hot_switch_provider(app_type.as_str(), id), + .hot_switch_provider_inner(app_type.as_str(), id), ) .map_err(|e| AppError::Message(format!("热切换失败: {e}")))?; @@ -1800,11 +1815,6 @@ impl ProviderService { return Ok(()); }; - let takeover_enabled = - futures::executor::block_on(state.db.get_proxy_config_for_app(app_type.as_str())) - .map(|config| config.enabled) - .unwrap_or(false); - let has_live_backup = futures::executor::block_on(state.db.get_live_backup(app_type.as_str())) .ok() @@ -1815,7 +1825,9 @@ impl ProviderService { .proxy_service .detect_takeover_in_live_config_for_app(&app_type); - if takeover_enabled && (has_live_backup || live_taken_over) { + // See the save path above: backup/placeholders are the ownership signal + // here, not just proxy_config.enabled. + if has_live_backup || live_taken_over { if matches!(app_type, AppType::ClaudeDesktop) { write_live_with_common_config(state.db.as_ref(), &app_type, provider)?; return Ok(()); diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index 9f67d5ea3..0ae8dd3dd 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -385,6 +385,13 @@ impl ProxyService { }); } + pub(crate) async fn lock_switch_for_app( + &self, + app_type: &str, + ) -> tokio::sync::OwnedMutexGuard<()> { + self.switch_locks.lock_for_app(app_type).await + } + /// 启动代理服务器 pub async fn start(&self) -> Result { // 1. 启动时自动设置 proxy_enabled = true @@ -535,6 +542,7 @@ impl ProxyService { pub async fn set_takeover_for_app(&self, app_type: &str, enabled: bool) -> Result<(), String> { let app = AppType::from_str(app_type).map_err(|e| format!("无效的应用类型: {e}"))?; let app_type_str = app.as_str(); + let _guard = self.switch_locks.lock_for_app(app_type_str).await; if enabled { // 1) 代理服务未运行则自动启动 @@ -549,6 +557,7 @@ impl ProxyService { .await .map_err(|e| format!("获取 {app_type_str} 配置失败: {e}"))?; + let mut restore_existing_backup_before_takeover = false; if current_config.enabled { let has_backup = match self.db.get_live_backup(app_type_str).await { Ok(v) => v.is_some(), @@ -557,33 +566,45 @@ impl ProxyService { false } }; - let live_taken_over = self.detect_takeover_in_live_config_for_app(&app); + let live_matches_current_proxy = + match self.live_takeover_matches_current_proxy(&app).await { + Ok(value) => value, + Err(e) => { + log::warn!("检测 {app_type_str} 接管配置失败(将继续重建接管): {e}"); + false + } + }; - // 必须 backup AND live 占位符同时存在才算真接管。 - // 只看其一会出现「UI 显示已接管但 Live 已被恢复」或「Live 仍是占位符但备份丢失」 - // 两种脏角落,下面的重建分支会把这些情况修复成一致状态。 - if has_backup && live_taken_over { + // 必须 backup 存在,且 live 确实指向当前代理地址,才算真接管。 + // 只看占位符会把半接管/旧端口残留误判为可复用,导致开启接管后 + // live 文件仍停留在普通供应商配置。 + if has_backup && live_matches_current_proxy { return Ok(()); } + restore_existing_backup_before_takeover = has_backup; log::warn!( - "{app_type_str} 标记为已接管,但 backup={has_backup} live_taken_over={live_taken_over},正在重新接管并补齐备份" + "{app_type_str} 标记为已接管,但 backup={has_backup} live_matches_current_proxy={live_matches_current_proxy},正在重新接管并补齐 Live" ); } // 3) 备份 Live 配置(严格:目标 app 不存在则报错) - self.backup_live_config_strict(&app).await?; + if restore_existing_backup_before_takeover { + self.restore_live_config_for_app_inner(&app).await?; + } else { + self.backup_live_config_strict(&app).await?; - // 4) 同步 Live Token 到数据库(仅当前 app) - if let Err(e) = self.sync_live_to_provider(&app).await { - let _ = self.db.delete_live_backup(app_type_str).await; - return Err(e); + // 4) 同步 Live Token 到数据库(仅当前 app) + if let Err(e) = self.sync_live_to_provider(&app).await { + let _ = self.db.delete_live_backup(app_type_str).await; + return Err(e); + } } // 5) 写入接管配置(仅当前 app) if let Err(e) = self.takeover_live_config_strict(&app).await { log::error!("{app_type_str} 接管 Live 配置失败,尝试恢复: {e}"); - match self.restore_live_config_for_app(&app).await { + match self.restore_live_config_for_app_inner(&app).await { Ok(()) => { // 恢复成功才清理备份,避免失败场景下丢失唯一可回滚来源 let _ = self.db.delete_live_backup(app_type_str).await; @@ -650,7 +671,8 @@ impl ProxyService { // 必须走 with_fallback 版本:备份 → SSOT → 清理占位符 的三层兜底。 // 简版 restore_live_config_for_app 在备份缺失时会静默 Ok(()), // 留下接管时写入的占位符(代理地址/PROXY_MANAGED token),客户端无法工作。 - self.restore_live_config_for_app_with_fallback(&app).await?; + self.restore_live_config_for_app_with_fallback_inner(&app) + .await?; // 2) 删除该 app 的备份(避免长期存储敏感 Token) self.db @@ -1360,12 +1382,6 @@ impl ProxyService { Ok(()) } - /// 恢复指定应用的 Live 配置(若无备份则不做任何操作) - async fn restore_live_config_for_app(&self, app_type: &AppType) -> Result<(), String> { - let _guard = self.switch_locks.lock_for_app(app_type.as_str()).await; - self.restore_live_config_for_app_inner(app_type).await - } - async fn restore_live_config_for_app_inner(&self, app_type: &AppType) -> Result<(), String> { match app_type { AppType::Claude => { @@ -1558,6 +1574,82 @@ impl ProxyService { || rest.starts_with("::") } + fn proxy_urls_match(actual: &str, expected: &str) -> bool { + actual.trim().trim_end_matches('/') == expected.trim().trim_end_matches('/') + } + + fn codex_config_has_base_url_matching( + config_text: &str, + predicate: impl Fn(&str) -> bool, + ) -> bool { + let Ok(doc) = toml::from_str::(config_text) else { + return false; + }; + + let active_provider = doc + .get("model_provider") + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|id| !id.is_empty()); + + if let Some(provider_id) = active_provider { + if doc + .get("model_providers") + .and_then(|value| value.get(provider_id)) + .and_then(|value| value.get("base_url")) + .and_then(|value| value.as_str()) + .is_some_and(&predicate) + { + return true; + } + } + + doc.get("base_url") + .and_then(|value| value.as_str()) + .is_some_and(predicate) + } + + async fn live_takeover_matches_current_proxy( + &self, + app_type: &AppType, + ) -> Result { + let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?; + + match app_type { + AppType::Claude => { + let config = self.read_claude_live()?; + let base_url_matches = config + .get("env") + .and_then(|value| value.get("ANTHROPIC_BASE_URL")) + .and_then(|value| value.as_str()) + .is_some_and(|url| Self::proxy_urls_match(url, &proxy_url)); + Ok(Self::is_claude_live_taken_over(&config) && base_url_matches) + } + AppType::Codex => { + let config = self.read_codex_live()?; + let base_url_matches = config + .get("config") + .and_then(|value| value.as_str()) + .is_some_and(|config_text| { + Self::codex_config_has_base_url_matching(config_text, |url| { + Self::proxy_urls_match(url, &proxy_codex_base_url) + }) + }); + Ok(Self::codex_live_has_proxy_placeholder(&config) && base_url_matches) + } + AppType::Gemini => { + let config = self.read_gemini_live()?; + let base_url_matches = config + .get("env") + .and_then(|value| value.get("GOOGLE_GEMINI_BASE_URL")) + .and_then(|value| value.as_str()) + .is_some_and(|url| Self::proxy_urls_match(url, &proxy_url)); + Ok(Self::is_gemini_live_taken_over(&config) && base_url_matches) + } + _ => Ok(false), + } + } + fn cleanup_claude_takeover_placeholders_in_live(&self) -> Result<(), String> { let mut config = self.read_claude_live()?; @@ -1718,7 +1810,7 @@ impl ProxyService { false } - fn is_codex_live_taken_over(config: &Value) -> bool { + fn codex_live_has_proxy_placeholder(config: &Value) -> bool { if config .get("auth") .and_then(|v| v.as_object()) @@ -1737,6 +1829,10 @@ impl ProxyService { == Some(PROXY_TOKEN_PLACEHOLDER) } + fn is_codex_live_taken_over(config: &Value) -> bool { + Self::codex_live_has_proxy_placeholder(config) + } + fn is_gemini_live_taken_over(config: &Value) -> bool { let env = match config.get("env").and_then(|v| v.as_object()) { Some(env) => env, @@ -1788,6 +1884,7 @@ impl ProxyService { &mut effective_settings, existing_value, )?; + Self::preserve_codex_oauth_auth_in_backup(&mut effective_settings, existing_value)?; } } @@ -1824,7 +1921,14 @@ impl ProxyService { provider_id: &str, ) -> Result { let _guard = self.switch_locks.lock_for_app(app_type).await; + self.hot_switch_provider_inner(app_type, provider_id).await + } + pub(crate) async fn hot_switch_provider_inner( + &self, + app_type: &str, + provider_id: &str, + ) -> Result { let app_type_enum = AppType::from_str(app_type).map_err(|_| format!("无效的应用类型: {app_type}"))?; let provider = self @@ -1973,6 +2077,40 @@ impl ProxyService { Ok(()) } + fn preserve_codex_oauth_auth_in_backup( + target_settings: &mut Value, + existing_backup: &Value, + ) -> Result<(), String> { + if !crate::settings::preserve_codex_official_auth_on_switch() { + return Ok(()); + } + + let Some(existing_auth) = existing_backup + .get("auth") + .filter(|auth| crate::codex_config::codex_auth_has_oauth_login_material(auth)) + .cloned() + else { + return Ok(()); + }; + + let Some(target_obj) = target_settings.as_object_mut() else { + return Ok(()); + }; + + let provider_auth = target_obj.get("auth").cloned().unwrap_or_else(|| json!({})); + if let Some(config_text) = target_obj.get("config").and_then(|value| value.as_str()) { + let live_config = crate::codex_config::prepare_codex_provider_live_config( + &provider_auth, + config_text, + ) + .map_err(|e| format!("更新 Codex 备份配置失败: {e}"))?; + target_obj.insert("config".to_string(), json!(live_config)); + } + target_obj.insert("auth".to_string(), existing_auth); + + Ok(()) + } + /// 代理模式下切换供应商(热切换,不写 Live) pub async fn switch_proxy_target( &self, @@ -2941,6 +3079,450 @@ wire_api = "responses" .expect("reset settings"); } + #[tokio::test] + #[serial] + async fn codex_set_takeover_for_app_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("official".to_string()); + db.save_provider("codex", &provider) + .expect("save misclassified 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 + .set_takeover_for_app("codex", true) + .await + .expect("enable Codex takeover"); + + 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, + "the public takeover command path must not rewrite auth.json when preservation is enabled" + ); + + service + .set_takeover_for_app("codex", false) + .await + .expect("disable Codex takeover"); + crate::settings::update_settings(crate::settings::AppSettings::default()) + .expect("reset settings"); + } + + #[tokio::test] + #[serial] + async fn codex_sync_current_to_live_during_takeover_preserves_oauth_auth_json() { + 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 state = crate::store::AppState::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("official".to_string()); + db.save_provider("codex", &provider) + .expect("save misclassified 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"); + + state + .proxy_service + .set_takeover_for_app("codex", true) + .await + .expect("enable Codex takeover"); + + crate::services::provider::ProviderService::sync_current_to_live(&state) + .expect("sync current providers while Codex is taken over"); + + 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, + "post-change provider sync must not rewrite Codex auth.json during takeover" + ); + + let backup = db + .get_live_backup("codex") + .await + .expect("get live backup") + .expect("backup exists"); + let backup_value: Value = + serde_json::from_str(&backup.original_config).expect("parse backup"); + assert_eq!( + backup_value.get("auth"), + Some(&oauth_auth), + "provider-derived takeover backup should preserve official OAuth auth" + ); + assert!( + backup_value + .get("config") + .and_then(|value| value.as_str()) + .is_some_and(|config| config.contains("deepseek-key")), + "provider token should be carried by config.toml in the restore backup" + ); + + state + .proxy_service + .set_takeover_for_app("codex", false) + .await + .expect("disable Codex takeover"); + let restored_auth: Value = + crate::config::read_json_file(&crate::codex_config::get_codex_auth_path()) + .expect("read restored auth"); + assert_eq!( + restored_auth, oauth_auth, + "turning takeover off should restore the preserved official OAuth auth" + ); + + crate::settings::update_settings(crate::settings::AppSettings::default()) + .expect("reset settings"); + } + + #[tokio::test] + #[serial] + async fn codex_sync_current_to_live_during_takeover_activation_keeps_proxy_live_config() { + 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 state = crate::store::AppState::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("official".to_string()); + db.save_provider("codex", &provider) + .expect("save misclassified 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"); + + state + .proxy_service + .backup_live_config_strict(&AppType::Codex) + .await + .expect("backup Codex live config"); + state + .proxy_service + .takeover_live_config_strict(&AppType::Codex) + .await + .expect("take over Codex live config"); + assert!( + !db.get_proxy_config_for_app("codex") + .await + .expect("get Codex proxy config") + .enabled, + "this reproduces the activation window before set_takeover_for_app marks enabled=true" + ); + + crate::services::provider::ProviderService::sync_current_to_live(&state) + .expect("sync current providers during takeover activation"); + + 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, + "activation-time provider sync must not rewrite Codex OAuth auth.json" + ); + + 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), + "activation-time provider sync must keep the proxy bearer placeholder" + ); + assert!( + live_config.contains("http://127.0.0.1"), + "activation-time provider sync must keep the local proxy base_url" + ); + assert!( + state + .proxy_service + .detect_takeover_in_live_config_for_app(&AppType::Codex), + "Codex live config should still be detected as taken over" + ); + + crate::settings::update_settings(crate::settings::AppSettings::default()) + .expect("reset settings"); + } + + #[tokio::test] + #[serial] + async fn codex_set_takeover_rebuilds_stale_enabled_state_without_overwriting_backup() { + 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 original_deepseek_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" +"#; + let stale_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 = "PROXY_MANAGED" +"#; + crate::codex_config::write_codex_live_atomic(&oauth_auth, Some(stale_live_config)) + .expect("seed stale Codex live 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("official".to_string()); + db.save_provider("codex", &provider) + .expect("save misclassified 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"); + db.save_live_backup( + "codex", + &serde_json::to_string(&json!({ + "auth": oauth_auth, + "config": original_deepseek_config + })) + .expect("serialize original backup"), + ) + .await + .expect("seed original live backup"); + let mut proxy_config = db + .get_proxy_config_for_app("codex") + .await + .expect("get Codex proxy config"); + proxy_config.enabled = true; + db.update_proxy_config_for_app(proxy_config) + .await + .expect("mark Codex takeover enabled"); + + service + .set_takeover_for_app("codex", true) + .await + .expect("rebuild Codex takeover"); + + 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, + "repairing stale takeover must restore the preserved OAuth auth from backup" + ); + + let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path()) + .expect("read live config"); + assert!( + live_config.contains("http://127.0.0.1:15721/v1"), + "stale enabled takeover must be rebuilt to the current proxy base_url" + ); + assert!( + live_config.contains(PROXY_TOKEN_PLACEHOLDER), + "rebuilt takeover should keep the proxy bearer placeholder" + ); + assert!( + service + .live_takeover_matches_current_proxy(&AppType::Codex) + .await + .expect("detect rebuilt Codex takeover"), + "rebuilt Codex live config should match the active proxy address" + ); + + let backup = db + .get_live_backup("codex") + .await + .expect("get Codex live backup") + .expect("backup exists"); + let backup_value: Value = + serde_json::from_str(&backup.original_config).expect("parse backup"); + assert_eq!( + backup_value.get("auth"), + Some(&oauth_auth), + "rebuilding stale takeover must not overwrite the original OAuth backup" + ); + assert!( + backup_value + .get("config") + .and_then(|value| value.as_str()) + .is_some_and(|config| config.contains("deepseek-key") + && !config.contains("http://127.0.0.1")), + "backup should remain the restorable DeepSeek config, not the proxy config" + ); + + service + .set_takeover_for_app("codex", false) + .await + .expect("disable Codex takeover"); + crate::settings::update_settings(crate::settings::AppSettings::default()) + .expect("reset settings"); + } + #[tokio::test] #[serial] async fn codex_takeover_preserve_disabled_uses_legacy_auth_write_path() { diff --git a/src-tauri/tests/provider_service.rs b/src-tauri/tests/provider_service.rs index c8152edaa..d399b01e0 100644 --- a/src-tauri/tests/provider_service.rs +++ b/src-tauri/tests/provider_service.rs @@ -523,6 +523,173 @@ requires_openai_auth = true ); } +#[tokio::test(flavor = "current_thread")] +#[allow( + clippy::await_holding_lock, + reason = "this integration-style test must serialize global test HOME and settings mutations across async takeover calls" +)] +async fn codex_official_to_deepseek_then_takeover_enters_and_restores_proxy_managed_live_config() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + enable_codex_official_auth_preservation(); + let _home = ensure_test_home(); + + let oauth_auth = json!({ + "auth_mode": "chatgpt", + "tokens": { + "access_token": "oauth-access", + "id_token": "oauth-id" + } + }); + let official_config = r#"model_provider = "openai" +model = "gpt-5" + +[model_providers.openai] +name = "OpenAI" +wire_api = "responses" +"#; + write_codex_live_atomic(&oauth_auth, Some(official_config)) + .expect("seed official Codex OAuth live config"); + + let deepseek_provider_config = r#"model_provider = "deepseek" +model = "deepseek-chat" + +[model_providers.deepseek] +name = "DeepSeek" +base_url = "https://api.deepseek.com/v1" +wire_api = "responses" +"#; + let mut initial_config = MultiAppConfig::default(); + { + let manager = initial_config + .get_manager_mut(&AppType::Codex) + .expect("codex manager"); + manager.current = "official-provider".to_string(); + + let mut official_provider = Provider::with_id( + "official-provider".to_string(), + "OpenAI Official".to_string(), + json!({ + "auth": oauth_auth, + "config": official_config + }), + None, + ); + official_provider.category = Some("official".to_string()); + manager + .providers + .insert("official-provider".to_string(), official_provider); + + let mut deepseek_provider = Provider::with_id( + "deepseek-provider".to_string(), + "DeepSeek".to_string(), + json!({ + "auth": {"OPENAI_API_KEY": "deepseek-key"}, + "config": deepseek_provider_config + }), + None, + ); + deepseek_provider.category = Some("custom".to_string()); + manager + .providers + .insert("deepseek-provider".to_string(), deepseek_provider); + } + + let state = create_test_state_with_config(&initial_config).expect("create test state"); + + ProviderService::switch(&state, AppType::Codex, "deepseek-provider") + .expect("switch from official subscription to DeepSeek"); + + let auth_after_switch: serde_json::Value = + read_json_file(&cc_switch_lib::get_codex_auth_path()).expect("read auth after switch"); + assert_eq!( + auth_after_switch, oauth_auth, + "normal provider switch with Codex preservation enabled must keep OAuth auth.json" + ); + + let config_after_switch = + std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect("read config"); + assert!( + config_after_switch.contains("https://api.deepseek.com/v1"), + "normal switch should write the DeepSeek endpoint before takeover" + ); + assert!( + config_after_switch.contains("deepseek-key"), + "normal switch should inject the DeepSeek key into config.toml" + ); + + state + .proxy_service + .set_takeover_for_app("codex", true) + .await + .expect("enable Codex takeover"); + + let auth_after_takeover: serde_json::Value = + read_json_file(&cc_switch_lib::get_codex_auth_path()).expect("read auth after takeover"); + assert_eq!( + auth_after_takeover, oauth_auth, + "enabling takeover must not rewrite Codex OAuth auth.json" + ); + + let config_after_takeover = + std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect("read config"); + assert!( + config_after_takeover.contains("http://127.0.0.1:15721/v1"), + "enabling takeover should point Codex config.toml at the local proxy" + ); + assert!( + config_after_takeover.contains("PROXY_MANAGED"), + "enabling takeover should move the proxy placeholder into config.toml" + ); + assert!( + !config_after_takeover.contains("https://api.deepseek.com/v1"), + "takeover live config should not keep the upstream DeepSeek endpoint" + ); + + let backup = state + .db + .get_live_backup("codex") + .await + .expect("read Codex backup") + .expect("backup exists after takeover"); + let backup_value: serde_json::Value = + serde_json::from_str(&backup.original_config).expect("parse backup"); + let backup_config = backup_value + .get("config") + .and_then(|value| value.as_str()) + .unwrap_or_default(); + assert!( + backup_config.contains("https://api.deepseek.com/v1") + && backup_config.contains("deepseek-key"), + "takeover backup should remain the restorable DeepSeek config" + ); + + state + .proxy_service + .set_takeover_for_app("codex", false) + .await + .expect("disable Codex takeover"); + + let restored_auth: serde_json::Value = + read_json_file(&cc_switch_lib::get_codex_auth_path()).expect("read restored auth"); + assert_eq!( + restored_auth, oauth_auth, + "disabling takeover should restore without replacing OAuth auth.json" + ); + + let restored_config = std::fs::read_to_string(cc_switch_lib::get_codex_config_path()) + .expect("read restored config"); + assert!( + restored_config.contains("https://api.deepseek.com/v1") + && restored_config.contains("deepseek-key"), + "disabling takeover should restore the selected DeepSeek live config" + ); + assert!( + !restored_config.contains("PROXY_MANAGED"), + "restored live config must not keep the proxy placeholder" + ); +} + #[test] fn provider_service_switch_codex_default_overwrites_official_auth_when_preservation_off() { let _guard = test_mutex().lock().expect("acquire test mutex"); @@ -1019,6 +1186,153 @@ fn sync_current_provider_for_app_keeps_live_takeover_and_updates_restore_backup( ); } +#[test] +fn switch_codex_provider_with_takeover_live_but_stopped_proxy_keeps_proxy_live_config() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + enable_codex_official_auth_preservation(); + let _home = ensure_test_home(); + + let oauth_auth = json!({ + "auth_mode": "chatgpt", + "tokens": { + "access_token": "oauth-access", + "id_token": "oauth-id" + } + }); + let old_provider_config = r#"model_provider = "deepseek" +model = "deepseek-chat" + +[model_providers.deepseek] +name = "DeepSeek" +base_url = "https://api.deepseek.com/v1" +wire_api = "responses" +experimental_bearer_token = "old-key" +"#; + let proxy_live_config = r#"model_provider = "deepseek" +model = "deepseek-chat" + +[model_providers.deepseek] +name = "DeepSeek" +base_url = "http://127.0.0.1:15721/v1" +wire_api = "responses" +experimental_bearer_token = "PROXY_MANAGED" +"#; + write_codex_live_atomic(&oauth_auth, Some(proxy_live_config)) + .expect("seed taken-over Codex live config"); + + let mut config = MultiAppConfig::default(); + { + let manager = config + .get_manager_mut(&AppType::Codex) + .expect("codex manager"); + manager.current = "old-provider".to_string(); + + let mut old_provider = Provider::with_id( + "old-provider".to_string(), + "DeepSeek Old".to_string(), + json!({ + "auth": {"OPENAI_API_KEY": "old-key"}, + "config": old_provider_config + }), + None, + ); + old_provider.category = Some("custom".to_string()); + manager + .providers + .insert("old-provider".to_string(), old_provider); + + let mut new_provider = Provider::with_id( + "new-provider".to_string(), + "DeepSeek New".to_string(), + json!({ + "auth": {"OPENAI_API_KEY": "new-key"}, + "config": r#"model_provider = "deepseek-new" +model = "deepseek-reasoner" + +[model_providers.deepseek-new] +name = "DeepSeek New" +base_url = "https://new.deepseek.example/v1" +wire_api = "responses" +"# + }), + None, + ); + new_provider.category = Some("custom".to_string()); + manager + .providers + .insert("new-provider".to_string(), new_provider); + } + + let state = create_test_state_with_config(&config).expect("create test state"); + futures::executor::block_on( + state.db.save_live_backup( + "codex", + &serde_json::to_string(&json!({ + "auth": oauth_auth, + "config": old_provider_config + })) + .expect("serialize backup"), + ), + ) + .expect("seed Codex live backup"); + + assert!( + !futures::executor::block_on(state.proxy_service.is_running()), + "fixture keeps the proxy server stopped" + ); + + ProviderService::switch(&state, AppType::Codex, "new-provider") + .expect("switch should update takeover backup instead of writing normal live config"); + + let auth_after: serde_json::Value = + read_json_file(&cc_switch_lib::get_codex_auth_path()).expect("read auth.json"); + assert_eq!( + auth_after, oauth_auth, + "provider switch during takeover ownership must not rewrite Codex OAuth auth" + ); + + let live_config = + std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect("read config.toml"); + assert!( + live_config.contains("http://127.0.0.1:15721/v1"), + "live config should remain pointed at the local proxy" + ); + assert!( + live_config.contains("PROXY_MANAGED"), + "live config should keep the proxy bearer placeholder" + ); + assert!( + !live_config.contains("https://new.deepseek.example/v1"), + "normal provider base_url must not overwrite taken-over live config" + ); + + let backup = futures::executor::block_on(state.db.get_live_backup("codex")) + .expect("get Codex backup") + .expect("backup exists"); + let backup_value: serde_json::Value = + serde_json::from_str(&backup.original_config).expect("parse backup"); + assert_eq!( + backup_value.get("auth"), + Some(&auth_after), + "restore backup should preserve the official OAuth auth" + ); + let backup_config = backup_value + .get("config") + .and_then(|v| v.as_str()) + .unwrap_or_default(); + assert!( + backup_config.contains("new-key") && backup_config.contains("deepseek-new"), + "restore backup should be rebuilt from the newly selected provider" + ); + + let current = state + .db + .get_current_provider(AppType::Codex.as_str()) + .expect("get current provider"); + assert_eq!(current.as_deref(), Some("new-provider")); +} + #[test] fn explicitly_cleared_common_snippet_is_not_auto_extracted() { let _guard = test_mutex().lock().expect("acquire test mutex");