From d6ebc24cb306815ed973e6447502a61b19151cfe Mon Sep 17 00:00:00 2001 From: SaladDay Date: Wed, 1 Jul 2026 15:43:49 +0000 Subject: [PATCH] fix(codex-oauth): keep managed token in restore backup; fix failing tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI caught three test failures from the previous commit. Root cause: the Live backup (proxy_live_backup) is local restore state that is replayed to ~/.codex/auth.json when proxy takeover ends, so it must contain the real auth to restore a working login. Stripping it (the earlier "don't leak into backup" change) broke restore and over-stripped a user's native login. - Revert the backup auth stripping: remove sanitize_codex_backup_auth from the initial/strict snapshot paths and the forced auth={} in update_live_backup_from_provider_inner. The managed token belongs in the restore backup (the refresh_token is already persisted by CodexOAuthManager, so this is not a new exposure). - Restore the ownership marker (account_id + access_token fingerprint) so backup cleanup can tell our managed write from a user's native `codex login` of the same account. extract now also tolerates the full-bundle shape (refresh_token + last_refresh) so it fingerprints ①'s refreshable writes. clear_codex_auth_in_backup and clear_codex_live_auth_for_managed_account use the marker again; the account_id-only helper is dropped. - Fix the adopt unit test: adopt now invalidates the cached access token, so the test asserts the stored refresh_token/id_token were updated and the cache was cleared instead of reading it back through get_valid_token_bundle (which would trigger a network refresh). Token stays out of the exported provider settings_config (backfill strip) — only the local restore backup keeps it, matching pre-existing behavior. --- src-tauri/src/codex_config.rs | 136 ++++++++++++++++-- .../src/proxy/providers/codex_oauth_auth.rs | 21 +-- src-tauri/src/services/provider/live.rs | 8 ++ src-tauri/src/services/provider/mod.rs | 27 +--- src-tauri/src/services/proxy.rs | 89 +++--------- 5 files changed, 170 insertions(+), 111 deletions(-) diff --git a/src-tauri/src/codex_config.rs b/src-tauri/src/codex_config.rs index 544500aa1..7369ab651 100644 --- a/src-tauri/src/codex_config.rs +++ b/src-tauri/src/codex_config.rs @@ -6,7 +6,9 @@ use crate::config::{ write_json_file, write_text_file, }; use crate::error::AppError; +use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; use std::fs; use std::process::Command; use toml_edit::DocumentMut; @@ -94,6 +96,14 @@ fn codex_native_gateway_rejects_web_search(config_text: &str) -> bool { false } const CODEX_MODEL_CATALOG_TEMPLATE_SLUG: &str = "gpt-5.5"; +const CODEX_MANAGED_OAUTH_LIVE_AUTH_MARKER_FILENAME: &str = "codex_managed_oauth_live_auth.json"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct CodexManagedOAuthLiveAuthMarker { + version: u32, + account_id: String, + access_token_sha256: String, +} /// Which Codex tool surface the generated model catalog should target. /// @@ -148,23 +158,131 @@ pub fn get_codex_auth_path() -> PathBuf { get_codex_config_dir().join("auth.json") } -/// 切走托管 provider 时删除其残留在 `~/.codex/auth.json` 的登录。 +fn get_codex_managed_oauth_live_auth_marker_path() -> PathBuf { + crate::config::get_app_config_dir().join(CODEX_MANAGED_OAUTH_LIVE_AUTH_MARKER_FILENAME) +} + +fn sha256_hex(text: &str) -> String { + let digest = Sha256::digest(text.as_bytes()); + digest + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::() +} + +/// 从 live/备份的 Codex `auth` 中提取 `(account_id, access_token)`,用于 marker 记录/比对。 /// -/// 已知边界(B 方案固有):完整可刷新 bundle 与原生浏览器登录形状一致,无法可靠 -/// 区分「我们写的托管登录」与「用户手动 `codex login` 的同一账号」。因此这里按 -/// account_id 内容判定;在「用户手动登录了同一账号,又切到一个不写 auth.json 的 -/// config-only provider」这一罕见组合下,可能删除用户的原生登录。常见路径(切到 -/// 会写 auth 的 provider)由 `clear_stale_managed_codex_live_auth` 的守卫覆盖。 +/// 仅接受 ChatGPT 登录形状(`auth_mode == "chatgpt"`、`OPENAI_API_KEY` 可清空)。 +/// 托管账号写入的完整 bundle 会额外带 `tokens.refresh_token` 与顶层 `last_refresh`, +/// 这里一并容忍,从而能对完整 bundle 记录/比对指纹(marker 靠 account_id + +/// access_token 哈希识别「我们写的那一份」,与用户原生登录区分开)。 +fn extract_codex_managed_oauth_auth(auth: &Value) -> Option<(String, String)> { + let auth_obj = auth.as_object()?; + + if auth_obj.keys().any(|key| { + !matches!( + key.as_str(), + "auth_mode" | "OPENAI_API_KEY" | "tokens" | "last_refresh" + ) + }) { + return None; + } + + if auth.get("auth_mode").and_then(|value| value.as_str()) != Some("chatgpt") { + return None; + } + + let api_key_is_clearable = auth + .get("OPENAI_API_KEY") + .is_none_or(|value| value.is_null() || value.as_str() == Some("PROXY_MANAGED")); + if !api_key_is_clearable { + return None; + } + + let tokens = auth.get("tokens").and_then(|value| value.as_object())?; + + if tokens.keys().any(|key| { + !matches!( + key.as_str(), + "access_token" | "account_id" | "id_token" | "refresh_token" + ) + }) { + return None; + } + + let account_id = tokens + .get("account_id") + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|id| !id.is_empty())?; + let access_token = tokens + .get("access_token") + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|token| !token.is_empty())?; + + Some((account_id.to_string(), access_token.to_string())) +} + +pub fn record_codex_managed_oauth_live_auth(auth: &Value) -> Result<(), AppError> { + let Some((account_id, access_token)) = extract_codex_managed_oauth_auth(auth) else { + return Ok(()); + }; + + let marker = CodexManagedOAuthLiveAuthMarker { + version: 1, + account_id, + access_token_sha256: sha256_hex(&access_token), + }; + crate::config::write_json_file(&get_codex_managed_oauth_live_auth_marker_path(), &marker) +} + +pub fn codex_auth_matches_recorded_managed_oauth( + auth: &Value, + account_id: &str, +) -> Result { + let account_id = account_id.trim(); + if account_id.is_empty() { + return Ok(false); + } + + let Some((auth_account_id, access_token)) = extract_codex_managed_oauth_auth(auth) else { + return Ok(false); + }; + if auth_account_id != account_id { + return Ok(false); + } + + let marker_path = get_codex_managed_oauth_live_auth_marker_path(); + let marker: CodexManagedOAuthLiveAuthMarker = match read_json_file(&marker_path) { + Ok(marker) => marker, + Err(err) => { + log::warn!( + "Failed to read Codex managed OAuth auth marker at {}: {err}", + marker_path.display() + ); + return Ok(false); + } + }; + + Ok(marker.version == 1 + && marker.account_id == account_id + && marker.access_token_sha256 == sha256_hex(&access_token)) +} + +/// 切走托管 provider 时删除其残留在 `~/.codex/auth.json` 的**由本应用写入的**托管登录。 +/// +/// 仅当 live auth 与最近一次记录的 marker(account_id + access_token 指纹)匹配时才删除, +/// 从而绝不误删用户自己 `codex login` 的原生登录。 pub fn clear_codex_live_auth_for_managed_account(account_id: &str) -> Result<(), AppError> { let auth_path = get_codex_auth_path(); if auth_path.exists() { let auth: Value = read_json_file(&auth_path)?; - // 完整可刷新 bundle 无法凭形状/哈希区分,按 account_id 内容判定是否为该 - // 托管账号的登录;不是则不动(保护用户其它账号的原生登录)。 - if !codex_live_auth_is_managed_chatgpt_login(&auth, account_id) { + if !codex_auth_matches_recorded_managed_oauth(&auth, account_id)? { return Ok(()); } delete_file(&auth_path)?; + let _ = delete_file(&get_codex_managed_oauth_live_auth_marker_path()); } Ok(()) } diff --git a/src-tauri/src/proxy/providers/codex_oauth_auth.rs b/src-tauri/src/proxy/providers/codex_oauth_auth.rs index ad9ff16a6..41a11ba3c 100644 --- a/src-tauri/src/proxy/providers/codex_oauth_auth.rs +++ b/src-tauri/src/proxy/providers/codex_oauth_auth.rs @@ -1379,14 +1379,19 @@ mod tests { .unwrap(); assert!(changed, "rotated refresh_token should be adopted"); - // 完整 bundle 反映新的 refresh_token;access_token 仍取自缓存(无需联网)。 - let bundle = manager - .get_valid_token_bundle_for_account("acc-1") - .await - .unwrap(); - assert_eq!(bundle.refresh_token, "rotated-rt"); - assert_eq!(bundle.access_token, "access-cached"); - assert_eq!(bundle.id_token.as_deref(), Some("id-2")); + // 存储里的 refresh_token / id_token 已更新为盘上(CLI 轮换后)的值。 + { + let accounts = manager.accounts.read().await; + let account = accounts.get("acc-1").expect("account present"); + assert_eq!(account.refresh_token, "rotated-rt"); + assert_eq!(account.id_token.as_deref(), Some("id-2")); + } + // 采纳后清掉了该账号的缓存 access_token,以便下次按新 refresh_token 重取 + // (因此这里不再用 get_valid_token_bundle_for_account 断言——它会触发联网刷新)。 + assert!( + !manager.access_tokens.read().await.contains_key("acc-1"), + "adopt should invalidate the cached access token" + ); // 未知账号不接管。 assert!(!manager diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs index 14746ccce..0266cd01b 100644 --- a/src-tauri/src/services/provider/live.rs +++ b/src-tauri/src/services/provider/live.rs @@ -965,6 +965,14 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re config_str, profile, )?; + if provider + .meta + .as_ref() + .and_then(|meta| meta.managed_account_id_for("codex_oauth")) + .is_some() + { + crate::codex_config::record_codex_managed_oauth_live_auth(auth)?; + } } AppType::Gemini => { // Delegate to write_gemini_live which handles env file writing correctly diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index a15512698..451069b8d 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -1711,27 +1711,6 @@ impl ProviderService { .filter(|id| !id.is_empty()) } - /// 切走托管 Codex provider 时,清理其残留在 `~/.codex/auth.json` 的托管登录。 - /// - /// 但**跳过**「新 provider 自身就写入了同一账号 ChatGPT 登录」的情况(例如非托管 - /// 官方 provider 保存了该账号的原生登录)——那是它刚写入的有效 auth,不应误删。 - fn clear_stale_managed_codex_live_auth( - new_provider: &Provider, - account_id: &str, - ) -> Result<(), AppError> { - let new_provider_owns_same_login = - new_provider - .settings_config - .get("auth") - .is_some_and(|auth| { - crate::codex_config::codex_live_auth_is_managed_chatgpt_login(auth, account_id) - }); - if new_provider_owns_same_login { - return Ok(()); - } - crate::codex_config::clear_codex_live_auth_for_managed_account(account_id) - } - /// 提交 current(settings/DB)前的预检:若目标是托管 Codex official provider, /// 先解析一次有效 live 配置(会联网换取并缓存 token)。失败即返回 Err,从而避免 /// 留下「DB/UI 指向新 provider,但 live 仍是旧 provider」的不一致状态;后续真正 @@ -2165,7 +2144,7 @@ impl ProviderService { .map_err(|e| AppError::Message(format!("更新 Live 备份失败: {e}")))?; if let Some(account_id) = unbound_codex_managed_account_id.as_deref() { - Self::clear_stale_managed_codex_live_auth(&provider, account_id)?; + crate::codex_config::clear_codex_live_auth_for_managed_account(account_id)?; } } @@ -2182,7 +2161,7 @@ impl ProviderService { } else { write_live_with_common_config_for_state(state, &app_type, &provider)?; if let Some(account_id) = unbound_codex_managed_account_id.as_deref() { - Self::clear_stale_managed_codex_live_auth(&provider, account_id)?; + crate::codex_config::clear_codex_live_auth_for_managed_account(account_id)?; } // Sync MCP McpService::sync_all_enabled(state)?; @@ -2515,7 +2494,7 @@ impl ProviderService { && Self::managed_codex_oauth_account_id(provider).is_none() { if let Some(account_id) = current_managed_codex_account_id.as_deref() { - Self::clear_stale_managed_codex_live_auth(provider, account_id)?; + crate::codex_config::clear_codex_live_auth_for_managed_account(account_id)?; } } diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index 5302f0c08..921de55e5 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -1170,48 +1170,6 @@ impl ProxyService { } /// 备份各应用的 Live 配置 - /// 若当前 Codex provider 是托管账号绑定,则把待备份的 live 配置中属于该账号的 - /// ChatGPT 登录 auth 清空。托管账号的可刷新 token 由 CodexOAuthManager 集中保管, - /// 绝不应持久化进 Live 备份(否则完整 bundle 的 refresh_token 会落进 DB 备份槽)。 - fn sanitize_codex_backup_auth(&self, config: &mut Value) { - let Some(current_id) = - crate::settings::get_effective_current_provider(&self.db, &AppType::Codex) - .ok() - .flatten() - else { - return; - }; - let Ok(providers) = self.db.get_all_providers(AppType::Codex.as_str()) else { - return; - }; - let Some(provider) = providers.get(¤t_id) else { - return; - }; - if provider.category.as_deref() != Some("official") { - return; - } - let Some(account_id) = provider - .meta - .as_ref() - .and_then(|meta| meta.managed_account_id_for("codex_oauth")) - .map(|id| id.trim().to_string()) - .filter(|id| !id.is_empty()) - else { - return; - }; - let should_strip = config - .get("auth") - .map(|auth| { - crate::codex_config::codex_live_auth_is_managed_chatgpt_login(auth, &account_id) - }) - .unwrap_or(false); - if should_strip { - if let Some(obj) = config.as_object_mut() { - obj.insert("auth".to_string(), json!({})); - } - } - } - async fn backup_live_configs(&self) -> Result<(), String> { // Claude if let Ok(config) = self.read_claude_live() { @@ -1232,11 +1190,10 @@ impl ProxyService { } // Codex - if let Ok(mut config) = self.read_codex_live() { + if let Ok(config) = self.read_codex_live() { if Self::live_has_proxy_placeholder_for_app(&AppType::Codex, &config) { log::warn!("codex Live 已被代理接管,不备份(避免把代理配置固化进备份槽);下次 stop 会从 SSOT 重建 Live"); } else { - self.sanitize_codex_backup_auth(&mut config); let json_str = serde_json::to_string(&config) .map_err(|e| format!("序列化 Codex 配置失败: {e}"))?; self.db @@ -1266,7 +1223,7 @@ impl ProxyService { /// 备份指定应用的 Live 配置(严格模式:目标配置不存在则返回错误) async fn backup_live_config_strict(&self, app_type: &AppType) -> Result<(), String> { - let (app_type_str, mut config) = match app_type { + let (app_type_str, config) = match app_type { AppType::Claude => ("claude", self.read_claude_live()?), AppType::Codex => ("codex", self.read_codex_live()?), AppType::Gemini => ("gemini", self.read_gemini_live()?), @@ -1282,11 +1239,6 @@ impl ProxyService { return Ok(()); } - // 托管 Codex 账号的可刷新 token 不入备份(见 sanitize_codex_backup_auth)。 - if matches!(app_type, AppType::Codex) { - self.sanitize_codex_backup_auth(&mut config); - } - let json_str = serde_json::to_string(&config) .map_err(|e| format!("序列化 {app_type_str} 配置失败: {e}"))?; self.db @@ -2143,22 +2095,6 @@ impl ProxyService { &mut effective_settings, ) .map_err(|e| format!("注入统一会话路由失败: {e}"))?; - - // 托管账号:build 出的 effective_settings.auth 是完整可刷新 bundle(含 - // refresh_token),绝不能写进 DB 备份(token 由 CodexOAuthManager 集中保管)。 - // 覆盖上面的 clear/preserve 分支,统一剥离为占位;恢复时 live 由 manager - // 重新派生。 - let provider_is_managed_codex = provider - .meta - .as_ref() - .and_then(|meta| meta.managed_account_id_for("codex_oauth")) - .map(|id| !id.trim().is_empty()) - .unwrap_or(false); - if provider_is_managed_codex { - if let Some(obj) = effective_settings.as_object_mut() { - obj.insert("auth".to_string(), json!({})); - } - } } let backup_json = match app_type_enum { @@ -2184,6 +2120,19 @@ impl ProxyService { .await .map_err(|e| format!("更新 {app_type} 备份失败: {e}"))?; + if matches!(app_type_enum, AppType::Codex) + && provider + .meta + .as_ref() + .and_then(|meta| meta.managed_account_id_for("codex_oauth")) + .is_some() + { + if let Some(auth) = effective_settings.get("auth") { + crate::codex_config::record_codex_managed_oauth_live_auth(auth) + .map_err(|e| format!("记录 Codex 托管认证标记失败: {e}"))?; + } + } + log::info!("已更新 {app_type} Live 备份(热切换)"); Ok(()) } @@ -2366,10 +2315,10 @@ impl ProxyService { return Err("Codex 备份必须是 JSON 对象".to_string()); }; - // 托管账号写入的是完整可刷新 bundle(含 refresh_token),必须避免它被 - // 持久化进 Live 备份。按 account_id 内容判定:命中托管账号则清空备份 auth, - // 否则原样保留用户自己的登录以便接管结束后还原。 - if crate::codex_config::codex_live_auth_is_managed_chatgpt_login(existing_auth, account_id) + // 仅当备份里的 auth 是**本应用写入的**托管登录(与 marker 指纹匹配)时才清空; + // 用户自己 `codex login` 的原生登录原样保留,以便接管结束后还原。 + if crate::codex_config::codex_auth_matches_recorded_managed_oauth(existing_auth, account_id) + .map_err(|e| format!("检查 Codex 托管认证标记失败: {e}"))? { target_obj.insert("auth".to_string(), json!({})); return Ok(());