diff --git a/src-tauri/src/codex_config.rs b/src-tauri/src/codex_config.rs index ac9b43bf1..f622723e7 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; @@ -14,6 +16,14 @@ use toml_edit::DocumentMut; pub const CC_SWITCH_CODEX_MODEL_PROVIDER_ID: &str = "custom"; pub const CC_SWITCH_CODEX_MODEL_CATALOG_FILENAME: &str = "cc-switch-model-catalog.json"; 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, +} /// Reserved built-in provider IDs from OpenAI Codex's config/model-provider /// catalog. Keep in sync with Codex `RESERVED_MODEL_PROVIDER_IDS` and legacy @@ -41,6 +51,121 @@ 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())?; + + if tokens + .keys() + .any(|key| !matches!(key.as_str(), "access_token" | "account_id")) + { + 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)) +} + +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)? { + return Ok(()); + } + delete_file(&auth_path)?; + let _ = delete_file(&get_codex_managed_oauth_live_auth_marker_path()); + } + Ok(()) +} + /// 获取 Codex config.toml 路径 pub fn get_codex_config_path() -> PathBuf { get_codex_config_dir().join("config.toml") @@ -1253,6 +1378,36 @@ 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 prepare_provider_live_config_rejects_key_without_config() { @@ -1265,6 +1420,47 @@ mod tests { ); } + #[test] + #[serial] + fn recorded_managed_oauth_marker_matches_only_same_account_and_token() { + let _home = TempHome::new(); + let managed_auth = 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] fn prepare_provider_live_config_uses_top_level_token_for_reserved_provider() { let input = r#"model_provider = "openai" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c96b752e5..2f1259f27 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -921,13 +921,11 @@ pub fn run() { // 初始化 CodexOAuthManager (ChatGPT Plus/Pro 反代) { - use crate::proxy::providers::codex_oauth_auth::CodexOAuthManager; use commands::CodexOAuthState; - use tokio::sync::RwLock; - let app_config_dir = crate::config::get_app_config_dir(); - let codex_oauth_manager = CodexOAuthManager::new(app_config_dir); - app.manage(CodexOAuthState(Arc::new(RwLock::new(codex_oauth_manager)))); + let codex_oauth_manager = + app.state::().codex_oauth_manager.clone(); + app.manage(CodexOAuthState(codex_oauth_manager)); log::info!("✓ CodexOAuthManager initialized"); } diff --git a/src-tauri/src/proxy/providers/codex_oauth_auth.rs b/src-tauri/src/proxy/providers/codex_oauth_auth.rs index 945473f01..2d2d81ba0 100644 --- a/src-tauri/src/proxy/providers/codex_oauth_auth.rs +++ b/src-tauri/src/proxy/providers/codex_oauth_auth.rs @@ -695,6 +695,31 @@ impl CodexOAuthManager { } } + #[cfg(test)] + pub(crate) async fn add_test_account_with_access_token( + &self, + account_id: &str, + access_token: &str, + ) -> Result<(), CodexOAuthError> { + self.add_account_internal( + account_id.to_string(), + "test-refresh-token".to_string(), + Some(format!("{account_id}@example.test")), + ) + .await?; + + let mut tokens = self.access_tokens.write().await; + tokens.insert( + account_id.to_string(), + CachedAccessToken { + token: access_token.to_string(), + expires_at_ms: chrono::Utc::now().timestamp_millis() + 3_600_000, + }, + ); + + Ok(()) + } + // ==================== 内部方法 ==================== async fn add_account_internal( diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs index 8a6b53807..3dc454455 100644 --- a/src-tauri/src/services/provider/live.rs +++ b/src-tauri/src/services/provider/live.rs @@ -3,8 +3,10 @@ //! Handles reading and writing live configuration files for Claude, Codex, and Gemini. 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; @@ -13,6 +15,7 @@ use crate::config::{delete_file, get_claude_settings_path, read_json_file, write use crate::database::Database; use crate::error::AppError; use crate::provider::Provider; +use crate::proxy::providers::codex_oauth_auth::CodexOAuthManager; use crate::services::mcp::McpService; use crate::store::AppState; @@ -506,14 +509,31 @@ pub(crate) fn build_effective_settings_with_common_config( Ok(effective_settings) } -pub(crate) fn write_live_with_common_config( - db: &Database, +pub(crate) fn write_live_with_common_config_for_state( + state: &AppState, app_type: &AppType, provider: &Provider, ) -> Result<(), AppError> { - let mut effective_provider = provider.clone(); - effective_provider.settings_config = - build_effective_settings_with_common_config(db, app_type, provider)?; + write_live_with_common_config_for_codex_oauth_manager( + state.db.as_ref(), + app_type, + provider, + &state.codex_oauth_manager, + ) +} + +pub(crate) fn write_live_with_common_config_for_codex_oauth_manager( + db: &Database, + app_type: &AppType, + provider: &Provider, + codex_oauth_manager: &Arc>, +) -> Result<(), AppError> { + let effective_provider = build_effective_provider_for_live_with_codex_oauth_manager( + db, + app_type, + provider, + codex_oauth_manager, + )?; if matches!(app_type, AppType::ClaudeDesktop) { crate::claude_desktop_config::apply_provider(db, &effective_provider)?; @@ -528,6 +548,92 @@ pub(crate) fn write_live_with_common_config( write_live_snapshot(app_type, &effective_provider) } +pub(crate) fn build_effective_provider_for_live_with_codex_oauth_manager( + db: &Database, + app_type: &AppType, + provider: &Provider, + codex_oauth_manager: &Arc>, +) -> Result { + let mut effective_provider = provider.clone(); + effective_provider.settings_config = + build_effective_settings_with_common_config(db, app_type, provider)?; + apply_codex_managed_oauth_auth(app_type, &mut effective_provider, Some(codex_oauth_manager))?; + Ok(effective_provider) +} + +fn apply_codex_managed_oauth_auth( + app_type: &AppType, + provider: &mut Provider, + codex_oauth_manager: Option<&Arc>>, +) -> Result<(), AppError> { + if !matches!(app_type, AppType::Codex) || provider.category.as_deref() != Some("official") { + return Ok(()); + } + + 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 Ok(()); + }; + + let Some(manager) = codex_oauth_manager else { + return Err(AppError::Message( + "Codex OAuth 托管账号不可用,请重启应用后重试".to_string(), + )); + }; + + let access_token = get_codex_managed_oauth_token(manager.clone(), account_id.clone())?; + let auth = codex_managed_oauth_live_auth(&account_id, &access_token); + + let Some(settings_obj) = provider.settings_config.as_object_mut() else { + return Err(AppError::Config( + "Codex 供应商配置必须是 JSON 对象".to_string(), + )); + }; + + settings_obj.insert("auth".to_string(), auth); + Ok(()) +} + +fn get_codex_managed_oauth_token( + manager: Arc>, + account_id: String, +) -> Result { + let 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_for_account(&account_id) + .await + .map_err(|err| { + format!( + "Codex OAuth 账号 {error_account_id} 认证失败,请重新登录 ChatGPT 账号: {err}" + ) + }) + }) + }) + .join() + .map_err(|_| AppError::Message("Codex OAuth token 获取线程异常退出".to_string()))?; + + result.map_err(AppError::Message) +} + +pub(crate) fn codex_managed_oauth_live_auth(account_id: &str, access_token: &str) -> Value { + json!({ + "auth_mode": "chatgpt", + "OPENAI_API_KEY": null, + "tokens": { + "access_token": access_token, + "account_id": account_id, + }, + }) +} + pub(crate) fn strip_common_config_from_live_settings( db: &Database, app_type: &AppType, @@ -596,6 +702,8 @@ fn restore_live_settings_for_provider_backfill( ); } + strip_codex_managed_oauth_auth_for_backfill(provider, &mut settings); + // `modelCatalog` is a cc-switch–private field whose SSOT is the DB. Live's // `config.toml` only carries a lossy projection (`model_catalog_json` → // generated catalog file) that proxy takeover/restore cycles and Codex.app @@ -612,6 +720,41 @@ fn restore_live_settings_for_provider_backfill( settings } +fn strip_codex_managed_oauth_auth_for_backfill(provider: &Provider, settings: &mut Value) { + let Some(account_id) = provider + .meta + .as_ref() + .and_then(|meta| meta.managed_account_id_for("codex_oauth")) + else { + return; + }; + + let Some(auth) = settings.get("auth") else { + 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 + ); + } + } +} + pub(crate) fn normalize_provider_common_config_for_storage( db: &Database, app_type: &AppType, @@ -753,6 +896,14 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re auth, config_str, )?; + 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 @@ -884,7 +1035,7 @@ fn sync_all_providers_to_live(state: &AppState, app_type: &AppType) -> Result<() continue; } - if let Err(e) = write_live_with_common_config(state.db.as_ref(), app_type, provider) { + if let Err(e) = write_live_with_common_config_for_state(state, app_type, provider) { log::warn!( "Failed to sync {:?} provider '{}' to live: {e}", app_type, @@ -914,7 +1065,7 @@ pub(crate) fn sync_current_provider_for_app_to_live( 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)?; + write_live_with_common_config_for_state(state, app_type, provider)?; } } @@ -950,7 +1101,7 @@ fn sync_current_provider_for_app_respecting_takeover( // 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)?; + write_live_with_common_config_for_state(state, app_type, provider)?; } else { futures::executor::block_on( state @@ -962,7 +1113,7 @@ fn sync_current_provider_for_app_respecting_takeover( return Ok(()); } - write_live_with_common_config(state.db.as_ref(), app_type, provider) + write_live_with_common_config_for_state(state, app_type, provider) } /// Sync current provider to live configuration @@ -1569,6 +1720,7 @@ pub fn remove_openclaw_provider_from_live(provider_id: &str) -> Result<(), AppEr #[cfg(test)] mod tests { use super::*; + use crate::provider::{AuthBinding, AuthBindingSource, ProviderMeta}; use serde_json::json; #[test] @@ -1615,6 +1767,114 @@ mod tests { assert_eq!(stripped, settings); } + #[test] + fn codex_managed_oauth_live_auth_matches_codex_cli_shape() { + assert_eq!( + codex_managed_oauth_live_auth("acct-managed", "access-token"), + json!({ + "auth_mode": "chatgpt", + "OPENAI_API_KEY": null, + "tokens": { + "access_token": "access-token", + "account_id": "acct-managed" + } + }) + ); + } + + #[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(), + ))); + tauri::async_runtime::block_on(async { + manager + .read() + .await + .add_test_account_with_access_token("acct-managed", "managed-token") + .await + .expect("seed managed account"); + }); + + let mut provider = Provider::with_id( + "openai-official".to_string(), + "OpenAI Official".to_string(), + json!({ + "auth": { + "OPENAI_API_KEY": "stale-key" + }, + "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() + }); + + apply_codex_managed_oauth_auth(&AppType::Codex, &mut provider, Some(&manager)) + .expect("apply managed OAuth auth"); + + assert_eq!( + provider.settings_config.get("auth"), + Some(&codex_managed_oauth_live_auth( + "acct-managed", + "managed-token" + )), + "explicit account binding should write the managed ChatGPT token to Codex live auth" + ); + } + + #[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(), + ))); + tauri::async_runtime::block_on(async { + manager + .read() + .await + .add_test_account_with_access_token("acct-managed", "managed-token") + .await + .expect("seed managed account"); + }); + + let original_auth = json!({ + "auth_mode": "chatgpt", + "OPENAI_API_KEY": null, + "tokens": { + "access_token": "native-codex-token", + "account_id": "acct-native" + } + }); + let mut provider = Provider::with_id( + "openai-official".to_string(), + "OpenAI Official".to_string(), + json!({ + "auth": original_auth.clone(), + "config": "" + }), + None, + ); + provider.category = Some("official".to_string()); + + apply_codex_managed_oauth_auth(&AppType::Codex, &mut provider, Some(&manager)) + .expect("no binding should be a no-op"); + + assert_eq!( + provider.settings_config.get("auth"), + Some(&original_auth), + "unbound official providers must keep native Codex auth instead of using the managed default account" + ); + } + #[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 f41fb7827..611df8fc0 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -30,9 +30,11 @@ pub use live::{ // Internal re-exports (pub(crate)) pub(crate) use live::sanitize_claude_settings_for_live; pub(crate) use live::{ + build_effective_provider_for_live_with_codex_oauth_manager, build_effective_settings_with_common_config, normalize_provider_common_config_for_storage, provider_exists_in_live_config, strip_common_config_from_live_settings, - sync_current_provider_for_app_to_live, write_live_with_common_config, + sync_current_provider_for_app_to_live, write_live_with_common_config_for_codex_oauth_manager, + write_live_with_common_config_for_state, }; // Internal re-exports @@ -59,7 +61,7 @@ mod tests { use crate::claude_desktop_config::PROFILE_ID; use crate::config::{get_claude_settings_path, read_json_file, write_json_file}; use crate::database::Database; - use crate::provider::ProviderMeta; + use crate::provider::{AuthBinding, AuthBindingSource, ProviderMeta}; #[cfg(any(target_os = "macos", windows))] use crate::provider::{ClaudeDesktopMode, ClaudeDesktopModelRoute}; use crate::proxy::types::ProxyConfig; @@ -831,6 +833,93 @@ base_url = "http://localhost:8080" }); } + #[test] + #[serial] + fn switch_from_managed_codex_official_to_unbound_clears_live_without_backfilling_token() { + with_test_home(|state, _| { + crate::settings::reload_settings().expect("reload settings"); + tauri::async_runtime::block_on(async { + state + .codex_oauth_manager + .read() + .await + .add_test_account_with_access_token("acct-managed", "managed-token") + .await + .expect("seed managed Codex OAuth account"); + }); + + 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-managed".to_string()), + }), + ..Default::default() + }); + + let mut unbound = Provider::with_id( + "unbound-official".to_string(), + "Unbound Official".to_string(), + json!({ + "auth": {}, + "config": "" + }), + None, + ); + unbound.category = Some("official".to_string()); + + state + .db + .save_provider(AppType::Codex.as_str(), &managed) + .expect("save managed provider"); + state + .db + .save_provider(AppType::Codex.as_str(), &unbound) + .expect("save unbound provider"); + + ProviderService::switch(state, AppType::Codex, "managed-official") + .expect("switch to managed official"); + let live_auth: Value = read_json_file(&crate::codex_config::get_codex_auth_path()) + .expect("read managed live auth"); + assert_eq!( + live_auth + .pointer("/tokens/access_token") + .and_then(Value::as_str), + Some("managed-token"), + "managed switch should write the selected ChatGPT token to live auth" + ); + + ProviderService::switch(state, AppType::Codex, "unbound-official") + .expect("switch to unbound official"); + + assert!( + !crate::codex_config::get_codex_auth_path().exists(), + "switching to an unbound official provider should clear the recorded managed live auth" + ); + + let saved_managed = state + .db + .get_provider_by_id("managed-official", AppType::Codex.as_str()) + .expect("query managed provider") + .expect("managed provider should exist"); + assert_eq!( + saved_managed.settings_config.get("auth"), + Some(&json!({})), + "switch-away backfill must not persist the managed access token into provider storage" + ); + }); + } + #[test] #[serial] fn import_opencode_providers_from_live_marks_provider_as_live_managed() { @@ -1116,6 +1205,35 @@ base_url = "http://localhost:8080" } impl ProviderService { + fn managed_codex_oauth_account_id(provider: &Provider) -> Option { + 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()) + } + + fn unbound_managed_codex_oauth_account_id( + app_type: &AppType, + existing_provider: Option<&Provider>, + provider: &Provider, + ) -> Option { + if !matches!(app_type, AppType::Codex) || provider.category.as_deref() != Some("official") { + return None; + } + + let existing_provider = existing_provider + .filter(|existing| existing.category.as_deref() == Some("official"))?; + let old_account_id = Self::managed_codex_oauth_account_id(existing_provider)?; + + if Self::managed_codex_oauth_account_id(provider).is_some() { + return None; + } + + Some(old_account_id) + } + fn normalize_provider_if_claude(app_type: &AppType, provider: &mut Provider) { if matches!(app_type, AppType::Claude) { let mut v = provider.settings_config.clone(); @@ -1209,7 +1327,7 @@ impl ProviderService { if !add_to_live { return Ok(true); } - write_live_with_common_config(state.db.as_ref(), &app_type, &provider)?; + write_live_with_common_config_for_state(state, &app_type, &provider)?; return Ok(true); } @@ -1220,7 +1338,7 @@ impl ProviderService { state .db .set_current_provider(app_type.as_str(), &provider.id)?; - write_live_with_common_config(state.db.as_ref(), &app_type, &provider)?; + write_live_with_common_config_for_state(state, &app_type, &provider)?; } Ok(true) @@ -1370,10 +1488,16 @@ impl ProviderService { if !live_config_managed { return Ok(true); } - write_live_with_common_config(state.db.as_ref(), &app_type, &provider)?; + write_live_with_common_config_for_state(state, &app_type, &provider)?; return Ok(true); } + let unbound_codex_managed_account_id = Self::unbound_managed_codex_oauth_account_id( + &app_type, + existing_provider.as_ref(), + &provider, + ); + // Save to database state.db.save_provider(app_type.as_str(), &provider)?; @@ -1401,14 +1525,32 @@ impl ProviderService { if should_sync_via_proxy { if matches!(app_type, AppType::ClaudeDesktop) { - write_live_with_common_config(state.db.as_ref(), &app_type, &provider)?; + write_live_with_common_config_for_state(state, &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}")))?; + let update_backup_result = + if let Some(account_id) = unbound_codex_managed_account_id.as_deref() { + futures::executor::block_on( + state + .proxy_service + .update_live_backup_from_provider_clearing_codex_auth( + app_type.as_str(), + &provider, + account_id, + ), + ) + } else { + futures::executor::block_on( + state + .proxy_service + .update_live_backup_from_provider(app_type.as_str(), &provider), + ) + }; + update_backup_result + .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)?; + } } if matches!(app_type, AppType::Claude) @@ -1422,7 +1564,10 @@ impl ProviderService { .map_err(|e| AppError::Message(format!("同步 Claude Live 配置失败: {e}")))?; } } else { - write_live_with_common_config(state.db.as_ref(), &app_type, &provider)?; + 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)?; + } // Sync MCP McpService::sync_all_enabled(state)?; } @@ -1691,6 +1836,10 @@ impl ProviderService { // Backfill: Backfill current live config to current provider // Use effective current provider (validated existence) to ensure backfill targets valid provider let current_id = crate::settings::get_effective_current_provider(&state.db, &app_type)?; + let current_managed_codex_account_id = current_id + .as_deref() + .and_then(|current_id| providers.get(current_id)) + .and_then(Self::managed_codex_oauth_account_id); if let Some(current_id) = current_id { if current_id != id { @@ -1731,7 +1880,14 @@ impl ProviderService { } // Sync to live (write_gemini_live handles security flag internally for Gemini) - write_live_with_common_config(state.db.as_ref(), &app_type, provider)?; + write_live_with_common_config_for_state(state, &app_type, provider)?; + if matches!(app_type, AppType::Codex) + && 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)?; + } + } // Hermes is additive, so "switching" doesn't overwrite a live config file // — we instead update the top-level `model:` section to point at this @@ -1831,7 +1987,7 @@ impl ProviderService { // 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)?; + write_live_with_common_config_for_state(state, &app_type, provider)?; return Ok(()); } diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index 7918f5b9a..a13434a03 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -6,11 +6,14 @@ use crate::app_config::AppType; use crate::config::{get_claude_settings_path, read_json_file, write_json_file}; use crate::database::Database; use crate::provider::Provider; +use crate::proxy::providers::codex_oauth_auth::CodexOAuthManager; use crate::proxy::server::ProxyServer; use crate::proxy::switch_lock::SwitchLockManager; use crate::proxy::types::*; use crate::services::provider::{ - build_effective_settings_with_common_config, write_live_with_common_config, + build_effective_provider_for_live_with_codex_oauth_manager, + build_effective_settings_with_common_config, + write_live_with_common_config_for_codex_oauth_manager, }; use serde_json::{json, Map, Value}; use std::str::FromStr; @@ -54,6 +57,7 @@ enum ClaudeTakeoverAuthPolicy { #[derive(Clone)] pub struct ProxyService { db: Arc, + codex_oauth_manager: Arc>, server: Arc>>, /// AppHandle,用于传递给 ProxyServer 以支持故障转移时的 UI 更新 app_handle: Arc>>, @@ -67,8 +71,20 @@ 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(), + ))); + + Self::new_with_codex_oauth_manager(db, codex_oauth_manager) + } + + pub fn new_with_codex_oauth_manager( + db: Arc, + codex_oauth_manager: Arc>, + ) -> Self { Self { db, + codex_oauth_manager, server: Arc::new(RwLock::new(None)), app_handle: Arc::new(RwLock::new(None)), switch_locks: SwitchLockManager::new(), @@ -1650,8 +1666,13 @@ impl ProxyService { return Ok(false); }; - write_live_with_common_config(self.db.as_ref(), app_type, provider) - .map_err(|e| format!("写入 {app_type:?} Live 配置失败: {e}"))?; + write_live_with_common_config_for_codex_oauth_manager( + self.db.as_ref(), + app_type, + provider, + &self.codex_oauth_manager, + ) + .map_err(|e| format!("写入 {app_type:?} Live 配置失败: {e}"))?; Ok(true) } @@ -1975,7 +1996,18 @@ impl ProxyService { provider: &Provider, ) -> Result<(), String> { let _guard = self.switch_locks.lock_for_app(app_type).await; - self.update_live_backup_from_provider_inner(app_type, provider) + self.update_live_backup_from_provider_inner(app_type, provider, None) + .await + } + + pub(crate) async fn update_live_backup_from_provider_clearing_codex_auth( + &self, + app_type: &str, + provider: &Provider, + account_id: &str, + ) -> Result<(), String> { + let _guard = self.switch_locks.lock_for_app(app_type).await; + self.update_live_backup_from_provider_inner(app_type, provider, Some(account_id)) .await } @@ -1984,12 +2016,23 @@ impl ProxyService { &self, app_type: &str, provider: &Provider, + clear_codex_auth_for_account: Option<&str>, ) -> Result<(), String> { let app_type_enum = AppType::from_str(app_type).map_err(|_| format!("未知的应用类型: {app_type}"))?; - let mut effective_settings = + let mut effective_settings = if matches!(app_type_enum, AppType::Codex) { + build_effective_provider_for_live_with_codex_oauth_manager( + self.db.as_ref(), + &app_type_enum, + provider, + &self.codex_oauth_manager, + ) + .map_err(|e| format!("构建 {app_type} 有效配置失败: {e}"))? + .settings_config + } else { build_effective_settings_with_common_config(self.db.as_ref(), &app_type_enum, provider) - .map_err(|e| format!("构建 {app_type} 有效配置失败: {e}"))?; + .map_err(|e| format!("构建 {app_type} 有效配置失败: {e}"))? + }; if matches!(app_type_enum, AppType::Codex) { let existing_backup_value = self @@ -2008,7 +2051,19 @@ impl ProxyService { &mut effective_settings, existing_value, )?; - Self::preserve_codex_oauth_auth_in_backup(&mut effective_settings, existing_value)?; + if let Some(account_id) = clear_codex_auth_for_account { + Self::clear_codex_auth_in_backup( + &mut effective_settings, + existing_value, + account_id, + )?; + } else { + Self::preserve_codex_oauth_auth_in_backup( + &mut effective_settings, + existing_value, + provider, + )?; + } } } @@ -2035,6 +2090,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(()) } @@ -2091,7 +2159,7 @@ impl ProxyService { .map_err(|e| format!("更新本地当前供应商失败: {e}"))?; if should_sync_backup { - self.update_live_backup_from_provider_inner(app_type, &provider) + self.update_live_backup_from_provider_inner(app_type, &provider, None) .await?; if matches!(app_type_enum, AppType::Claude) { @@ -2201,14 +2269,48 @@ impl ProxyService { Ok(()) } + fn clear_codex_auth_in_backup( + target_settings: &mut Value, + existing_backup: &Value, + account_id: &str, + ) -> Result<(), String> { + let Some(existing_auth) = existing_backup.get("auth") else { + return Ok(()); + }; + let Some(target_obj) = target_settings.as_object_mut() else { + 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}"))? + { + target_obj.insert("auth".to_string(), json!({})); + return Ok(()); + } + + target_obj.insert("auth".to_string(), existing_auth.clone()); + Ok(()) + } + fn preserve_codex_oauth_auth_in_backup( target_settings: &mut Value, existing_backup: &Value, + provider: &Provider, ) -> Result<(), String> { if !crate::settings::preserve_codex_official_auth_on_switch() { return Ok(()); } + if provider + .meta + .as_ref() + .and_then(|meta| meta.managed_account_id_for("codex_oauth")) + .map(|id| !id.trim().is_empty()) + .unwrap_or(false) + { + return Ok(()); + } + let Some(existing_auth) = existing_backup .get("auth") .filter(|auth| crate::codex_config::codex_auth_has_oauth_login_material(auth)) @@ -2668,7 +2770,7 @@ impl ProxyService { #[cfg(test)] mod tests { use super::*; - use crate::provider::ProviderMeta; + use crate::provider::{AuthBinding, AuthBindingSource, ProviderMeta}; use serial_test::serial; use std::env; use tempfile::TempDir; @@ -4826,6 +4928,161 @@ base_url = "https://codex.example/v1" ); } + #[tokio::test] + #[serial] + async fn update_live_backup_from_provider_uses_managed_codex_oauth_binding_over_existing_oauth_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 official auth preservation"); + + let db = Arc::new(Database::memory().expect("init db")); + let service = ProxyService::new(db.clone()); + service + .codex_oauth_manager + .read() + .await + .add_test_account_with_access_token("acct-managed", "managed-token") + .await + .expect("seed managed Codex OAuth account"); + + db.save_live_backup( + "codex", + &serde_json::to_string(&json!({ + "auth": { + "auth_mode": "chatgpt", + "OPENAI_API_KEY": null, + "tokens": { + "access_token": "old-native-token", + "account_id": "acct-native" + } + }, + "config": "" + })) + .expect("serialize seed backup"), + ) + .await + .expect("seed live backup"); + + 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() + }); + + service + .update_live_backup_from_provider("codex", &provider) + .await + .expect("update live backup"); + + let backup = db + .get_live_backup("codex") + .await + .expect("get live backup") + .expect("backup exists"); + let stored: Value = + serde_json::from_str(&backup.original_config).expect("parse backup json"); + + assert_eq!( + stored + .pointer("/auth/tokens/access_token") + .and_then(|value| value.as_str()), + Some("managed-token"), + "explicit managed account binding must replace stale native OAuth backup auth" + ); + assert_eq!( + stored + .pointer("/auth/tokens/account_id") + .and_then(|value| value.as_str()), + Some("acct-managed") + ); + } + + #[tokio::test] + #[serial] + async fn update_live_backup_clearing_managed_codex_auth_preserves_native_backup_auth() { + 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 official auth preservation"); + + let db = Arc::new(Database::memory().expect("init db")); + let service = ProxyService::new(db.clone()); + let native_auth = json!({ + "auth_mode": "chatgpt", + "OPENAI_API_KEY": null, + "tokens": { + "access_token": "native-token", + "account_id": "acct-managed" + } + }); + + db.save_live_backup( + "codex", + &serde_json::to_string(&json!({ + "auth": native_auth, + "config": "" + })) + .expect("serialize seed backup"), + ) + .await + .expect("seed live backup"); + + let mut provider = Provider::with_id( + "openai-official".to_string(), + "OpenAI Official".to_string(), + json!({ + "auth": {}, + "config": "" + }), + None, + ); + provider.category = Some("official".to_string()); + + service + .update_live_backup_from_provider_clearing_codex_auth( + "codex", + &provider, + "acct-managed", + ) + .await + .expect("update live backup"); + + let backup = db + .get_live_backup("codex") + .await + .expect("get live backup") + .expect("backup exists"); + let stored: Value = + serde_json::from_str(&backup.original_config).expect("parse backup json"); + + assert_eq!( + stored.get("auth"), + Some(&native_auth), + "unbinding should not overwrite a later native Codex login in the backup" + ); + } + #[tokio::test] #[serial] async fn update_live_backup_from_provider_preserves_codex_mcp_servers() { diff --git a/src-tauri/src/store.rs b/src-tauri/src/store.rs index f34e1de9a..f7b413f2b 100644 --- a/src-tauri/src/store.rs +++ b/src-tauri/src/store.rs @@ -1,23 +1,31 @@ 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>, } impl AppState { /// 创建新的应用状态 pub fn new(db: Arc) -> Self { - let proxy_service = ProxyService::new(db.clone()); + let codex_oauth_manager = Arc::new(RwLock::new(CodexOAuthManager::new( + crate::config::get_app_config_dir(), + ))); + let proxy_service = + ProxyService::new_with_codex_oauth_manager(db.clone(), codex_oauth_manager.clone()); Self { db, proxy_service, usage_cache: Arc::new(UsageCache::new()), + codex_oauth_manager, } } } diff --git a/src/components/providers/forms/CodexFormFields.tsx b/src/components/providers/forms/CodexFormFields.tsx index a01cf4b24..16a705aeb 100644 --- a/src/components/providers/forms/CodexFormFields.tsx +++ b/src/components/providers/forms/CodexFormFields.tsx @@ -19,6 +19,7 @@ import { Trash2, } from "lucide-react"; import EndpointSpeedTest from "./EndpointSpeedTest"; +import { CodexOAuthSection } from "./CodexOAuthSection"; import { ApiKeySection, EndpointField, ModelDropdown } from "./shared"; import { fetchModelsForConfig, @@ -46,6 +47,10 @@ interface CodexFormFieldsProps { websiteUrl: string; isPartner?: boolean; partnerPromotionKey?: string; + isCodexOauthPreset?: boolean; + selectedCodexAccountId?: string | null; + onCodexAccountSelect?: (accountId: string | null) => void; + codexOauthNoneOptionLabel?: string; // Base URL shouldShowSpeedTest: boolean; @@ -111,6 +116,10 @@ export function CodexFormFields({ websiteUrl, isPartner, partnerPromotionKey, + isCodexOauthPreset = false, + selectedCodexAccountId, + onCodexAccountSelect, + codexOauthNoneOptionLabel, shouldShowSpeedTest, codexBaseUrl, onBaseUrlChange, @@ -284,26 +293,37 @@ export function CodexFormFields({ return ( <> + {/* Codex OAuth 账号选择 */} + {isCodexOauthPreset && ( + + )} + {/* Codex API Key 输入框 */} - + {!isCodexOauthPreset && ( + + )} {/* Codex Base URL 输入框 */} {shouldShowSpeedTest && ( diff --git a/src/components/providers/forms/CodexOAuthSection.tsx b/src/components/providers/forms/CodexOAuthSection.tsx index 603939ad6..7e3e91dfc 100644 --- a/src/components/providers/forms/CodexOAuthSection.tsx +++ b/src/components/providers/forms/CodexOAuthSection.tsx @@ -31,6 +31,8 @@ interface CodexOAuthSectionProps { selectedAccountId?: string | null; /** 账号选择回调 */ onAccountSelect?: (accountId: string | null) => void; + /** 空选择项文案;默认表示使用托管认证的默认账号 */ + noneOptionLabel?: string; /** 是否开启 Codex FAST mode */ fastModeEnabled?: boolean; /** FAST mode 切换回调 */ @@ -47,6 +49,7 @@ export const CodexOAuthSection: React.FC = ({ className, selectedAccountId, onAccountSelect, + noneOptionLabel, fastModeEnabled = false, onFastModeChange, }) => { @@ -111,7 +114,7 @@ export const CodexOAuthSection: React.FC = ({ {/* 账号选择器 */} - {hasAnyAccount && onAccountSelect && ( + {onAccountSelect && (hasAnyAccount || noneOptionLabel) && (