From c72bd49213a3f081c351097809e460fe56369107 Mon Sep 17 00:00:00 2001 From: SaladDay Date: Wed, 1 Jul 2026 15:19:12 +0000 Subject: [PATCH] fix(codex-oauth): make managed accounts safe & usable with the bare CLI Addresses review feedback on #3879. These backend changes are deeply intertwined across shared files, so they are committed together. Refreshable managed auth (bare `codex` support): - Write a full, native-shaped auth.json bundle (tokens.refresh_token + top-level last_refresh) instead of an access-only token, so the Codex CLI can self-refresh and the managed account keeps working past ~1h without a proxy. - last_refresh reflects the access token's real obtained-at time (not write time), so the CLI doesn't treat a cached token as freshly refreshed. - Read back the CLI-rotated refresh_token from ~/.codex/auth.json before writing (account_id-matched, chatgpt-mode guarded) so re-switching doesn't clobber the CLI's valid login with a stale refresh_token. Never persist managed tokens at rest: - Backfill of a managed provider always replaces live auth with the stored placeholder (even on marker mismatch), so native tokens can't leak into the provider's DB config. - Sanitize managed auth out of Live backups on the initial/strict snapshot paths and in update_live_backup_from_provider_inner before serialization. Concurrency / blocking: - Drop the redundant outer Arc> (all methods are &self) so token refresh no longer holds a coarse lock across the network; give OAuth token/device requests a 30s timeout instead of the shared 600s client default. - Add a persistence lock and linearize the access-token cache under a consistent accounts -> access_tokens order (existence-checked reads/writes; remove/clear clear the cache atomically) to prevent stale or resurrected cache entries. Switch ordering: - Preflight the managed token before committing the current provider on switch/add/update, so a failed token fetch can't leave DB/UI pointing at a provider whose live config was never written. Cleanup: - Remove the now-dead content-fingerprint marker machinery; managed writes are now shape-identical to a native login, so ownership is judged by account_id. Known, documented limitations (B-scheme, narrow & recoverable): a remove+re-login of the same account within an in-flight refresh window (ABA), a login authorized after logout, and switch-away cleanup being unable to distinguish our write from a user's native login of the same account. --- src-tauri/src/codex_config.rs | 316 ++++++------------ src-tauri/src/commands/auth.rs | 14 +- src-tauri/src/commands/codex_oauth.rs | 11 +- src-tauri/src/proxy/forwarder.rs | 4 +- .../src/proxy/providers/codex_oauth_auth.rs | 269 +++++++++++++-- src-tauri/src/services/provider/live.rs | 289 +++++++++++----- src-tauri/src/services/provider/mod.rs | 132 +++++++- src-tauri/src/services/proxy.rs | 98 ++++-- src-tauri/src/store.rs | 10 +- 9 files changed, 763 insertions(+), 380 deletions(-) diff --git a/src-tauri/src/codex_config.rs b/src-tauri/src/codex_config.rs index 42b6b1f57..544500aa1 100644 --- a/src-tauri/src/codex_config.rs +++ b/src-tauri/src/codex_config.rs @@ -6,9 +6,7 @@ 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; @@ -96,14 +94,6 @@ 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. /// @@ -158,123 +148,96 @@ pub fn get_codex_auth_path() -> PathBuf { get_codex_config_dir().join("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::() -} - -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")) - { - 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())?; - - // id_token 与原生浏览器登录一致,允许存在;但原生登录特有的 - // refresh_token 等字段仍会被拒绝,从而保留「托管 vs 原生」的指纹区分。 - if tokens - .keys() - .any(|key| !matches!(key.as_str(), "access_token" | "account_id" | "id_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` 的登录。 +/// +/// 已知边界(B 方案固有):完整可刷新 bundle 与原生浏览器登录形状一致,无法可靠 +/// 区分「我们写的托管登录」与「用户手动 `codex login` 的同一账号」。因此这里按 +/// account_id 内容判定;在「用户手动登录了同一账号,又切到一个不写 auth.json 的 +/// config-only provider」这一罕见组合下,可能删除用户的原生登录。常见路径(切到 +/// 会写 auth 的 provider)由 `clear_stale_managed_codex_live_auth` 的守卫覆盖。 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 = read_json_file(&auth_path)?; - if !codex_auth_matches_recorded_managed_oauth(&auth, account_id)? { + let auth: Value = read_json_file(&auth_path)?; + // 完整可刷新 bundle 无法凭形状/哈希区分,按 account_id 内容判定是否为该 + // 托管账号的登录;不是则不动(保护用户其它账号的原生登录)。 + if !codex_live_auth_is_managed_chatgpt_login(&auth, account_id) { return Ok(()); } delete_file(&auth_path)?; - let _ = delete_file(&get_codex_managed_oauth_live_auth_marker_path()); } Ok(()) } +/// 判断给定的 Codex `auth`(来自 live auth.json 或 Live 备份)是否是「属于 +/// `account_id` 的 ChatGPT 托管登录」。 +/// +/// 托管账号写入的是**完整可刷新 bundle**,与原生浏览器登录形状一致(都含 +/// refresh_token),且 Codex CLI 会轮换 token 使旧的 access_token 指纹失效,因此 +/// 无法再凭形状/哈希区分。这里采用**基于内容的 account_id 判定**:只要是 chatgpt +/// 模式、且 `tokens.account_id` 命中托管账号,即视为该账号的登录。对同一账号的原生 +/// 登录会被同等处理(同账号,无损)。 +/// +/// 用于 Live 备份剥离:避免把托管账号的可刷新 token 持久化进备份配置。 +pub fn codex_live_auth_is_managed_chatgpt_login(auth: &Value, account_id: &str) -> bool { + let account_id = account_id.trim(); + if account_id.is_empty() { + return false; + } + let Some(obj) = auth.as_object() else { + return false; + }; + if obj.get("auth_mode").and_then(|value| value.as_str()) != Some("chatgpt") { + return false; + } + let api_key_clearable = obj + .get("OPENAI_API_KEY") + .is_none_or(|value| value.is_null() || value.as_str() == Some("PROXY_MANAGED")); + if !api_key_clearable { + return false; + } + obj.get("tokens") + .and_then(|tokens| tokens.as_object()) + .and_then(|tokens| tokens.get("account_id")) + .and_then(|value| value.as_str()) + .map(str::trim) + == Some(account_id) +} + +/// 读回 Codex CLI 当前 `~/.codex/auth.json` 中属于 `account_id` 的 refresh_token / +/// id_token(仅当磁盘上的登录账号与之一致时)。 +/// +/// 用于切换回托管 provider 前,采纳 CLI 自行刷新时轮换出的最新 refresh_token,避免 +/// 用陈腐 token 覆盖 CLI 的有效登录(“裸跑 codex” 反复切换场景)。 +pub fn read_codex_live_auth_refresh_for_account( + account_id: &str, +) -> Option<(String, Option)> { + let account_id = account_id.trim(); + if account_id.is_empty() { + return None; + } + let auth_path = get_codex_auth_path(); + if !auth_path.exists() { + return None; + } + let auth: Value = read_json_file(&auth_path).ok()?; + // 仅在磁盘上确是「该 account_id 的 ChatGPT 登录」时才采纳其 refresh_token, + // 避免从非 chatgpt/异常 auth 里误取 token。 + if !codex_live_auth_is_managed_chatgpt_login(&auth, account_id) { + return None; + } + let tokens = auth.get("tokens")?.as_object()?; + let refresh_token = tokens.get("refresh_token")?.as_str()?.trim().to_string(); + if refresh_token.is_empty() { + return None; + } + let id_token = tokens + .get("id_token") + .and_then(|value| value.as_str()) + .map(|token| token.to_string()); + Some((refresh_token, id_token)) +} + /// 获取 Codex config.toml 路径 pub fn get_codex_config_path() -> PathBuf { get_codex_config_dir().join("config.toml") @@ -1826,36 +1789,6 @@ pub fn remove_codex_toml_base_url_if(toml_str: &str, predicate: impl Fn(&str) -> mod tests { use super::*; use serde_json::json; - use serial_test::serial; - use std::env; - - struct TempHome { - #[allow(dead_code)] - dir: tempfile::TempDir, - original_test_home: Option, - } - - impl TempHome { - fn new() -> Self { - let dir = tempfile::tempdir().expect("create temp home"); - let original_test_home = env::var("CC_SWITCH_TEST_HOME").ok(); - env::set_var("CC_SWITCH_TEST_HOME", dir.path()); - - Self { - dir, - original_test_home, - } - } - } - - impl Drop for TempHome { - fn drop(&mut self) { - match &self.original_test_home { - Some(value) => env::set_var("CC_SWITCH_TEST_HOME", value), - None => env::remove_var("CC_SWITCH_TEST_HOME"), - } - } - } #[test] fn unified_session_bucket_injects_for_empty_official_config() { @@ -2016,86 +1949,35 @@ base_url = "https://single.example.com/v1" } #[test] - #[serial] - fn recorded_managed_oauth_marker_matches_only_same_account_and_token() { - let _home = TempHome::new(); - let managed_auth = json!({ + fn managed_chatgpt_login_matched_by_account_id_including_full_refresh_bundle() { + // ① 之后托管写入的是含 refresh_token 的完整 bundle;备份剥离必须凭 account_id + // 认出它,避免把可刷新 token 持久化进 Live 备份。 + let full_bundle = json!({ "auth_mode": "chatgpt", "OPENAI_API_KEY": null, "tokens": { - "access_token": "managed-token", - "account_id": "acct-managed" - } - }); - let same_account_native_auth = json!({ - "auth_mode": "chatgpt", - "OPENAI_API_KEY": null, - "tokens": { - "access_token": "native-token", - "account_id": "acct-managed" - } - }); - - assert!( - !codex_auth_matches_recorded_managed_oauth(&managed_auth, "acct-managed") - .expect("marker check before record"), - "without a cc-switch marker, OAuth-shaped auth must not be treated as managed" - ); - - record_codex_managed_oauth_live_auth(&managed_auth).expect("record marker"); - - assert!( - codex_auth_matches_recorded_managed_oauth(&managed_auth, "acct-managed") - .expect("marker should match"), - "the recorded managed token should be clearable" - ); - assert!( - !codex_auth_matches_recorded_managed_oauth(&same_account_native_auth, "acct-managed") - .expect("same-account native token should not match"), - "a later native login for the same account must not be cleared" - ); - } - - #[test] - #[serial] - fn recorded_managed_oauth_marker_tolerates_id_token_but_rejects_native_login_shape() { - let _home = TempHome::new(); - // 托管写入:tokens 仅含 id_token + access_token + account_id - let managed_auth_with_id_token = json!({ - "auth_mode": "chatgpt", - "OPENAI_API_KEY": null, - "tokens": { - "id_token": "managed-id-token", - "access_token": "managed-token", - "account_id": "acct-managed" - } - }); - // 原生浏览器登录:即使 access_token 巧合相同,也带有 refresh_token - // 与顶层 last_refresh,必须被判定为非托管、永不清除。 - let native_login_auth = json!({ - "auth_mode": "chatgpt", - "OPENAI_API_KEY": null, - "tokens": { - "id_token": "native-id-token", - "access_token": "managed-token", - "refresh_token": "native-refresh-token", + "id_token": "id", + "access_token": "access", + "refresh_token": "refresh-secret", "account_id": "acct-managed" }, - "last_refresh": "2026-01-01T00:00:00Z" + "last_refresh": "2026-01-02T03:04:05.000000000Z" }); - - record_codex_managed_oauth_live_auth(&managed_auth_with_id_token).expect("record marker"); - assert!( - codex_auth_matches_recorded_managed_oauth(&managed_auth_with_id_token, "acct-managed") - .expect("managed auth with id_token should match"), - "an id_token alongside access_token/account_id must still be treated as managed" + codex_live_auth_is_managed_chatgpt_login(&full_bundle, "acct-managed"), + "a full refreshable bundle for the managed account must be recognized" ); assert!( - !codex_auth_matches_recorded_managed_oauth(&native_login_auth, "acct-managed") - .expect("native login should not match"), - "a real browser login (refresh_token + last_refresh) must never be treated as managed" + !codex_live_auth_is_managed_chatgpt_login(&full_bundle, "acct-other"), + "a login for a different account must not match" ); + + // 非 chatgpt 模式(API key)不应命中。 + let api_key_auth = json!({ "OPENAI_API_KEY": "sk-live" }); + assert!(!codex_live_auth_is_managed_chatgpt_login( + &api_key_auth, + "acct-managed" + )); } #[test] diff --git a/src-tauri/src/commands/auth.rs b/src-tauri/src/commands/auth.rs index a64901c58..f2bc32632 100644 --- a/src-tauri/src/commands/auth.rs +++ b/src-tauri/src/commands/auth.rs @@ -99,7 +99,7 @@ pub async fn auth_start_login( Ok(map_device_code_response(auth_provider, response)) } AUTH_PROVIDER_CODEX_OAUTH => { - let auth_manager = codex_state.0.read().await; + let auth_manager = &codex_state.0; let response = auth_manager .start_device_flow() .await @@ -137,7 +137,7 @@ pub async fn auth_poll_for_account( } } AUTH_PROVIDER_CODEX_OAUTH => { - let auth_manager = codex_state.0.write().await; + let auth_manager = &codex_state.0; match auth_manager.poll_for_token(&device_code).await { Ok(account) => { let default_account_id = auth_manager.get_status().await.default_account_id; @@ -172,7 +172,7 @@ pub async fn auth_list_accounts( .collect()) } AUTH_PROVIDER_CODEX_OAUTH => { - let auth_manager = codex_state.0.read().await; + let auth_manager = &codex_state.0; let status = auth_manager.get_status().await; let default_account_id = status.default_account_id.clone(); Ok(status @@ -212,7 +212,7 @@ pub async fn auth_get_status( }) } AUTH_PROVIDER_CODEX_OAUTH => { - let auth_manager = codex_state.0.read().await; + let auth_manager = &codex_state.0; let status = auth_manager.get_status().await; let default_account_id = status.default_account_id.clone(); Ok(ManagedAuthStatus { @@ -250,7 +250,7 @@ pub async fn auth_remove_account( .map_err(|e| e.to_string()) } AUTH_PROVIDER_CODEX_OAUTH => { - let auth_manager = codex_state.0.write().await; + let auth_manager = &codex_state.0; auth_manager .remove_account(&account_id) .await @@ -277,7 +277,7 @@ pub async fn auth_set_default_account( .map_err(|e| e.to_string()) } AUTH_PROVIDER_CODEX_OAUTH => { - let auth_manager = codex_state.0.write().await; + let auth_manager = &codex_state.0; auth_manager .set_default_account(&account_id) .await @@ -300,7 +300,7 @@ pub async fn auth_logout( auth_manager.clear_auth().await.map_err(|e| e.to_string()) } AUTH_PROVIDER_CODEX_OAUTH => { - let auth_manager = codex_state.0.write().await; + let auth_manager = &codex_state.0; auth_manager.clear_auth().await.map_err(|e| e.to_string()) } _ => unreachable!(), diff --git a/src-tauri/src/commands/codex_oauth.rs b/src-tauri/src/commands/codex_oauth.rs index eb1ceb663..b0b4aef71 100644 --- a/src-tauri/src/commands/codex_oauth.rs +++ b/src-tauri/src/commands/codex_oauth.rs @@ -10,10 +10,13 @@ use crate::services::model_fetch::FetchedModel; use crate::services::subscription::{query_codex_quota, CredentialStatus, SubscriptionQuota}; use std::sync::Arc; use tauri::State; -use tokio::sync::RwLock; /// Codex OAuth 认证状态 -pub struct CodexOAuthState(pub Arc>); +/// +/// `CodexOAuthManager` 内部已使用细粒度锁且所有方法均为 `&self`,因此这里 +/// 直接持有 `Arc`,不再包一层 `RwLock`——避免任一命令持有粗粒度锁跨网络刷新 +/// 时阻塞其他命令(切换 / 认证中心操作 / token 读取)。 +pub struct CodexOAuthState(pub Arc); /// 查询 Codex OAuth (ChatGPT Plus/Pro) 订阅额度 /// @@ -26,7 +29,7 @@ pub async fn get_codex_oauth_quota( account_id: Option, state: State<'_, CodexOAuthState>, ) -> Result { - let manager = state.0.read().await; + let manager = &state.0; // 解析最终使用的账号 ID:显式 > 默认账号 > 无账号 (not_found) let resolved = match account_id { @@ -68,7 +71,7 @@ pub async fn get_codex_oauth_models( account_id: Option, state: State<'_, CodexOAuthState>, ) -> Result, String> { - let manager = state.0.read().await; + let manager = &state.0; let resolved = match account_id .as_deref() .map(str::trim) diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index d11e52901..4c2aae719 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -23,7 +23,6 @@ use super::{ ProxyError, }; use crate::commands::{CodexOAuthState, CopilotAuthState}; -use crate::proxy::providers::codex_oauth_auth::CodexOAuthManager; use crate::proxy::providers::copilot_auth::CopilotAuthManager; use crate::{ app_config::AppType, @@ -1484,8 +1483,7 @@ impl RequestForwarder { if auth.strategy == AuthStrategy::CodexOAuth { if let Some(app_handle) = &self.app_handle { let codex_state = app_handle.state::(); - let codex_auth: tokio::sync::RwLockReadGuard<'_, CodexOAuthManager> = - codex_state.0.read().await; + let codex_auth = &codex_state.0; // 从 provider.meta 获取关联的 ChatGPT 账号 ID let account_id = provider diff --git a/src-tauri/src/proxy/providers/codex_oauth_auth.rs b/src-tauri/src/proxy/providers/codex_oauth_auth.rs index 9bbe60238..ad9ff16a6 100644 --- a/src-tauri/src/proxy/providers/codex_oauth_auth.rs +++ b/src-tauri/src/proxy/providers/codex_oauth_auth.rs @@ -23,6 +23,7 @@ use std::fs; use std::io::Write; use std::path::PathBuf; use std::sync::Arc; +use std::time::Duration; use tokio::sync::{Mutex, RwLock}; use super::copilot_auth::{GitHubAccount, GitHubDeviceCodeResponse}; @@ -48,6 +49,10 @@ const DEVICE_REDIRECT_URI: &str = "https://auth.openai.com/deviceauth/callback"; /// Token 刷新提前量(毫秒) const TOKEN_REFRESH_BUFFER_MS: i64 = 60_000; +/// OAuth token/device 端点的单请求超时。共享 HTTP client 默认 600s 超时是给 +/// 大模型流式响应用的,对认证请求过长;网络卡住时应尽快失败而非长时间阻塞。 +const OAUTH_HTTP_TIMEOUT: Duration = Duration::from_secs(30); + /// Device Code 默认有效时长(秒),OpenAI 文档约定 15 分钟 const DEVICE_CODE_DEFAULT_EXPIRES_IN: u64 = 900; @@ -160,6 +165,10 @@ struct CachedAccessToken { token: String, /// 过期时间戳(毫秒) expires_at_ms: i64, + /// 获取(刷新)时间戳(毫秒)。用于写入托管 auth.json 的 `last_refresh`, + /// 使其如实反映 access_token 的真实获取时间,而非写盘时刻——否则 Codex CLI + /// 会误判一个旧 token 是刚刷新的。 + obtained_at_ms: i64, } impl CachedAccessToken { @@ -225,6 +234,17 @@ struct CodexOAuthStore { default_account_id: Option, } +/// 写入托管 Codex `auth.json` 所需的完整可刷新 token 束。 +#[derive(Debug, Clone)] +pub(crate) struct ManagedTokenBundle { + pub access_token: String, + pub id_token: Option, + pub refresh_token: String, + /// access_token 的真实获取时间,RFC3339 纳秒精度 + `Z`(与原生 auth.json 的 + /// `last_refresh` 形状一致)。反映 token 何时真正刷新,而非写盘时刻。 + pub last_refresh: String, +} + /// Codex OAuth 认证管理器(多账号) pub struct CodexOAuthManager { accounts: Arc>>, @@ -237,6 +257,10 @@ pub struct CodexOAuthManager { /// 过期条目会在 start_device_flow 时被清理,防止放弃的登录流程导致无界增长 pending_device_codes: Arc>>, storage_path: PathBuf, + /// 持久化串行锁:`save_to_disk` 与 `clear_auth` 的「快照+写盘/删文件」都在此锁内 + /// 完成。此前由外层 `RwLock` 的写锁隐式串行化;去掉外层锁后 + /// 需要它防止并发保存/清除交错,导致已删账号被旧快照复活。 + storage_lock: Arc>, } impl CodexOAuthManager { @@ -250,6 +274,7 @@ impl CodexOAuthManager { refresh_locks: Arc::new(RwLock::new(HashMap::new())), pending_device_codes: Arc::new(RwLock::new(HashMap::new())), storage_path, + storage_lock: Arc::new(Mutex::new(())), }; if let Err(e) = manager.load_from_disk_sync() { @@ -272,6 +297,7 @@ impl CodexOAuthManager { let response = crate::proxy::http_client::get() .post(DEVICE_AUTH_USERCODE_URL) + .timeout(OAUTH_HTTP_TIMEOUT) .header("Content-Type", "application/json") .header("User-Agent", CODEX_USER_AGENT) .json(&serde_json::json!({ "client_id": CODEX_CLIENT_ID })) @@ -354,6 +380,7 @@ impl CodexOAuthManager { let poll_response = crate::proxy::http_client::get() .post(DEVICE_AUTH_TOKEN_URL) + .timeout(OAUTH_HTTP_TIMEOUT) .header("Content-Type", "application/json") .header("User-Agent", CODEX_USER_AGENT) .json(&serde_json::json!({ @@ -408,21 +435,11 @@ impl CodexOAuthManager { CodexOAuthError::ParseError("无法从 token 中提取 account_id".to_string()) })?; - // 缓存 access_token - { - let mut tokens_cache = self.access_tokens.write().await; - tokens_cache.insert( - account_id.clone(), - CachedAccessToken { - token: tokens.access_token.clone(), - expires_at_ms: compute_expires_at_ms(tokens.expires_in), - }, - ); - } - + // 先落账号(写 accounts + 持久化),再按全局 accounts -> access_tokens 顺序、 + // 在存在性确认下写 token 缓存,遵守「缓存条目只对应存在的账号」。 let account = self .add_account_internal( - account_id, + account_id.clone(), refresh_token, email, // 空字符串视为缺失,避免写出空的 id_token @@ -430,6 +447,21 @@ impl CodexOAuthManager { ) .await?; + { + let accounts = self.accounts.read().await; + if accounts.contains_key(&account_id) { + let mut tokens_cache = self.access_tokens.write().await; + tokens_cache.insert( + account_id.clone(), + CachedAccessToken { + token: tokens.access_token.clone(), + expires_at_ms: compute_expires_at_ms(tokens.expires_in), + obtained_at_ms: chrono::Utc::now().timestamp_millis(), + }, + ); + } + } + Ok(Some(account)) } @@ -441,6 +473,7 @@ impl CodexOAuthManager { ) -> Result { let response = crate::proxy::http_client::get() .post(OAUTH_TOKEN_URL) + .timeout(OAUTH_HTTP_TIMEOUT) .header("Content-Type", "application/x-www-form-urlencoded") .header("User-Agent", CODEX_USER_AGENT) .form(&[ @@ -474,6 +507,7 @@ impl CodexOAuthManager { ) -> Result { let response = crate::proxy::http_client::get() .post(OAUTH_TOKEN_URL) + .timeout(OAUTH_HTTP_TIMEOUT) .header("Content-Type", "application/x-www-form-urlencoded") .header("User-Agent", CODEX_USER_AGENT) .form(&[ @@ -510,12 +544,36 @@ impl CodexOAuthManager { &self, account_id: &str, ) -> Result { - // 先检查缓存 + Ok(self.resolve_valid_cached_token(account_id).await?.token) + } + + /// 解析账号的有效缓存 token(含真实获取时间),必要时刷新。 + /// + /// 返回完整 `CachedAccessToken`,使 token 与其 `obtained_at_ms` 天然配套(写托管 + /// auth.json 的 `last_refresh` 直接取用),避免分两次读缓存造成的错配。 + /// + /// 并发正确性:统一按 `accounts -> access_tokens` 顺序加锁——读/写 token 缓存前都 + /// 在 `accounts` 读锁下确认账号仍存在。配合 `remove_account`/`clear_auth` 在 + /// `accounts` 写锁内原子清缓存,杜绝「已删账号的 token 被写回或被继续返回」。 + /// + /// 已知未覆盖边界(ABA,极窄且可恢复):若一次刷新已用旧 refresh_token 在飞(≤30s + /// 超时),期间同一 `account_id` 被 remove 后又重新登录,则旧刷新返回时可能把旧 + /// generation 的 token 写进新账号。需要 generation/version 校验才能彻底关闭;因触发 + /// 需要「刷新在飞窗口内 remove+重加同一账号」且结果可通过重新登录恢复,暂不引入。 + async fn resolve_valid_cached_token( + &self, + account_id: &str, + ) -> Result { + // 快路径:确认账号存在后读缓存 { + let accounts = self.accounts.read().await; + if !accounts.contains_key(account_id) { + return Err(CodexOAuthError::AccountNotFound(account_id.to_string())); + } let tokens = self.access_tokens.read().await; if let Some(cached) = tokens.get(account_id) { if !cached.is_expiring_soon() { - return Ok(cached.token.clone()); + return Ok(cached.clone()); } } } @@ -525,12 +583,16 @@ impl CodexOAuthManager { let refresh_lock = self.get_refresh_lock(account_id).await; let _guard = refresh_lock.lock().await; - // double-check + // double-check(同样在 accounts 读锁下) { + let accounts = self.accounts.read().await; + if !accounts.contains_key(account_id) { + return Err(CodexOAuthError::AccountNotFound(account_id.to_string())); + } let tokens = self.access_tokens.read().await; if let Some(cached) = tokens.get(account_id) { if !cached.is_expiring_soon() { - return Ok(cached.token.clone()); + return Ok(cached.clone()); } } } @@ -574,21 +636,24 @@ impl CodexOAuthManager { self.save_to_disk().await?; } - let access_token = new_tokens.access_token.clone(); - let expires_at_ms = compute_expires_at_ms(new_tokens.expires_in); + let cached = CachedAccessToken { + token: new_tokens.access_token.clone(), + expires_at_ms: compute_expires_at_ms(new_tokens.expires_in), + obtained_at_ms: chrono::Utc::now().timestamp_millis(), + }; + // 在 accounts 读锁下确认账号仍存在,再写缓存:与 remove/clear(持 accounts + // 写锁并原子清缓存)互斥,杜绝把已删账号的 token 写回缓存。 { + let accounts = self.accounts.read().await; + if !accounts.contains_key(account_id) { + return Err(CodexOAuthError::AccountNotFound(account_id.to_string())); + } let mut tokens = self.access_tokens.write().await; - tokens.insert( - account_id.to_string(), - CachedAccessToken { - token: access_token.clone(), - expires_at_ms, - }, - ); + tokens.insert(account_id.to_string(), cached.clone()); } - Ok(access_token) + Ok(cached) } /// 获取指定账号的有效 access_token 与 id_token(必要时自动刷新) @@ -609,6 +674,89 @@ impl CodexOAuthManager { Ok((access_token, id_token)) } + /// 获取写入托管 Codex `auth.json` 所需的完整可刷新 token 束 + /// (access_token + id_token + refresh_token)。 + /// + /// 与仅返回 access_token 不同:写入 Codex CLI 的 auth.json 必须携带 + /// refresh_token,否则 CLI 在 access_token 过期后无法自刷新(详见托管直连 + /// 场景 “裸跑 codex”)。 + pub(crate) async fn get_valid_token_bundle_for_account( + &self, + account_id: &str, + ) -> Result { + // access_token 与其获取时间来自同一次解析(缓存命中即同一条目、刷新即新铸), + // 天然配套,杜绝「旧 token + 新时间戳」的错配。 + let cached = self.resolve_valid_cached_token(account_id).await?; + let last_refresh = + chrono::DateTime::::from_timestamp_millis(cached.obtained_at_ms) + .unwrap_or_else(chrono::Utc::now) + .to_rfc3339_opts(chrono::SecondsFormat::Nanos, true); + let (id_token, refresh_token) = { + let accounts = self.accounts.read().await; + let account = accounts + .get(account_id) + .ok_or_else(|| CodexOAuthError::AccountNotFound(account_id.to_string()))?; + (account.id_token.clone(), account.refresh_token.clone()) + }; + Ok(ManagedTokenBundle { + access_token: cached.token, + id_token, + refresh_token, + last_refresh, + }) + } + + /// 采纳(读回)Codex CLI 轮换后的 refresh_token / id_token。 + /// + /// 托管账号以「完整 bundle」写入 auth.json 后,Codex CLI 会自行刷新并把新的 + /// refresh_token 回写 auth.json。切换回该 provider 前调用本方法,把盘上的最新 + /// refresh_token 采纳进本地存储,避免用陈腐 token 覆盖 CLI 的有效登录。 + /// + /// 仅当账号确由本 manager 托管、且值确有变化时才更新并落盘;返回是否更新。 + pub async fn adopt_account_refresh_token( + &self, + account_id: &str, + refresh_token: String, + id_token: Option, + ) -> Result { + let refresh_token = refresh_token.trim().to_string(); + if refresh_token.is_empty() { + return Ok(false); + } + // 与该账号的刷新串行化:若一个 refresh 正持旧 refresh_token 在飞,避免它返回后 + // 覆盖我们刚采纳的 CLI 轮换值。 + let refresh_lock = self.get_refresh_lock(account_id).await; + let _guard = refresh_lock.lock().await; + let mut changed = false; + { + let mut accounts = self.accounts.write().await; + let Some(account) = accounts.get_mut(account_id) else { + // 不是本 manager 托管的账号:不接管、不落盘。 + return Ok(false); + }; + if account.refresh_token != refresh_token { + account.refresh_token = refresh_token; + changed = true; + } + if let Some(id_token) = id_token.filter(|token| !token.trim().is_empty()) { + if account.id_token.as_deref() != Some(id_token.as_str()) { + account.id_token = Some(id_token); + changed = true; + } + } + // 采纳了 CLI 轮换后的 refresh_token:与之配套的旧 access_token 可能已被 + // 服务端提前失效。在同一 accounts 写锁内(accounts -> access_tokens 顺序) + // 清缓存,避免释放锁后被快路径读到旧 token;下次按新 refresh_token 重取。 + if changed { + self.access_tokens.write().await.remove(account_id); + } + } + if changed { + self.save_to_disk().await?; + } + Ok(changed) + } + /// 获取默认账号的有效 token pub async fn get_valid_token(&self) -> Result { match self.resolve_default_account_id().await { @@ -636,15 +784,13 @@ impl CodexOAuthManager { log::info!("[CodexOAuth] 移除账号: {account_id}"); { + // 在 accounts 写锁内原子清除该账号的 token 缓存(accounts -> access_tokens + // 顺序),确保不存在「账号已删但缓存仍在」的窗口。 let mut accounts = self.accounts.write().await; if accounts.remove(account_id).is_none() { return Err(CodexOAuthError::AccountNotFound(account_id.to_string())); } - } - - { - let mut tokens = self.access_tokens.write().await; - tokens.remove(account_id); + self.access_tokens.write().await.remove(account_id); } { let mut locks = self.refresh_locks.write().await; @@ -683,18 +829,21 @@ impl CodexOAuthManager { pub async fn clear_auth(&self) -> Result<(), CodexOAuthError> { log::info!("[CodexOAuth] 清除所有认证"); + // 与 save_to_disk 共用持久化锁:确保「清内存 + 删文件」相对于并发保存原子, + // 不会被一个持有旧快照的 save 复活已清除的账号。 + let _persist = self.storage_lock.lock().await; + { + // 在 accounts 写锁内原子清除 accounts 与 token 缓存(accounts -> + // access_tokens 顺序),杜绝「账号已清但缓存仍在」及并发 refresh 回填。 let mut accounts = self.accounts.write().await; accounts.clear(); + self.access_tokens.write().await.clear(); } { let mut default = self.default_account_id.write().await; *default = None; } - { - let mut tokens = self.access_tokens.write().await; - tokens.clear(); - } { let mut locks = self.refresh_locks.write().await; locks.clear(); @@ -757,6 +906,7 @@ impl CodexOAuthManager { CachedAccessToken { token: access_token.to_string(), expires_at_ms: chrono::Utc::now().timestamp_millis() + 3_600_000, + obtained_at_ms: chrono::Utc::now().timestamp_millis(), }, ); @@ -937,6 +1087,9 @@ impl CodexOAuthManager { } async fn save_to_disk(&self) -> Result<(), CodexOAuthError> { + // 串行化「快照 + 写盘」:在持久化锁内取快照,确保并发保存/清除不会用 + // 陈旧快照覆盖,避免已删账号被复活。 + let _persist = self.storage_lock.lock().await; let accounts = self.accounts.read().await.clone(); let default = self.resolve_default_account_id().await; @@ -1093,6 +1246,7 @@ mod tests { let expiring = CachedAccessToken { token: "t".to_string(), expires_at_ms: now + 30_000, + obtained_at_ms: now, }; assert!(expiring.is_expiring_soon()); @@ -1100,6 +1254,7 @@ mod tests { let valid = CachedAccessToken { token: "t".to_string(), expires_at_ms: now + 3_600_000, + obtained_at_ms: now, }; assert!(!valid.is_expiring_soon()); } @@ -1203,4 +1358,46 @@ mod tests { assert_eq!(accounts.len(), 1); assert_eq!(accounts[0].id, "acc-456"); } + + #[tokio::test] + async fn adopt_account_refresh_token_syncs_rotated_value() { + let temp = tempfile::tempdir().unwrap(); + let manager = CodexOAuthManager::new(temp.path().to_path_buf()); + manager + .add_test_account_with_access_token("acc-1", "access-cached", Some("id-1")) + .await + .unwrap(); + + // 采纳 Codex CLI 轮换后的 refresh_token / id_token。 + let changed = manager + .adopt_account_refresh_token( + "acc-1", + "rotated-rt".to_string(), + Some("id-2".to_string()), + ) + .await + .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")); + + // 未知账号不接管。 + assert!(!manager + .adopt_account_refresh_token("acc-unknown", "x".to_string(), None) + .await + .unwrap()); + + // 相同值不算变化。 + assert!(!manager + .adopt_account_refresh_token("acc-1", "rotated-rt".to_string(), None) + .await + .unwrap()); + } } diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs index 44a3fe1d2..14746ccce 100644 --- a/src-tauri/src/services/provider/live.rs +++ b/src-tauri/src/services/provider/live.rs @@ -6,7 +6,6 @@ use std::collections::HashMap; use std::sync::Arc; use serde_json::{json, Value}; -use tokio::sync::RwLock; use toml_edit::{DocumentMut, Item, TableLike}; use crate::app_config::AppType; @@ -526,7 +525,7 @@ pub(crate) fn write_live_with_common_config_for_codex_oauth_manager( db: &Database, app_type: &AppType, provider: &Provider, - codex_oauth_manager: &Arc>, + codex_oauth_manager: &Arc, ) -> Result<(), AppError> { let effective_provider = build_effective_provider_for_live_with_codex_oauth_manager( db, @@ -552,7 +551,7 @@ pub(crate) fn build_effective_provider_for_live_with_codex_oauth_manager( db: &Database, app_type: &AppType, provider: &Provider, - codex_oauth_manager: &Arc>, + codex_oauth_manager: &Arc, ) -> Result { let mut effective_provider = provider.clone(); effective_provider.settings_config = @@ -564,7 +563,7 @@ pub(crate) fn build_effective_provider_for_live_with_codex_oauth_manager( fn apply_codex_managed_oauth_auth( app_type: &AppType, provider: &mut Provider, - codex_oauth_manager: Option<&Arc>>, + codex_oauth_manager: Option<&Arc>, ) -> Result<(), AppError> { if !matches!(app_type, AppType::Codex) || provider.category.as_deref() != Some("official") { return Ok(()); @@ -586,9 +585,7 @@ fn apply_codex_managed_oauth_auth( )); }; - let (access_token, id_token) = - get_codex_managed_oauth_token(manager.clone(), account_id.clone())?; - let auth = codex_managed_oauth_live_auth(&account_id, &access_token, id_token.as_deref()); + let auth = get_codex_managed_oauth_live_auth_value(manager.clone(), account_id.clone())?; let Some(settings_obj) = provider.settings_config.as_object_mut() else { return Err(AppError::Config( @@ -600,36 +597,68 @@ fn apply_codex_managed_oauth_auth( Ok(()) } -fn get_codex_managed_oauth_token( - manager: Arc>, +/// 构建写入托管 Codex `auth.json` 的完整可刷新 auth(含 refresh_token + last_refresh)。 +/// +/// 步骤: +/// 1. **读回**:若 Codex CLI 已自行刷新并轮换 refresh_token,先采纳盘上最新值,避免 +/// 用陈腐 refresh_token 覆盖 CLI 的有效登录(反复切换场景)。 +/// 2. 取有效 token 束(必要时刷新 access_token)。 +/// 3. 按原生浏览器登录形状生成完整 auth。 +/// +/// 不再持有外层锁:manager 内部按账号加锁刷新,网络阻塞不会波及其他账号操作或 +/// token 读取。 +fn get_codex_managed_oauth_live_auth_value( + manager: Arc, account_id: String, -) -> Result<(String, Option), AppError> { - let result = std::thread::spawn(move || { +) -> Result { + std::thread::spawn(move || { tauri::async_runtime::block_on(async move { - let error_account_id = account_id.clone(); - let manager = manager.read().await; - manager - .get_valid_token_and_id_token_for_account(&account_id) + if let Some((refresh_token, id_token)) = + crate::codex_config::read_codex_live_auth_refresh_for_account(&account_id) + { + if let Err(err) = manager + .adopt_account_refresh_token(&account_id, refresh_token, id_token) + .await + { + log::warn!( + "读回 Codex CLI 轮换后的 refresh_token 失败(account={account_id}): {err}" + ); + } + } + + let bundle = manager + .get_valid_token_bundle_for_account(&account_id) .await .map_err(|err| { format!( - "Codex OAuth 账号 {error_account_id} 认证失败,请重新登录 ChatGPT 账号: {err}" - ) - }) + "Codex OAuth 账号 {account_id} 认证失败,请重新登录 ChatGPT 账号: {err}" + ) + })?; + + Ok::(codex_managed_oauth_live_auth( + &account_id, + &bundle.access_token, + bundle.id_token.as_deref(), + &bundle.refresh_token, + &bundle.last_refresh, + )) }) }) .join() - .map_err(|_| AppError::Message("Codex OAuth token 获取线程异常退出".to_string()))?; - - result.map_err(AppError::Message) + .map_err(|_| AppError::Message("Codex OAuth token 获取线程异常退出".to_string()))? + .map_err(AppError::Message) } pub(crate) fn codex_managed_oauth_live_auth( account_id: &str, access_token: &str, id_token: Option<&str>, + refresh_token: &str, + last_refresh: &str, ) -> Value { - // 与原生 Codex 浏览器登录的 tokens 字段顺序对齐:id_token 在前。 + // 与原生 Codex 浏览器登录的形状对齐:tokens 字段顺序 id_token、access_token、 + // refresh_token、account_id,并带顶层 last_refresh。**必须**包含 refresh_token, + // 否则 Codex CLI 在 access_token 过期后无法自刷新(“裸跑 codex” 会静默失效)。 let mut tokens = serde_json::Map::new(); if let Some(id_token) = id_token { tokens.insert("id_token".to_string(), Value::String(id_token.to_string())); @@ -638,6 +667,10 @@ pub(crate) fn codex_managed_oauth_live_auth( "access_token".to_string(), Value::String(access_token.to_string()), ); + tokens.insert( + "refresh_token".to_string(), + Value::String(refresh_token.to_string()), + ); tokens.insert( "account_id".to_string(), Value::String(account_id.to_string()), @@ -646,6 +679,7 @@ pub(crate) fn codex_managed_oauth_live_auth( "auth_mode": "chatgpt", "OPENAI_API_KEY": null, "tokens": Value::Object(tokens), + "last_refresh": last_refresh, }) } @@ -748,38 +782,37 @@ fn restore_live_settings_for_provider_backfill( settings } +/// 回填(backfill)托管 Codex 官方 provider 时,剥离 live 的 `auth`。 +/// +/// 托管 provider 的存储配置**永远不应**持久化真实 OAuth token:token 由 +/// `CodexOAuthManager` 按账号集中保管,provider 配置只保留绑定与占位 auth。 +/// +/// 因此无论 live 里当前是什么(我们写入的托管 auth、被 CLI 轮换过的 token、 +/// 还是用户自己浏览器登录的原生 auth,后者含真实 access/refresh_token), +/// 都统一替换为 provider 存储的占位 auth,避免把真实凭据回填进 DB 配置。 fn strip_codex_managed_oauth_auth_for_backfill(provider: &Provider, settings: &mut Value) { - let Some(account_id) = provider + let is_managed = provider .meta .as_ref() .and_then(|meta| meta.managed_account_id_for("codex_oauth")) - else { + .is_some(); + if !is_managed { return; - }; + } - let Some(auth) = settings.get("auth") else { + // live 里没有 auth 就无需处理。 + if settings.get("auth").is_none() { return; - }; + } - match crate::codex_config::codex_auth_matches_recorded_managed_oauth(auth, &account_id) { - Ok(true) => { - let stored_auth = provider - .settings_config - .get("auth") - .filter(|auth| auth.is_object()) - .cloned() - .unwrap_or_else(|| json!({})); - if let Some(obj) = settings.as_object_mut() { - obj.insert("auth".to_string(), stored_auth); - } - } - Ok(false) => {} - Err(err) => { - log::warn!( - "Failed to check Codex managed OAuth marker while backfilling '{}': {err}", - provider.id - ); - } + let stored_auth = provider + .settings_config + .get("auth") + .filter(|auth| auth.is_object()) + .cloned() + .unwrap_or_else(|| json!({})); + if let Some(obj) = settings.as_object_mut() { + obj.insert("auth".to_string(), stored_auth); } } @@ -932,14 +965,6 @@ 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 @@ -1822,31 +1847,47 @@ mod tests { #[test] fn codex_managed_oauth_live_auth_matches_codex_cli_shape() { assert_eq!( - codex_managed_oauth_live_auth("acct-managed", "access-token", Some("id-token")), + codex_managed_oauth_live_auth( + "acct-managed", + "access-token", + Some("id-token"), + "refresh-token", + "2026-01-02T03:04:05.000000000Z", + ), json!({ "auth_mode": "chatgpt", "OPENAI_API_KEY": null, "tokens": { "id_token": "id-token", "access_token": "access-token", + "refresh_token": "refresh-token", "account_id": "acct-managed" - } + }, + "last_refresh": "2026-01-02T03:04:05.000000000Z" }), - "managed live auth should include id_token to match native browser login" + "managed live auth must carry refresh_token + last_refresh so the Codex CLI can self-refresh" ); } #[test] fn codex_managed_oauth_live_auth_without_id_token_omits_it() { assert_eq!( - codex_managed_oauth_live_auth("acct-managed", "access-token", None), + codex_managed_oauth_live_auth( + "acct-managed", + "access-token", + None, + "refresh-token", + "2026-01-02T03:04:05.000000000Z", + ), json!({ "auth_mode": "chatgpt", "OPENAI_API_KEY": null, "tokens": { "access_token": "access-token", + "refresh_token": "refresh-token", "account_id": "acct-managed" - } + }, + "last_refresh": "2026-01-02T03:04:05.000000000Z" }), "without a stored id_token the field is omitted rather than written as null" ); @@ -1855,13 +1896,9 @@ mod tests { #[test] fn codex_official_managed_oauth_binding_replaces_auth_with_selected_account_token() { let temp = tempfile::tempdir().expect("tempdir"); - let manager = Arc::new(RwLock::new(CodexOAuthManager::new( - temp.path().to_path_buf(), - ))); + let manager = Arc::new(CodexOAuthManager::new(temp.path().to_path_buf())); tauri::async_runtime::block_on(async { manager - .read() - .await .add_test_account_with_access_token( "acct-managed", "managed-token", @@ -1895,27 +1932,48 @@ mod tests { apply_codex_managed_oauth_auth(&AppType::Codex, &mut provider, Some(&manager)) .expect("apply managed OAuth auth"); + // last_refresh 是写入时刻的时间戳(非确定),因此逐字段断言而非整体等值。 + let auth = provider.settings_config.get("auth").expect("auth written"); assert_eq!( - provider.settings_config.get("auth"), - Some(&codex_managed_oauth_live_auth( - "acct-managed", - "managed-token", - Some("managed-id-token") - )), - "explicit account binding should write the managed ChatGPT token to Codex live auth" + auth.get("auth_mode").and_then(|v| v.as_str()), + Some("chatgpt") + ); + assert!(auth.get("OPENAI_API_KEY").is_some_and(|v| v.is_null())); + let tokens = auth + .get("tokens") + .and_then(|v| v.as_object()) + .expect("tokens object"); + assert_eq!( + tokens.get("account_id").and_then(|v| v.as_str()), + Some("acct-managed") + ); + assert_eq!( + tokens.get("access_token").and_then(|v| v.as_str()), + Some("managed-token") + ); + assert_eq!( + tokens.get("id_token").and_then(|v| v.as_str()), + Some("managed-id-token") + ); + assert_eq!( + tokens.get("refresh_token").and_then(|v| v.as_str()), + Some("test-refresh-token"), + "managed live auth must carry the account's refresh_token so the Codex CLI can self-refresh" + ); + assert!( + auth.get("last_refresh") + .and_then(|v| v.as_str()) + .is_some_and(|s| s.ends_with('Z')), + "managed live auth must include an RFC3339 last_refresh timestamp" ); } #[test] fn codex_official_without_binding_does_not_fall_back_to_managed_default_account() { let temp = tempfile::tempdir().expect("tempdir"); - let manager = Arc::new(RwLock::new(CodexOAuthManager::new( - temp.path().to_path_buf(), - ))); + let manager = Arc::new(CodexOAuthManager::new(temp.path().to_path_buf())); tauri::async_runtime::block_on(async { manager - .read() - .await .add_test_account_with_access_token("acct-managed", "managed-token", None) .await .expect("seed managed account"); @@ -1950,6 +2008,85 @@ mod tests { ); } + #[test] + fn backfill_never_persists_native_tokens_into_managed_provider_config() { + // 托管 provider 的存储配置以占位 auth 表示;但 live 里此刻是用户自己 + // 浏览器登录的原生 auth(含真实 refresh_token)。backfill 必须把 live + // auth 换回存储占位,绝不把原生 access/refresh_token 回填进 DB 配置。 + let mut provider = Provider::with_id( + "openai-official".to_string(), + "OpenAI Official".to_string(), + json!({ + "auth": {}, + "config": "" + }), + None, + ); + provider.category = Some("official".to_string()); + provider.meta = Some(ProviderMeta { + auth_binding: Some(AuthBinding { + source: AuthBindingSource::ManagedAccount, + auth_provider: Some("codex_oauth".to_string()), + account_id: Some("acct-managed".to_string()), + }), + ..Default::default() + }); + + let mut live_settings = json!({ + "auth": { + "OPENAI_API_KEY": null, + "tokens": { + "id_token": "native-id", + "access_token": "native-access-secret", + "refresh_token": "native-refresh-secret", + "account_id": "acct-native" + }, + "last_refresh": "2026-01-01T00:00:00Z" + }, + "config": "" + }); + + strip_codex_managed_oauth_auth_for_backfill(&provider, &mut live_settings); + + assert_eq!( + live_settings.get("auth"), + Some(&json!({})), + "managed provider backfill must reset live auth to the stored placeholder" + ); + let serialized = live_settings.to_string(); + assert!( + !serialized.contains("native-refresh-secret"), + "native refresh_token must not leak into a managed provider's backfilled config" + ); + assert!( + !serialized.contains("native-access-secret"), + "native access_token must not leak into a managed provider's backfilled config" + ); + } + + #[test] + fn backfill_leaves_non_managed_provider_auth_untouched() { + // 非托管 provider 不受此剥离影响:其 auth 就该原样保留。 + let mut provider = Provider::with_id( + "custom".to_string(), + "Custom".to_string(), + json!({ "auth": { "OPENAI_API_KEY": "user-key" }, "config": "" }), + None, + ); + provider.category = Some("custom".to_string()); + + let mut live_settings = json!({ + "auth": { "OPENAI_API_KEY": "user-key" }, + "config": "" + }); + let before = live_settings.clone(); + strip_codex_managed_oauth_auth_for_backfill(&provider, &mut live_settings); + assert_eq!( + live_settings, before, + "non-managed provider auth must be left untouched by managed-oauth backfill stripping" + ); + } + #[test] fn explicit_common_config_flag_overrides_legacy_subset_detection() { let mut provider = Provider::with_id( diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index 3bab6557b..a15512698 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -1272,8 +1272,6 @@ base_url = "http://localhost:8080" tauri::async_runtime::block_on(async { state .codex_oauth_manager - .read() - .await .add_test_account_with_access_token( "acct-managed", "managed-token", @@ -1355,6 +1353,70 @@ base_url = "http://localhost:8080" }); } + #[test] + #[serial] + fn switch_to_managed_codex_official_with_unresolvable_account_keeps_current_unchanged() { + with_test_home(|state, _| { + crate::settings::reload_settings().expect("reload settings"); + + // 基线:一个普通第三方 provider,可正常切换,作为初始 current。 + let mut baseline = Provider::with_id( + "baseline".to_string(), + "Baseline".to_string(), + json!({ "auth": { "OPENAI_API_KEY": "sk-baseline" }, "config": "" }), + None, + ); + baseline.category = Some("custom".to_string()); + + // 托管 official provider,绑定一个 manager 中不存在的账号:切换预检 + // 取 token 必然失败。 + let mut managed = Provider::with_id( + "managed-official".to_string(), + "Managed Official".to_string(), + json!({ "auth": {}, "config": "" }), + None, + ); + managed.category = Some("official".to_string()); + managed.meta = Some(ProviderMeta { + auth_binding: Some(AuthBinding { + source: AuthBindingSource::ManagedAccount, + auth_provider: Some("codex_oauth".to_string()), + account_id: Some("acct-missing".to_string()), + }), + ..Default::default() + }); + + state + .db + .save_provider(AppType::Codex.as_str(), &baseline) + .expect("save baseline"); + state + .db + .save_provider(AppType::Codex.as_str(), &managed) + .expect("save managed"); + + ProviderService::switch(state, AppType::Codex, "baseline").expect("switch to baseline"); + + // 切到绑定了不存在账号的托管 provider:预检失败 → 返回 Err。 + let result = ProviderService::switch(state, AppType::Codex, "managed-official"); + assert!( + result.is_err(), + "switch must fail when the managed OAuth token cannot be resolved" + ); + + // current 必须仍是 baseline:预检在提交 current 之前失败,不留下 + // 「DB/UI 指向新 provider、但 live 仍是旧 provider」的不一致状态。 + let current = + crate::settings::get_effective_current_provider(&state.db, &AppType::Codex) + .expect("read current"); + assert_eq!( + current.as_deref(), + Some("baseline"), + "a failed managed switch must not move current off the previous provider" + ); + }); + } + #[test] #[serial] fn import_opencode_providers_from_live_marks_provider_as_live_managed() { @@ -1649,6 +1711,49 @@ 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」的不一致状态;后续真正 + /// 写 live 会复用缓存的 token。非托管路径为空操作。 + fn preflight_managed_codex_live( + state: &AppState, + app_type: &AppType, + provider: &Provider, + ) -> Result<(), AppError> { + if matches!(app_type, AppType::Codex) + && Self::managed_codex_oauth_account_id(provider).is_some() + { + build_effective_provider_for_live_with_codex_oauth_manager( + state.db.as_ref(), + app_type, + provider, + &state.codex_oauth_manager, + )?; + } + Ok(()) + } + fn unbound_managed_codex_oauth_account_id( app_type: &AppType, existing_provider: Option<&Provider>, @@ -1836,6 +1941,8 @@ impl ProviderService { // For other apps: Check if sync is needed (if this is current provider, or no current provider) let current = state.db.get_current_provider(app_type.as_str())?; if current.is_none() { + // 预检托管 Codex token:失败则不将其设为 current(见 switch)。 + Self::preflight_managed_codex_live(state, &app_type, &provider)?; // No current provider, set as current and sync state .db @@ -2001,14 +2108,20 @@ impl ProviderService { &provider, ); - // Save to database - state.db.save_provider(app_type.as_str(), &provider)?; - // For other apps: Check if this is current provider (use effective current, not just DB) let effective_current = crate::settings::get_effective_current_provider(&state.db, &app_type)?; let is_current = effective_current.as_deref() == Some(provider.id.as_str()); + // 编辑当前托管 Codex provider:写库前先预检 token(见 preflight_managed_codex_live), + // 失败则不落库、不改动 live,避免 DB 与 live 不一致。 + if is_current { + Self::preflight_managed_codex_live(state, &app_type, &provider)?; + } + + // Save to database + state.db.save_provider(app_type.as_str(), &provider)?; + if is_current { // 如果 Claude 代理接管处于激活状态,并且代理服务正在运行: // - 不直接走普通 Live 写入逻辑 @@ -2052,7 +2165,7 @@ impl ProviderService { .map_err(|e| AppError::Message(format!("更新 Live 备份失败: {e}")))?; if let Some(account_id) = unbound_codex_managed_account_id.as_deref() { - crate::codex_config::clear_codex_live_auth_for_managed_account(account_id)?; + Self::clear_stale_managed_codex_live_auth(&provider, account_id)?; } } @@ -2069,7 +2182,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() { - crate::codex_config::clear_codex_live_auth_for_managed_account(account_id)?; + Self::clear_stale_managed_codex_live_auth(&provider, account_id)?; } // Sync MCP McpService::sync_all_enabled(state)?; @@ -2384,6 +2497,9 @@ impl ProviderService { } } + // 提交 current 前预检托管 Codex token(见 preflight_managed_codex_live)。 + Self::preflight_managed_codex_live(state, &app_type, provider)?; + // Additive mode apps skip setting is_current (no such concept) if !app_type.is_additive_mode() { // Update local settings (device-level, takes priority) @@ -2399,7 +2515,7 @@ impl ProviderService { && Self::managed_codex_oauth_account_id(provider).is_none() { if let Some(account_id) = current_managed_codex_account_id.as_deref() { - crate::codex_config::clear_codex_live_auth_for_managed_account(account_id)?; + Self::clear_stale_managed_codex_live_auth(provider, account_id)?; } } diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index 4fe404713..5302f0c08 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -57,7 +57,7 @@ enum ClaudeTakeoverAuthPolicy { #[derive(Clone)] pub struct ProxyService { db: Arc, - codex_oauth_manager: Arc>, + codex_oauth_manager: Arc, server: Arc>>, /// AppHandle,用于传递给 ProxyServer 以支持故障转移时的 UI 更新 app_handle: Arc>>, @@ -71,16 +71,15 @@ pub struct HotSwitchOutcome { impl ProxyService { pub fn new(db: Arc) -> Self { - let codex_oauth_manager = Arc::new(RwLock::new(CodexOAuthManager::new( - crate::config::get_app_config_dir(), - ))); + let codex_oauth_manager = + Arc::new(CodexOAuthManager::new(crate::config::get_app_config_dir())); Self::new_with_codex_oauth_manager(db, codex_oauth_manager) } pub fn new_with_codex_oauth_manager( db: Arc, - codex_oauth_manager: Arc>, + codex_oauth_manager: Arc, ) -> Self { Self { db, @@ -1171,6 +1170,48 @@ 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() { @@ -1191,10 +1232,11 @@ impl ProxyService { } // Codex - if let Ok(config) = self.read_codex_live() { + if let Ok(mut 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 @@ -1224,7 +1266,7 @@ impl ProxyService { /// 备份指定应用的 Live 配置(严格模式:目标配置不存在则返回错误) async fn backup_live_config_strict(&self, app_type: &AppType) -> Result<(), String> { - let (app_type_str, config) = match app_type { + let (app_type_str, mut 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()?), @@ -1240,6 +1282,11 @@ 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 @@ -2096,6 +2143,22 @@ 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 { @@ -2121,19 +2184,6 @@ 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(()) } @@ -2316,8 +2366,10 @@ impl ProxyService { return Err("Codex 备份必须是 JSON 对象".to_string()); }; - if crate::codex_config::codex_auth_matches_recorded_managed_oauth(existing_auth, account_id) - .map_err(|e| format!("检查 Codex 托管认证标记失败: {e}"))? + // 托管账号写入的是完整可刷新 bundle(含 refresh_token),必须避免它被 + // 持久化进 Live 备份。按 account_id 内容判定:命中托管账号则清空备份 auth, + // 否则原样保留用户自己的登录以便接管结束后还原。 + if crate::codex_config::codex_live_auth_is_managed_chatgpt_login(existing_auth, account_id) { target_obj.insert("auth".to_string(), json!({})); return Ok(()); @@ -5096,8 +5148,6 @@ base_url = "https://codex.example/v1" let service = ProxyService::new(db.clone()); service .codex_oauth_manager - .read() - .await .add_test_account_with_access_token( "acct-managed", "managed-token", diff --git a/src-tauri/src/store.rs b/src-tauri/src/store.rs index f7b413f2b..75edfb9f2 100644 --- a/src-tauri/src/store.rs +++ b/src-tauri/src/store.rs @@ -2,22 +2,22 @@ use crate::database::Database; use crate::proxy::providers::codex_oauth_auth::CodexOAuthManager; use crate::services::{ProxyService, UsageCache}; use std::sync::Arc; -use tokio::sync::RwLock; /// 全局应用状态 pub struct AppState { pub db: Arc, pub proxy_service: ProxyService, pub usage_cache: Arc, - pub codex_oauth_manager: Arc>, + // 内部已使用细粒度锁(accounts/access_tokens/refresh_locks),所有方法均为 + // `&self`,无需外层 RwLock;避免持有粗粒度锁跨网络刷新导致的连锁阻塞。 + pub codex_oauth_manager: Arc, } impl AppState { /// 创建新的应用状态 pub fn new(db: Arc) -> Self { - let codex_oauth_manager = Arc::new(RwLock::new(CodexOAuthManager::new( - crate::config::get_app_config_dir(), - ))); + let codex_oauth_manager = + Arc::new(CodexOAuthManager::new(crate::config::get_app_config_dir())); let proxy_service = ProxyService::new_with_codex_oauth_manager(db.clone(), codex_oauth_manager.clone());