diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index cc40f9dde..73b9944ca 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -52,13 +52,56 @@ pub struct SwitchResult { #[cfg(test)] mod tests { use super::*; + use crate::config::{get_claude_settings_path, read_json_file, write_json_file}; use crate::database::Database; use crate::provider::ProviderMeta; + use crate::proxy::types::ProxyConfig; use crate::store::AppState; use serde_json::json; + use serial_test::serial; + use std::env; use std::fs; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex, OnceLock}; + use tempfile::TempDir; + + struct TempHome { + #[allow(dead_code)] + dir: TempDir, + original_home: Option, + original_userprofile: Option, + } + + impl TempHome { + fn new() -> Self { + let dir = TempDir::new().expect("failed to create temp home"); + let original_home = env::var("HOME").ok(); + let original_userprofile = env::var("USERPROFILE").ok(); + + env::set_var("HOME", dir.path()); + env::set_var("USERPROFILE", dir.path()); + + Self { + dir, + original_home, + original_userprofile, + } + } + } + + impl Drop for TempHome { + fn drop(&mut self) { + match &self.original_home { + Some(value) => env::set_var("HOME", value), + None => env::remove_var("HOME"), + } + + match &self.original_userprofile { + Some(value) => env::set_var("USERPROFILE", value), + None => env::remove_var("USERPROFILE"), + } + } + } fn test_guard() -> std::sync::MutexGuard<'static, ()> { static LOCK: OnceLock> = OnceLock::new(); @@ -267,6 +310,125 @@ base_url = "http://localhost:8080" ); } + #[tokio::test] + #[serial] + async fn update_current_claude_provider_syncs_live_when_proxy_takeover_detected_without_backup() { + let _home = TempHome::new(); + crate::settings::reload_settings().expect("reload settings"); + + let db = Arc::new(Database::memory().expect("init db")); + let state = AppState::new(db.clone()); + + let original = Provider::with_id( + "p1".into(), + "Claude A".into(), + json!({ + "env": { + "ANTHROPIC_API_KEY": "token-a", + "ANTHROPIC_BASE_URL": "https://api.a.example", + "ANTHROPIC_MODEL": "model-a" + }, + "permissions": { "allow": ["Bash"] } + }), + None, + ); + db.save_provider("claude", &original) + .expect("save provider"); + db.set_current_provider("claude", "p1") + .expect("set current provider"); + crate::settings::set_current_provider(&AppType::Claude, Some("p1")) + .expect("set local current provider"); + + db.update_proxy_config(ProxyConfig { + live_takeover_active: true, + ..Default::default() + }) + .await + .expect("update proxy config"); + { + let mut config = db + .get_proxy_config_for_app("claude") + .await + .expect("get app proxy config"); + config.enabled = true; + db.update_proxy_config_for_app(config) + .await + .expect("update app proxy config"); + } + + write_json_file( + &get_claude_settings_path(), + &json!({ + "env": { + "ANTHROPIC_BASE_URL": "http://127.0.0.1:15721", + "ANTHROPIC_API_KEY": "PROXY_MANAGED", + "ANTHROPIC_MODEL": "stale-model" + }, + "permissions": { "allow": ["Bash"] } + }), + ) + .expect("seed taken-over live file"); + + state.proxy_service.start().await.expect("start proxy service"); + + let updated = Provider::with_id( + "p1".into(), + "Claude A".into(), + json!({ + "env": { + "ANTHROPIC_API_KEY": "token-updated", + "ANTHROPIC_BASE_URL": "https://api.updated.example", + "ANTHROPIC_MODEL": "model-updated" + }, + "permissions": { "allow": ["Read"] } + }), + None, + ); + + ProviderService::update(&state, AppType::Claude, None, updated.clone()) + .expect("update current provider"); + + let backup = db + .get_live_backup("claude") + .await + .expect("get live backup") + .expect("backup exists"); + let stored_provider = db + .get_provider_by_id("p1", "claude") + .expect("get stored provider") + .expect("stored provider exists"); + let expected_backup = + serde_json::to_string(&stored_provider.settings_config).expect("serialize"); + assert_eq!(backup.original_config, expected_backup); + + let live: Value = read_json_file(&get_claude_settings_path()).expect("read live"); + assert_eq!( + live.get("permissions"), + updated.settings_config.get("permissions"), + "provider edits should propagate into Claude live config during takeover" + ); + assert_eq!( + live.get("env") + .and_then(|env| env.get("ANTHROPIC_API_KEY")) + .and_then(|v| v.as_str()), + Some("PROXY_MANAGED"), + "takeover placeholder should stay intact" + ); + assert_eq!( + live.get("env") + .and_then(|env| env.get("ANTHROPIC_BASE_URL")) + .and_then(|v| v.as_str()), + Some("http://127.0.0.1:15721"), + "proxy base URL should stay intact" + ); + assert!( + live.get("env") + .and_then(|env| env.get("ANTHROPIC_MODEL")) + .is_none(), + "model override should be removed in takeover live config" + ); + } + #[test] fn rename_rejects_missing_original_provider() { with_test_home(|state, _| { @@ -1007,24 +1169,37 @@ impl ProviderService { let is_current = effective_current.as_deref() == Some(provider.id.as_str()); if is_current { - // 如果代理接管模式处于激活状态,并且代理服务正在运行: - // - 不写 Live 配置(否则会破坏接管) - // - 仅更新 Live 备份(保证关闭代理时能恢复到最新配置) - let is_app_taken_over = + // 如果 Claude 代理接管处于激活状态,并且代理服务正在运行: + // - 不直接走普通 Live 写入逻辑 + // - 改为更新 Live 备份,并在 Claude 下同步代理安全的 Live 配置 + let has_live_backup = futures::executor::block_on(state.db.get_live_backup(app_type.as_str())) .ok() .flatten() .is_some(); let is_proxy_running = futures::executor::block_on(state.proxy_service.is_running()); - let should_skip_live_write = is_app_taken_over && is_proxy_running; + let live_taken_over = state + .proxy_service + .detect_takeover_in_live_config_for_app(&app_type); + let should_sync_via_proxy = + is_proxy_running && (has_live_backup || live_taken_over); - if should_skip_live_write { + if should_sync_via_proxy { 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}")))?; + + if matches!(app_type, AppType::Claude) { + futures::executor::block_on( + state + .proxy_service + .sync_claude_live_from_provider_while_proxy_active(&provider), + ) + .map_err(|e| AppError::Message(format!("同步 Claude Live 配置失败: {e}")))?; + } } else { write_live_with_common_config(state.db.as_ref(), &app_type, &provider)?; // Sync MCP diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index 74ca88312..0411c26d0 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -83,6 +83,65 @@ impl ProxyService { Ok(()) } + fn apply_claude_takeover_fields(config: &mut Value, proxy_url: &str) { + if !config.is_object() { + *config = json!({}); + } + + let root = config + .as_object_mut() + .expect("Claude config should be normalized to an object"); + let env = root.entry("env".to_string()).or_insert_with(|| json!({})); + if !env.is_object() { + *env = json!({}); + } + + let env = env + .as_object_mut() + .expect("Claude env should be normalized to an object"); + env.insert("ANTHROPIC_BASE_URL".to_string(), json!(proxy_url)); + + for key in CLAUDE_MODEL_OVERRIDE_ENV_KEYS { + env.remove(key); + } + + let token_keys = [ + "ANTHROPIC_AUTH_TOKEN", + "ANTHROPIC_API_KEY", + "OPENROUTER_API_KEY", + "OPENAI_API_KEY", + ]; + + let mut replaced_any = false; + for key in token_keys { + if env.contains_key(key) { + env.insert(key.to_string(), json!(PROXY_TOKEN_PLACEHOLDER)); + replaced_any = true; + } + } + + if !replaced_any { + env.insert( + "ANTHROPIC_AUTH_TOKEN".to_string(), + json!(PROXY_TOKEN_PLACEHOLDER), + ); + } + } + + pub async fn sync_claude_live_from_provider_while_proxy_active( + &self, + provider: &Provider, + ) -> Result<(), String> { + let mut effective_settings = + build_effective_settings_with_common_config(self.db.as_ref(), &AppType::Claude, provider) + .map_err(|e| format!("构建 claude 有效配置失败: {e}"))?; + let (proxy_url, _) = self.build_proxy_urls().await?; + + Self::apply_claude_takeover_fields(&mut effective_settings, &proxy_url); + self.write_claude_live(&effective_settings)?; + Ok(()) + } + /// 设置 AppHandle(在应用初始化时调用) pub fn set_app_handle(&self, handle: tauri::AppHandle) { futures::executor::block_on(async { @@ -849,41 +908,7 @@ impl ProxyService { // Claude: 修改 ANTHROPIC_BASE_URL,使用占位符替代真实 Token(代理会注入真实 Token) if let Ok(mut live_config) = self.read_claude_live() { - if let Some(env) = live_config.get_mut("env").and_then(|v| v.as_object_mut()) { - env.insert("ANTHROPIC_BASE_URL".to_string(), json!(&proxy_url)); - // 关键:接管模式下移除模型覆盖字段,避免切换供应商后仍用旧模型名发起请求 - for key in CLAUDE_MODEL_OVERRIDE_ENV_KEYS { - env.remove(key); - } - // 仅覆盖已存在的 Token 字段,避免新增字段导致用户困惑; - // 若完全没有 Token 字段,则写入 ANTHROPIC_AUTH_TOKEN 占位符用于避免客户端警告。 - let token_keys = [ - "ANTHROPIC_AUTH_TOKEN", - "ANTHROPIC_API_KEY", - "OPENROUTER_API_KEY", - "OPENAI_API_KEY", - ]; - - let mut replaced_any = false; - for key in token_keys { - if env.contains_key(key) { - env.insert(key.to_string(), json!(PROXY_TOKEN_PLACEHOLDER)); - replaced_any = true; - } - } - - if !replaced_any { - env.insert( - "ANTHROPIC_AUTH_TOKEN".to_string(), - json!(PROXY_TOKEN_PLACEHOLDER), - ); - } - } else { - live_config["env"] = json!({ - "ANTHROPIC_BASE_URL": &proxy_url, - "ANTHROPIC_AUTH_TOKEN": PROXY_TOKEN_PLACEHOLDER - }); - } + Self::apply_claude_takeover_fields(&mut live_config, &proxy_url); self.write_claude_live(&live_config)?; log::info!("Claude Live 配置已接管,代理地址: {proxy_url}"); } @@ -933,41 +958,7 @@ impl ProxyService { match app_type { AppType::Claude => { let mut live_config = self.read_claude_live()?; - if let Some(env) = live_config.get_mut("env").and_then(|v| v.as_object_mut()) { - env.insert("ANTHROPIC_BASE_URL".to_string(), json!(&proxy_url)); - // 关键:接管模式下移除模型覆盖字段,避免切换供应商后仍用旧模型名发起请求 - for key in CLAUDE_MODEL_OVERRIDE_ENV_KEYS { - env.remove(key); - } - - let token_keys = [ - "ANTHROPIC_AUTH_TOKEN", - "ANTHROPIC_API_KEY", - "OPENROUTER_API_KEY", - "OPENAI_API_KEY", - ]; - - let mut replaced_any = false; - for key in token_keys { - if env.contains_key(key) { - env.insert(key.to_string(), json!(PROXY_TOKEN_PLACEHOLDER)); - replaced_any = true; - } - } - - if !replaced_any { - env.insert( - "ANTHROPIC_AUTH_TOKEN".to_string(), - json!(PROXY_TOKEN_PLACEHOLDER), - ); - } - } else { - live_config["env"] = json!({ - "ANTHROPIC_BASE_URL": &proxy_url, - "ANTHROPIC_AUTH_TOKEN": PROXY_TOKEN_PLACEHOLDER - }); - } - + Self::apply_claude_takeover_fields(&mut live_config, &proxy_url); self.write_claude_live(&live_config)?; log::info!("Claude Live 配置已接管,代理地址: {proxy_url}"); } @@ -1024,41 +1015,7 @@ impl ProxyService { match app_type { AppType::Claude => { if let Ok(mut live_config) = self.read_claude_live() { - if let Some(env) = live_config.get_mut("env").and_then(|v| v.as_object_mut()) { - env.insert("ANTHROPIC_BASE_URL".to_string(), json!(&proxy_url)); - // 关键:接管模式下移除模型覆盖字段,避免切换供应商后仍用旧模型名发起请求 - for key in CLAUDE_MODEL_OVERRIDE_ENV_KEYS { - env.remove(key); - } - - let token_keys = [ - "ANTHROPIC_AUTH_TOKEN", - "ANTHROPIC_API_KEY", - "OPENROUTER_API_KEY", - "OPENAI_API_KEY", - ]; - - let mut replaced_any = false; - for key in token_keys { - if env.contains_key(key) { - env.insert(key.to_string(), json!(PROXY_TOKEN_PLACEHOLDER)); - replaced_any = true; - } - } - - if !replaced_any { - env.insert( - "ANTHROPIC_AUTH_TOKEN".to_string(), - json!(PROXY_TOKEN_PLACEHOLDER), - ); - } - } else { - live_config["env"] = json!({ - "ANTHROPIC_BASE_URL": &proxy_url, - "ANTHROPIC_AUTH_TOKEN": PROXY_TOKEN_PLACEHOLDER - }); - } - + Self::apply_claude_takeover_fields(&mut live_config, &proxy_url); let _ = self.write_claude_live(&live_config); } } @@ -1614,6 +1571,8 @@ impl ProxyService { .await?; if matches!(app_type_enum, AppType::Claude) { + self.sync_claude_live_from_provider_while_proxy_active(&provider) + .await?; if let Err(e) = self.cleanup_claude_model_overrides_in_live() { log::warn!("清理 Claude Live 模型字段失败(不影响热切换结果): {e}"); } @@ -2255,6 +2214,108 @@ model = "gpt-5.1-codex" assert_eq!(backup.original_config, expected); } + #[tokio::test] + #[serial] + async fn hot_switch_provider_updates_claude_live_while_preserving_takeover_fields() { + let _home = TempHome::new(); + crate::settings::reload_settings().expect("reload settings"); + + let db = Arc::new(Database::memory().expect("init db")); + let service = ProxyService::new(db.clone()); + + let provider_a = Provider::with_id( + "a".to_string(), + "A".to_string(), + json!({ + "env": { + "ANTHROPIC_API_KEY": "a-key", + "ANTHROPIC_BASE_URL": "https://api.a.example", + "ANTHROPIC_MODEL": "claude-old" + }, + "permissions": { "allow": ["Bash"] } + }), + None, + ); + let provider_b = Provider::with_id( + "b".to_string(), + "B".to_string(), + json!({ + "env": { + "ANTHROPIC_API_KEY": "b-key", + "ANTHROPIC_BASE_URL": "https://api.b.example", + "ANTHROPIC_MODEL": "claude-new" + }, + "permissions": { "allow": ["Read"] } + }), + None, + ); + + db.save_provider("claude", &provider_a) + .expect("save provider a"); + db.save_provider("claude", &provider_b) + .expect("save provider b"); + db.set_current_provider("claude", "a") + .expect("set current provider"); + crate::settings::set_current_provider(&AppType::Claude, Some("a")) + .expect("set local current provider"); + db.save_live_backup( + "claude", + &serde_json::to_string(&provider_a.settings_config).expect("serialize provider a"), + ) + .await + .expect("seed live backup"); + service + .write_claude_live(&json!({ + "env": { + "ANTHROPIC_BASE_URL": "http://127.0.0.1:15721", + "ANTHROPIC_API_KEY": PROXY_TOKEN_PLACEHOLDER, + "ANTHROPIC_MODEL": "stale-model" + }, + "permissions": { "allow": ["Bash"] } + })) + .expect("seed taken-over live file"); + + service + .hot_switch_provider("claude", "b") + .await + .expect("hot switch provider"); + + let live = service.read_claude_live().expect("read live config"); + assert_eq!( + live.get("permissions"), + provider_b.settings_config.get("permissions"), + "provider-derived live settings should be refreshed" + ); + assert_eq!( + live.get("env") + .and_then(|env| env.get("ANTHROPIC_API_KEY")) + .and_then(|v| v.as_str()), + Some(PROXY_TOKEN_PLACEHOLDER), + "takeover token placeholder should be preserved" + ); + assert_eq!( + live.get("env") + .and_then(|env| env.get("ANTHROPIC_BASE_URL")) + .and_then(|v| v.as_str()), + Some("http://127.0.0.1:15721"), + "takeover proxy URL should remain active" + ); + assert!( + live.get("env") + .and_then(|env| env.get("ANTHROPIC_MODEL")) + .is_none(), + "Claude model override fields should be removed in takeover mode" + ); + + let backup = db + .get_live_backup("claude") + .await + .expect("get live backup") + .expect("backup exists"); + let expected = serde_json::to_string(&provider_b.settings_config).expect("serialize"); + assert_eq!(backup.original_config, expected); + } + #[tokio::test] #[serial] async fn hot_switch_provider_serializes_same_app_switches() {