//! Provider service module //! //! Handles provider CRUD operations, switching, and configuration management. mod endpoints; mod gemini_auth; mod live; mod usage; use indexmap::IndexMap; use regex::Regex; use serde::Deserialize; use serde_json::Value; use crate::app_config::AppType; use crate::database::{ validate_cost_multiplier, validate_pricing_source, NewProviderAggregate, ProviderKey, ProviderRowUpdate, RenameProvider, }; use crate::error::AppError; use crate::provider::{Provider, ProviderMutationInput, UsageResult}; use crate::services::mcp::McpService; use crate::services::pi_catalog::{PiCatalogCoordinator, PiCatalogMutation}; use crate::settings::CustomEndpoint; use crate::store::AppState; // Re-export sub-module functions for external access pub use live::{ import_default_config, import_hermes_providers_from_live, import_openclaw_providers_from_live, import_opencode_providers_from_live, read_live_settings, should_import_default_config_on_startup, sync_current_to_live, update_toml_common_config_snippet, }; // Internal re-exports (pub(crate)) pub(crate) use live::sanitize_claude_settings_for_live; pub(crate) use live::{ 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, }; // Internal re-exports use live::{ remove_hermes_provider_from_live, remove_openclaw_provider_from_live, remove_opencode_provider_from_live, write_gemini_live, }; use usage::validate_usage_script; /// The built-in Codex official provider is safe to select during takeover: /// Codex keeps ownership of its ChatGPT login and the proxy only forwards the /// authenticated request. Other official providers retain the existing block. pub fn official_provider_supports_proxy_takeover(app_type: &AppType, provider: &Provider) -> bool { matches!(app_type, AppType::Codex) && crate::proxy::providers::is_codex_official_provider(provider) } /// 统一会话开关变更后,立即按新开关状态重写当前官方 Codex 供应商的 /// live 配置,使开关即时生效(无需等下一次切换)。 /// 当前供应商非官方(或不存在)时为 no-op:注入只作用于官方配置, /// 第三方 live 配置不受开关影响。 pub fn reapply_current_codex_official_live(state: &AppState) -> Result { let current_id = ProviderService::current(state, AppType::Codex)?; if current_id.is_empty() { return Ok(false); } let providers = state.db.get_all_providers(AppType::Codex.as_str())?; let Some(provider) = providers.get(¤t_id) else { return Ok(false); }; if provider.category.as_deref() != Some("official") { return Ok(false); } // 代理接管期间 live 归代理所有(开启代理时官方供应商只警告不拦截, // 二者可以共存)。与切换/保存路径一致:以 backup/占位符为所有权信号, // 只更新备份,注入后的配置由接管释放时的恢复路径落盘。 let has_live_backup = futures::executor::block_on(state.db.get_live_backup(AppType::Codex.as_str())) .ok() .flatten() .is_some(); let live_taken_over = state .proxy_service .detect_takeover_in_live_config_for_app(&AppType::Codex); if has_live_backup || live_taken_over { futures::executor::block_on( state .proxy_service .update_live_backup_from_provider(AppType::Codex.as_str(), provider), ) .map_err(|e| AppError::Message(format!("更新 Live 备份失败: {e}")))?; return Ok(true); } live::write_live_with_common_config(&state.db, &AppType::Codex, provider)?; // 重写 live 会整体替换 config.toml(有意设计),[mcp_servers] 随之丢失, // 写完必须立刻从 DB 重新投影启用的 MCP。只投影 Codex 而非 // sync_all_enabled:后者按 AppType::all() 顺序逐应用短路,排在 Codex // 前面的无关应用 live 损坏(如 ~/.claude.json 坏 JSON)会阻断 Codex // 的重投影,让刚被清掉的 [mcp_servers] 无人补回。 // 投影失败降级为警告:走到这里 live 已按新开关状态落盘,开关事实上 // 已生效;若把错误上抛,save_settings 会回滚开关设置,制造"设置=旧值、 // live=新桶"的会话分裂——正是该回滚要防止的状态。MCP 投影可自愈 // (下次切换 / 任一 MCP 启停操作都会重新投影)。 if let Err(err) = McpService::sync_enabled_for_app(state, &AppType::Codex) { log::warn!("统一会话开关重写 live 后重投影 Codex MCP 失败(将在下次同步时自愈): {err}"); } Ok(true) } /// Provider business logic service pub struct ProviderService; /// Explicit service-boundary conversion for normalized drafts and live-import /// records. There is intentionally no `From` implementation: a /// hydrated read projection cannot silently become a write DTO via `.into()`. pub(crate) fn provider_to_mutation_input(provider: Provider) -> ProviderMutationInput { ProviderMutationInput { id: provider.id, name: provider.name, settings_config: provider.settings_config, website_url: provider.website_url, category: provider.category, created_at: provider.created_at, sort_index: provider.sort_index, notes: provider.notes, meta: provider.meta, icon: provider.icon, icon_color: provider.icon_color, in_failover_queue: provider.in_failover_queue, } } fn create_provider_record( state: &AppState, app_type: &AppType, input: ProviderMutationInput, ) -> Result<(), AppError> { state .db .create_provider(NewProviderAggregate::from_input(app_type.as_str(), input)?) } fn update_provider_record( state: &AppState, app_type: &AppType, input: &ProviderMutationInput, ) -> Result<(), AppError> { let key = ProviderKey::new(app_type.as_str(), input.id.clone())?; let row = ProviderRowUpdate::from_input(input)?; state.db.update_provider(&key, &row) } fn update_provider_record_if_unchanged( state: &AppState, app_type: &AppType, observed_fingerprint: String, input: ProviderMutationInput, ) -> Result<(), AppError> { reconcile_provider_record_with_precondition( state.db.as_ref(), app_type.as_str(), input, ReconcilePrecondition::ExpectPresent { fingerprint: observed_fingerprint, }, ) } fn remove_hydrated_endpoints_from_row_update(provider: &mut Provider) { if let Some(meta) = provider.meta.as_mut() { meta.custom_endpoints.clear(); } } fn lock_additive_provider_mutation( state: &AppState, app_type: &AppType, ) -> Option> { app_type.is_additive_mode().then(|| { futures::executor::block_on(state.proxy_service.lock_switch_for_app(app_type.as_str())) }) } /// Reconcile 的显式前置期望(前置工程 A 认证契约 T9)。 /// check-then-branch 的 TOCTOU 由调用方在观察时声明期望、由本层强制。 #[derive(Debug, Clone)] pub(crate) enum ReconcilePrecondition { /// 调用方观察到目标不存在;若已被竞争者创建,必须返回 /// [`AppError::Conflict`],绝不退化为覆盖更新。 ExpectAbsent, /// 调用方观察到目标存在且内容指纹为 `fingerprint`;指纹过期必须返回 /// [`AppError::Conflict`],由调用方重读重试。 ExpectPresent { fingerprint: String }, } /// 行内容指纹:并发前置期望的版本标记(纯函数,不含状态列与 endpoint)。 /// /// 决定性要求:仓库启用了 serde_json `preserve_order`,且 `ProviderMeta` /// 内含 HashMap——直接序列化的键序随机,会产生伪 Conflict。因此必须走 /// 递归排序的规范化哈希;`meta.custom_endpoints` 属 endpoint authority, /// 不参与内容指纹(不同读 API 对其填充不一致)。 pub(crate) fn provider_row_fingerprint(provider: &crate::provider::Provider) -> String { provider.row_content_fingerprint() } /// Reconcile paths must carry the caller's observed state into the write. /// Creation is strict, while updates compare the observed row fingerprint and /// write under one database lock and transaction. pub(crate) fn reconcile_provider_record_with_precondition( db: &crate::database::Database, app_type: &str, input: ProviderMutationInput, precondition: ReconcilePrecondition, ) -> Result<(), AppError> { match precondition { ReconcilePrecondition::ExpectAbsent => { db.create_provider(NewProviderAggregate::from_input(app_type, input)?) } ReconcilePrecondition::ExpectPresent { fingerprint } => { let key = ProviderKey::new(app_type, input.id.clone())?; let row = ProviderRowUpdate::from_input(&input)?; db.update_provider_if_content_fingerprint(&key, &fingerprint, &row) } } } /// Result of a provider switch operation, including any non-fatal warnings #[derive(Debug, serde::Serialize, Default)] #[serde(rename_all = "camelCase")] pub struct SwitchResult { pub warnings: Vec, } #[cfg(test)] mod tests { use super::*; #[cfg(any(target_os = "macos", windows))] use crate::claude_desktop_config::PROFILE_ID; use crate::config::{get_claude_settings_path, read_json_file, write_json_file}; use crate::database::Database; #[cfg(any(target_os = "macos", windows))] use crate::provider::{ClaudeDesktopMode, ClaudeDesktopModelRoute}; use crate::provider::{ProviderMeta, UsageScript}; 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::{mpsc, Arc, Mutex, OnceLock}; use std::thread; use std::time::Duration; use tempfile::TempDir; struct TempHome { #[allow(dead_code)] dir: TempDir, original_home: Option, #[cfg(windows)] original_local_app_data: Option, original_userprofile: Option, original_test_home: Option, } impl TempHome { fn new() -> Self { let dir = TempDir::new().expect("failed to create temp home"); let original_home = env::var("HOME").ok(); #[cfg(windows)] let original_local_app_data = env::var("LOCALAPPDATA").ok(); let original_userprofile = env::var("USERPROFILE").ok(); let original_test_home = env::var("CC_SWITCH_TEST_HOME").ok(); env::set_var("HOME", dir.path()); #[cfg(windows)] env::set_var("LOCALAPPDATA", dir.path().join("AppData").join("Local")); env::set_var("USERPROFILE", dir.path()); env::set_var("CC_SWITCH_TEST_HOME", dir.path()); Self { dir, original_home, #[cfg(windows)] original_local_app_data, original_userprofile, original_test_home, } } } impl Drop for TempHome { fn drop(&mut self) { match &self.original_home { Some(value) => env::set_var("HOME", value), None => env::remove_var("HOME"), } #[cfg(windows)] { match &self.original_local_app_data { Some(value) => env::set_var("LOCALAPPDATA", value), None => env::remove_var("LOCALAPPDATA"), } } match &self.original_userprofile { Some(value) => env::set_var("USERPROFILE", value), None => env::remove_var("USERPROFILE"), } match &self.original_test_home { Some(value) => env::set_var("CC_SWITCH_TEST_HOME", value), None => env::remove_var("CC_SWITCH_TEST_HOME"), } } } #[cfg(windows)] fn claude_desktop_profile_path(home: &Path) -> PathBuf { home.join("AppData") .join("Local") .join("Claude-3p") .join("configLibrary") .join(format!("{PROFILE_ID}.json")) } #[cfg(target_os = "macos")] fn claude_desktop_profile_path(home: &Path) -> PathBuf { home.join("Library") .join("Application Support") .join("Claude-3p") .join("configLibrary") .join(format!("{PROFILE_ID}.json")) } fn test_guard() -> std::sync::MutexGuard<'static, ()> { static LOCK: OnceLock> = OnceLock::new(); LOCK.get_or_init(|| Mutex::new(())) .lock() .unwrap_or_else(|err| err.into_inner()) } fn with_test_home(test: impl FnOnce(&AppState, &Path) -> T) -> T { let _guard = test_guard(); let temp = tempfile::tempdir().expect("tempdir"); let old_test_home = std::env::var_os("CC_SWITCH_TEST_HOME"); let old_home = std::env::var_os("HOME"); std::env::set_var("CC_SWITCH_TEST_HOME", temp.path()); std::env::set_var("HOME", temp.path()); let db = Arc::new(Database::memory().expect("in-memory database")); let state = AppState::new(db); let result = test(&state, temp.path()); match old_test_home { Some(value) => std::env::set_var("CC_SWITCH_TEST_HOME", value), None => std::env::remove_var("CC_SWITCH_TEST_HOME"), } match old_home { Some(value) => std::env::set_var("HOME", value), None => std::env::remove_var("HOME"), } result } fn codex_settings(base_url: &str, api_key: &str) -> Value { json!({ "auth": { "OPENAI_API_KEY": api_key }, "config": format!( "model_provider = \"custom\"\n\ [model_providers.custom]\n\ name = \"custom\"\n\ base_url = \"{base_url}\"\n\ wire_api = \"chat\"\n" ) }) } fn usage_script_with_credentials( api_key: Option<&str>, base_url: Option<&str>, template_type: Option<&str>, ) -> UsageScript { UsageScript { enabled: true, language: "javascript".to_string(), code: "return { remaining: 1, unit: 'USD' };".to_string(), timeout: Some(10), api_key: api_key.map(str::to_string), base_url: base_url.map(str::to_string), access_token: None, user_id: None, template_type: template_type.map(str::to_string), auto_query_interval: None, coding_plan_provider: None, access_key_id: Some("ak-test".to_string()), secret_access_key: Some("sk-test".to_string()), team_organization_id: None, team_project_id: None, } } fn codex_provider_with_usage( id: &str, base_url: &str, api_key: &str, usage_api_key: Option<&str>, usage_base_url: Option<&str>, template_type: Option<&str>, ) -> Provider { let mut provider = Provider::with_id( id.to_string(), format!("Provider {id}"), codex_settings(base_url, api_key), None, ); provider.meta = Some(ProviderMeta { usage_script: Some(usage_script_with_credentials( usage_api_key, usage_base_url, template_type, )), ..Default::default() }); provider } fn openclaw_provider(id: &str) -> Provider { Provider { id: id.to_string(), name: format!("Provider {id}"), settings_config: json!({ "baseUrl": "https://api.deepseek.com", "apiKey": "test-key", "api": "openai-completions", "models": [], }), website_url: None, category: Some("custom".to_string()), created_at: Some(1), sort_index: Some(0), notes: None, meta: None, icon: None, icon_color: None, in_failover_queue: false, } } fn hermes_provider(id: &str) -> Provider { Provider { id: id.to_string(), name: format!("Provider {id}"), settings_config: json!({ "api": "openai-chat", "base_url": "https://api.example.com/v1", "api_key": "test-key", "models": { "gpt-4o": { "name": "GPT-4o" } } }), website_url: None, category: Some("custom".to_string()), created_at: Some(1), sort_index: Some(0), notes: None, meta: None, icon: None, icon_color: None, in_failover_queue: false, } } fn pi_provider(id: &str) -> Provider { Provider { id: id.to_string(), name: format!("Pi Provider {id}"), settings_config: json!({ "name": format!("Pi Provider {id}"), "api": "openai-responses", "baseUrl": "https://pi.example/v1", "apiKey": "test-key", "models": [ {"id": "model-a", "name": "Model A"}, {"id": "model-b", "name": "Model B"} ] }), website_url: None, category: Some("custom".to_string()), created_at: Some(1), sort_index: Some(0), notes: None, meta: None, icon: Some("pi".to_string()), icon_color: None, in_failover_queue: false, } } fn opencode_provider(id: &str) -> Provider { Provider { id: id.to_string(), name: format!("Provider {id}"), settings_config: json!({ "npm": "@ai-sdk/openai-compatible", "name": format!("Provider {id}"), "options": { "baseURL": "https://api.example.com/v1", "apiKey": "test-key" }, "models": { "gpt-4o": { "name": "GPT-4o" } } }), website_url: None, category: Some("custom".to_string()), created_at: Some(1), sort_index: Some(0), notes: None, meta: None, icon: None, icon_color: None, in_failover_queue: false, } } fn opencode_omo_provider(id: &str, category: &str) -> Provider { let mut settings = serde_json::Map::new(); settings.insert( "agents".to_string(), json!({ "writer": { "model": "gpt-4o-mini" } }), ); if category == "omo" { settings.insert( "categories".to_string(), json!({ "default": ["writer"] }), ); } settings.insert( "otherFields".to_string(), json!({ "theme": "dark" }), ); Provider { id: id.to_string(), name: format!("Provider {id}"), settings_config: Value::Object(settings), website_url: None, category: Some(category.to_string()), created_at: Some(1), sort_index: Some(0), notes: None, meta: None, icon: None, icon_color: None, in_failover_queue: false, } } fn omo_config_path(home: &Path, category: &str) -> PathBuf { home.join(".config").join("opencode").join(match category { "omo" => crate::services::omo::STANDARD.preferred_filename, "omo-slim" => crate::services::omo::SLIM.preferred_filename, other => panic!("unexpected OMO category in test: {other}"), }) } fn endpoint(url: &str, added_at: Option, last_used: Option) -> CustomEndpoint { CustomEndpoint { url: url.to_string(), added_at, last_used, } } fn provider_snapshot(state: &AppState, app_type: &str, id: &str) -> (Value, i64, i64) { let aggregate = state .db .get_provider_aggregate(app_type, id) .expect("read aggregate") .map(|aggregate| serde_json::to_value(aggregate).expect("serialize aggregate")) .unwrap_or(Value::Null); let conn = state.db.conn.lock().expect("lock test database"); let state_bits = conn .query_row( "SELECT is_current, in_failover_queue FROM providers WHERE app_type = ?1 AND id = ?2", rusqlite::params![app_type, id], |row| Ok((row.get(0)?, row.get(1)?)), ) .unwrap_or((0, 0)); (aggregate, state_bits.0, state_bits.1) } #[test] #[serial] fn provider_service_create_owns_initial_endpoints_and_duplicate_is_atomic() { with_test_home(|state, _| { let mut provider = opencode_provider("typed-create"); provider.in_failover_queue = true; let expected_endpoints = HashMap::from([ ( "https://one.example".to_string(), endpoint("https://one.example", None, Some(11)), ), ( "https://two.example".to_string(), endpoint("https://two.example", Some(20), None), ), ]); provider.meta = Some(ProviderMeta { custom_endpoints: expected_endpoints.clone(), ..Default::default() }); let input = provider_to_mutation_input(provider); ProviderService::add(state, AppType::OpenCode, input.clone(), false) .expect("strict service create"); let aggregate = state .db .get_provider_aggregate("opencode", "typed-create") .expect("read") .expect("aggregate"); let hydrated_endpoints = aggregate.endpoints.into_iter().collect::>(); assert_eq!( hydrated_endpoints, expected_endpoints, "the public create entry must hydrate the complete initial endpoint set losslessly" ); let before = provider_snapshot(state, "opencode", "typed-create"); assert!( ProviderService::add(state, AppType::OpenCode, input, false).is_err(), "duplicate create must not reconcile as update" ); assert_eq!( provider_snapshot(state, "opencode", "typed-create"), before, "row, endpoints, current and failover state remain byte-logically unchanged" ); }); } #[test] #[serial] fn pi_provider_service_create_hydrates_all_endpoints_and_publishes_native_default() { with_test_home(|state, home| { let original_settings = crate::settings::get_settings(); let mut isolated_settings = original_settings.clone(); isolated_settings.pi_config_dir = Some( home.join(".pi") .join("agent") .to_string_lossy() .into_owned(), ); isolated_settings.current_provider_pi = None; crate::settings::update_settings(isolated_settings) .expect("install isolated Pi settings"); let outcome = (|| -> Result<_, AppError> { let expected_endpoints = HashMap::from([ ( "https://one.pi.example".to_string(), endpoint("https://one.pi.example", None, Some(11)), ), ( "https://two.pi.example".to_string(), endpoint("https://two.pi.example", Some(20), None), ), ]); let mut provider = pi_provider("managed-pi"); provider.meta = Some(ProviderMeta { custom_endpoints: expected_endpoints.clone(), ..Default::default() }); ProviderService::add( state, AppType::Pi, provider_to_mutation_input(provider), false, )?; let aggregate = state .db .get_provider_aggregate("pi", "managed-pi")? .ok_or_else(|| AppError::NotFound("managed Pi aggregate".to_string()))?; let hydrated = aggregate.endpoints.into_iter().collect::>(); let models: Value = serde_json::from_slice( &fs::read(home.join(".pi/agent/models.json")) .map_err(|error| AppError::io(home, error))?, ) .map_err(|error| AppError::json(home, error))?; let defaults = crate::pi_config::native_settings::read_pi_native_defaults()?; Ok((expected_endpoints, hydrated, models, defaults)) })(); crate::settings::update_settings(original_settings).expect("restore process settings"); let (expected, hydrated, models, defaults) = outcome.expect("Pi service create"); assert_eq!(hydrated, expected); assert_eq!( models.pointer("/providers/managed-pi/models/0/id"), Some(&json!("model-a")) ); assert_eq!(defaults.default_provider.as_deref(), Some("managed-pi")); assert_eq!(defaults.default_model.as_deref(), Some("model-a")); }); } #[test] #[serial] fn pi_provider_service_update_rehomes_a_removed_active_model() { with_test_home(|state, home| { let original_settings = crate::settings::get_settings(); let mut isolated_settings = original_settings.clone(); isolated_settings.pi_config_dir = Some( home.join(".pi") .join("agent") .to_string_lossy() .into_owned(), ); isolated_settings.current_provider_pi = None; crate::settings::update_settings(isolated_settings) .expect("install isolated Pi settings"); let outcome = (|| -> Result<_, AppError> { let provider = pi_provider("managed-pi-update"); ProviderService::add( state, AppType::Pi, provider_to_mutation_input(provider.clone()), false, )?; let mut updated = provider; updated.settings_config["models"] = json!([{"id": "model-b", "name": "Model B"}]); ProviderService::update( state, AppType::Pi, Some("managed-pi-update"), provider_to_mutation_input(updated), )?; let defaults = crate::pi_config::native_settings::read_pi_native_defaults()?; let models: Value = serde_json::from_slice( &fs::read(home.join(".pi/agent/models.json")) .map_err(|error| AppError::io(home, error))?, ) .map_err(|error| AppError::json(home, error))?; Ok((defaults, models)) })(); crate::settings::update_settings(original_settings).expect("restore process settings"); let (defaults, models) = outcome.expect("Pi service update"); assert_eq!( defaults.default_provider.as_deref(), Some("managed-pi-update") ); assert_eq!(defaults.default_model.as_deref(), Some("model-b")); assert_eq!( models.pointer("/providers/managed-pi-update/models"), Some(&json!([{"id": "model-b", "name": "Model B"}])) ); }); } #[test] #[serial] fn pi_provider_service_current_follows_the_native_default_after_external_edit() { with_test_home(|state, home| { let original_settings = crate::settings::get_settings(); let mut isolated_settings = original_settings.clone(); isolated_settings.pi_config_dir = Some( home.join(".pi") .join("agent") .to_string_lossy() .into_owned(), ); isolated_settings.current_provider_pi = None; crate::settings::update_settings(isolated_settings) .expect("install isolated Pi settings"); let outcome = (|| -> Result<_, AppError> { for provider_id in ["native-first", "native-second"] { ProviderService::add( state, AppType::Pi, provider_to_mutation_input(pi_provider(provider_id)), false, )?; } assert_eq!( state.db.get_current_provider("pi")?.as_deref(), Some("native-first") ); crate::pi_config::native_settings::set_pi_native_default( "native-second", "model-b", )?; ProviderService::current(state, AppType::Pi) })(); crate::settings::update_settings(original_settings).expect("restore process settings"); assert_eq!( outcome.expect("resolve current Pi provider"), "native-second", "the UI current marker must follow Pi's live settings, not a stale DB marker" ); }); } #[test] #[serial] fn provider_service_create_canonicalizes_initial_endpoint_identity() { with_test_home(|state, _| { let raw_url = " https://canonical.example/// "; let mut provider = opencode_provider("canonical-endpoint"); provider.meta = Some(ProviderMeta { custom_endpoints: HashMap::from([( raw_url.to_string(), endpoint(raw_url, None, Some(11)), )]), ..Default::default() }); ProviderService::add( state, AppType::OpenCode, provider_to_mutation_input(provider), false, ) .expect("create with a non-canonical initial endpoint"); let aggregate = state .db .get_provider_aggregate("opencode", "canonical-endpoint") .expect("read canonical aggregate") .expect("canonical aggregate"); assert_eq!(aggregate.endpoints.len(), 1); assert!(aggregate .endpoints .contains_key("https://canonical.example")); ProviderService::update_endpoint_last_used( state, AppType::OpenCode, "canonical-endpoint", " https://canonical.example/ ".to_string(), ) .expect("touch must resolve the same canonical endpoint"); ProviderService::remove_custom_endpoint( state, AppType::OpenCode, "canonical-endpoint", "https://canonical.example///".to_string(), ) .expect("remove must resolve the same canonical endpoint"); assert!(state .db .get_provider_aggregate("opencode", "canonical-endpoint") .expect("read after remove") .expect("provider after remove") .endpoints .is_empty()); let mut duplicate = opencode_provider("duplicate-canonical-endpoint"); duplicate.meta = Some(ProviderMeta { custom_endpoints: HashMap::from([ ( "https://duplicate.example".to_string(), endpoint("https://duplicate.example", None, None), ), ( " https://duplicate.example/ ".to_string(), endpoint(" https://duplicate.example/ ", Some(1), None), ), ]), ..Default::default() }); assert!(matches!( ProviderService::add( state, AppType::OpenCode, provider_to_mutation_input(duplicate), false, ), Err(AppError::InvalidInput(_)) )); assert!(state .db .get_provider_aggregate("opencode", "duplicate-canonical-endpoint") .expect("read duplicate candidate") .is_none()); }); } #[test] #[serial] fn provider_service_stale_edit_payload_cannot_overwrite_endpoint_operations() { with_test_home(|state, _| { let mut provider = opencode_provider("stale-edit"); provider.meta = Some(ProviderMeta { custom_endpoints: HashMap::from([ ( "https://remove.example".to_string(), endpoint("https://remove.example", Some(1), None), ), ( "https://touch.example".to_string(), endpoint("https://touch.example", None, None), ), ]), ..Default::default() }); ProviderService::add( state, AppType::OpenCode, provider_to_mutation_input(provider), false, ) .expect("create"); // This is the existing-provider form snapshot: endpoints are // intentionally absent from the update IPC. let mut edit = state .db .get_provider_by_id("stale-edit", "opencode") .expect("read") .expect("provider"); edit.name = "Edited row".to_string(); edit.meta .get_or_insert_with(Default::default) .custom_endpoints .clear(); let stale_row_payload = provider_to_mutation_input(edit); ProviderService::add_custom_endpoint( state, AppType::OpenCode, "stale-edit", "https://added.example".to_string(), ) .expect("concurrent add"); ProviderService::remove_custom_endpoint( state, AppType::OpenCode, "stale-edit", "https://remove.example".to_string(), ) .expect("concurrent remove"); ProviderService::update_endpoint_last_used( state, AppType::OpenCode, "stale-edit", "https://touch.example".to_string(), ) .expect("concurrent touch"); ProviderService::update(state, AppType::OpenCode, None, stale_row_payload) .expect("row-only service update"); let aggregate = state .db .get_provider_aggregate("opencode", "stale-edit") .expect("read") .expect("aggregate"); assert_eq!(aggregate.provider.name, "Edited row"); assert!(!aggregate.endpoints.contains_key("https://remove.example")); assert!(aggregate.endpoints.contains_key("https://added.example")); assert!(aggregate.endpoints["https://touch.example"] .last_used .is_some()); let mut forbidden = provider_to_mutation_input(aggregate.into_provider()); forbidden .meta .get_or_insert_with(Default::default) .custom_endpoints .insert( "https://forbidden.example".to_string(), endpoint("https://forbidden.example", None, None), ); let before = provider_snapshot(state, "opencode", "stale-edit"); assert!( ProviderService::update(state, AppType::OpenCode, None, forbidden).is_err(), "endpoint-bearing update IPC is rejected" ); assert_eq!(provider_snapshot(state, "opencode", "stale-edit"), before); }); } #[test] #[serial] fn provider_service_db_only_rename_matrix_is_atomic_and_lossless() { with_test_home(|state, _| { let mut source = opencode_provider("rename-source"); let expected_endpoints = HashMap::from([ ( "https://nullable.example".to_string(), endpoint("https://nullable.example", None, None), ), ( "https://timed.example".to_string(), endpoint("https://timed.example", Some(10), Some(11)), ), ]); source.meta = Some(ProviderMeta { custom_endpoints: expected_endpoints.clone(), ..Default::default() }); ProviderService::add( state, AppType::OpenCode, provider_to_mutation_input(source), false, ) .expect("DB-only source"); let mut renamed = opencode_provider("rename-target"); renamed.name = "Renamed".to_string(); ProviderService::update( state, AppType::OpenCode, Some("rename-source"), provider_to_mutation_input(renamed), ) .expect("DB-only additive rename"); assert!(state .db .get_provider_aggregate("opencode", "rename-source") .expect("old read") .is_none()); let renamed = state .db .get_provider_aggregate("opencode", "rename-target") .expect("new read") .expect("renamed"); let renamed_endpoints = renamed.endpoints.into_iter().collect::>(); assert_eq!( renamed_endpoints, expected_endpoints, "rename preserves every endpoint field, including NULL timestamps" ); for id in ["conflict-source", "conflict-target"] { ProviderService::add( state, AppType::OpenCode, provider_to_mutation_input(opencode_provider(id)), false, ) .expect("conflict fixture"); } let source_before = provider_snapshot(state, "opencode", "conflict-source"); let target_before = provider_snapshot(state, "opencode", "conflict-target"); assert!(ProviderService::update( state, AppType::OpenCode, Some("conflict-source"), provider_to_mutation_input(opencode_provider("conflict-target")), ) .is_err()); assert_eq!( provider_snapshot(state, "opencode", "conflict-source"), source_before ); assert_eq!( provider_snapshot(state, "opencode", "conflict-target"), target_before ); ProviderService::add( state, AppType::OpenCode, provider_to_mutation_input(opencode_provider("live-source")), true, ) .expect("live source"); let live_before = provider_snapshot(state, "opencode", "live-source"); assert!(ProviderService::update( state, AppType::OpenCode, Some("live-source"), provider_to_mutation_input(opencode_provider("live-target")), ) .is_err()); assert_eq!( provider_snapshot(state, "opencode", "live-source"), live_before ); ProviderService::add( state, AppType::OpenCode, provider_to_mutation_input(opencode_omo_provider("omo-source", "omo")), false, ) .expect("OMO source"); let omo_before = provider_snapshot(state, "opencode", "omo-source"); let mut omo_target = opencode_omo_provider("omo-target", "omo"); omo_target.name = "Forbidden OMO rename".to_string(); assert!(ProviderService::update( state, AppType::OpenCode, Some("omo-source"), provider_to_mutation_input(omo_target), ) .is_err()); assert_eq!( provider_snapshot(state, "opencode", "omo-source"), omo_before ); ProviderService::add( state, AppType::Hermes, provider_to_mutation_input(hermes_provider("hermes-source")), false, ) .expect("Hermes source"); let hermes_before = provider_snapshot(state, "hermes", "hermes-source"); assert!(ProviderService::update( state, AppType::Hermes, Some("hermes-source"), provider_to_mutation_input(hermes_provider("hermes-target")), ) .is_err()); assert_eq!( provider_snapshot(state, "hermes", "hermes-source"), hermes_before ); }); } #[test] #[serial] fn provider_service_rename_fails_closed_for_malformed_additive_live_config() { with_test_home(|state, home| { let cases = [ ( AppType::OpenCode, opencode_provider("malformed-source"), opencode_provider("malformed-target"), home.join(".config").join("opencode").join("opencode.json"), r#"{"provider":{"malformed-source":{"npm":"@ai-sdk/openai-compatible"}"#, ), ( AppType::OpenClaw, openclaw_provider("corrupt-source"), openclaw_provider("corrupt-target"), home.join(".openclaw").join("openclaw.json"), r#"{"models":{"providers":{"corrupt-target":{"baseUrl":"https://example.test"}"#, ), ]; for (app_type, source, target, live_path, malformed_live) in cases { let app_name = app_type.as_str().to_string(); let source_id = source.id.clone(); let target_id = target.id.clone(); ProviderService::add( state, app_type.clone(), provider_to_mutation_input(source), false, ) .expect("create DB-only rename source"); fs::create_dir_all(live_path.parent().expect("live config parent")) .expect("create live config directory"); fs::write(&live_path, malformed_live).expect("write malformed live config"); let source_before = provider_snapshot(state, &app_name, &source_id); let target_before = provider_snapshot(state, &app_name, &target_id); let error = ProviderService::update( state, app_type, Some(&source_id), provider_to_mutation_input(target), ) .expect_err("rename must fail closed when live identity cannot be inspected"); assert!( matches!(error, AppError::Config(_)), "rename should surface the live parse error, got {error:?}" ); assert_eq!( provider_snapshot(state, &app_name, &source_id), source_before, "source aggregate must remain unchanged" ); assert_eq!( provider_snapshot(state, &app_name, &target_id), target_before, "target aggregate must remain unchanged" ); assert_eq!( fs::read_to_string(&live_path).expect("reread malformed live config"), malformed_live, "failed rename must not rewrite the live config" ); } }); } #[test] #[serial] fn automated_row_transform_conflicts_instead_of_reverting_a_newer_edit() { with_test_home(|state, _| { let provider = opencode_provider("observed-row"); ProviderService::add( state, AppType::OpenCode, provider_to_mutation_input(provider), false, ) .expect("create observed provider"); let observed = state .db .get_provider_by_id("observed-row", "opencode") .expect("read observed provider") .expect("observed provider exists"); let fingerprint = provider_row_fingerprint(&observed); let mut stale_transform = observed.clone(); stale_transform.notes = Some("automatic transform".to_string()); remove_hydrated_endpoints_from_row_update(&mut stale_transform); let mut user_edit = observed; user_edit.name = "Concurrent user edit".to_string(); user_edit .meta .get_or_insert_with(Default::default) .custom_endpoints .clear(); ProviderService::update( state, AppType::OpenCode, None, provider_to_mutation_input(user_edit), ) .expect("persist concurrent user edit"); let error = update_provider_record_if_unchanged( state, &AppType::OpenCode, fingerprint, provider_to_mutation_input(stale_transform), ) .expect_err("stale automatic transform must conflict"); assert!(matches!(error, AppError::Conflict(_))); let saved = state .db .get_provider_by_id("observed-row", "opencode") .expect("read saved provider") .expect("saved provider exists"); assert_eq!(saved.name, "Concurrent user edit"); assert_eq!(saved.notes, None); }); } #[test] #[serial] fn rename_waits_for_shared_live_lock_and_rechecks_source_ownership() { let _test_guard = test_guard(); let _home = TempHome::new(); let db = Arc::new(Database::memory().expect("in-memory database")); let lock_owner_state = AppState::new(db.clone()); let rename_state = AppState::new(db.clone()); ProviderService::add( &lock_owner_state, AppType::OpenCode, provider_to_mutation_input(opencode_provider("race-source")), false, ) .expect("create DB-only source"); let live_guard = futures::executor::block_on( lock_owner_state .proxy_service .lock_switch_for_app(AppType::OpenCode.as_str()), ); let (started_tx, started_rx) = mpsc::channel(); let rename_thread = thread::spawn(move || { started_tx.send(()).expect("signal rename start"); ProviderService::update( &rename_state, AppType::OpenCode, Some("race-source"), provider_to_mutation_input(opencode_provider("race-target")), ) }); started_rx .recv_timeout(Duration::from_secs(1)) .expect("rename thread started"); thread::sleep(Duration::from_millis(30)); assert!( !rename_thread.is_finished(), "a distinct AppState must share the same per-app live mutation lock" ); let mut switched = db .get_provider_by_id("race-source", "opencode") .expect("read source during simulated switch") .expect("source exists"); ProviderService::set_provider_live_config_managed(&mut switched, true); remove_hydrated_endpoints_from_row_update(&mut switched); let key = ProviderKey::new("opencode", "race-source").expect("source key"); let row = ProviderRowUpdate::from_input(&provider_to_mutation_input(switched)) .expect("marker row"); db.update_provider(&key, &row) .expect("persist simulated switch marker"); drop(live_guard); let error = rename_thread .join() .expect("join rename thread") .expect_err("live-managed source must not be renamed"); assert!( matches!(&error, AppError::Conflict(_) | AppError::Message(_)), "ownership recheck should reject with a structured conflict or service error: {error}" ); assert!(db .get_provider_by_id("race-source", "opencode") .expect("read source") .is_some()); assert!(db .get_provider_by_id("race-target", "opencode") .expect("read target") .is_none()); let rename = RenameProvider::from_input( ProviderKey::new("opencode", "race-source").expect("source key"), &provider_to_mutation_input(opencode_provider("direct-target")), ) .expect("build direct rename"); assert!(matches!( db.rename_db_only_additive_provider(rename), Err(AppError::Conflict(_)) )); } #[test] #[serial] fn add_clears_usage_credentials_that_match_provider_config() { with_test_home(|state, _| { let provider = codex_provider_with_usage( "codex-a", "https://api.a.example/v1/", "sk-a", Some(" sk-a "), Some(" https://api.a.example/v1/ "), None, ); ProviderService::add( state, AppType::Codex, provider_to_mutation_input(provider), false, ) .expect("add provider"); let saved = state .db .get_provider_by_id("codex-a", AppType::Codex.as_str()) .expect("query saved provider") .expect("saved provider should exist"); let script = saved .meta .as_ref() .and_then(|meta| meta.usage_script.as_ref()) .expect("usage script should remain"); assert_eq!(script.api_key, None); assert_eq!(script.base_url, None); }); } #[test] #[serial] fn update_preserves_usage_credentials_that_only_match_previous_config() { with_test_home(|state, _| { let provider = codex_provider_with_usage( "codex-usage-old", "https://api.a.example/v1/", "sk-a", Some("sk-a"), Some("https://api.a.example/v1/"), None, ); state .db .reconcile_provider_fixture(AppType::Codex.as_str(), &provider) .expect("seed provider with explicit usage credentials"); let mut updated = provider.clone(); updated.settings_config = codex_settings("https://api.b.example/v1/", "sk-b"); ProviderService::update( state, AppType::Codex, None, provider_to_mutation_input(updated), ) .expect("update provider main credentials"); let saved = state .db .get_provider_by_id("codex-usage-old", AppType::Codex.as_str()) .expect("query updated provider") .expect("updated provider should exist"); let script = saved .meta .as_ref() .and_then(|meta| meta.usage_script.as_ref()) .expect("usage script should remain"); assert_eq!(script.api_key.as_deref(), Some("sk-a")); assert_eq!( script.base_url.as_deref(), Some("https://api.a.example/v1/") ); assert_eq!( saved.resolve_usage_credentials(&AppType::Codex), ("https://api.b.example/v1".to_string(), "sk-b".to_string()) ); }); } #[test] #[serial] fn copied_provider_uses_edited_credentials_after_add_clears_mirrored_usage_credentials() { with_test_home(|state, _| { let copied_provider = codex_provider_with_usage( "codex-copy", "https://api.a.example/v1/", "sk-a", Some("sk-a"), Some("https://api.a.example/v1/"), None, ); ProviderService::add( state, AppType::Codex, provider_to_mutation_input(copied_provider), false, ) .expect("add copied provider"); let saved_after_add = state .db .get_provider_by_id("codex-copy", AppType::Codex.as_str()) .expect("query copied provider") .expect("copied provider should exist"); let script_after_add = saved_after_add .meta .as_ref() .and_then(|meta| meta.usage_script.as_ref()) .expect("usage script should remain"); assert_eq!(script_after_add.api_key, None); assert_eq!(script_after_add.base_url, None); let mut edited_provider = saved_after_add.clone(); edited_provider.settings_config = codex_settings("https://api.b.example/v1/", "sk-b"); ProviderService::update( state, AppType::Codex, None, provider_to_mutation_input(edited_provider), ) .expect("edit copied provider credentials"); let saved_after_update = state .db .get_provider_by_id("codex-copy", AppType::Codex.as_str()) .expect("query edited provider") .expect("edited provider should exist"); let script_after_update = saved_after_update .meta .as_ref() .and_then(|meta| meta.usage_script.as_ref()) .expect("usage script should remain"); assert_eq!(script_after_update.api_key, None); assert_eq!(script_after_update.base_url, None); assert_eq!( saved_after_update.resolve_usage_credentials(&AppType::Codex), ("https://api.b.example/v1".to_string(), "sk-b".to_string()) ); }); } #[test] #[serial] fn update_clears_usage_credentials_that_match_current_config() { with_test_home(|state, _| { let provider = codex_provider_with_usage( "codex-current", "https://api.a.example/v1", "sk-a", Some("sk-usage"), Some("https://usage.example/api"), None, ); state .db .reconcile_provider_fixture(AppType::Codex.as_str(), &provider) .expect("seed provider with distinct usage credentials"); let mut updated = provider.clone(); updated.settings_config = codex_settings("https://api.b.example/v1/", "sk-b"); updated.meta = Some(ProviderMeta { usage_script: Some(usage_script_with_credentials( Some(" sk-b "), Some(" https://api.b.example/v1/ "), None, )), ..Default::default() }); ProviderService::update( state, AppType::Codex, None, provider_to_mutation_input(updated), ) .expect("update provider with redundant usage credentials"); let saved = state .db .get_provider_by_id("codex-current", AppType::Codex.as_str()) .expect("query updated provider") .expect("updated provider should exist"); let script = saved .meta .as_ref() .and_then(|meta| meta.usage_script.as_ref()) .expect("usage script should remain"); assert_eq!(script.api_key, None); assert_eq!(script.base_url, None); }); } #[test] #[serial] fn add_preserves_distinct_usage_credentials() { with_test_home(|state, _| { let provider = codex_provider_with_usage( "codex-distinct", "https://api.main.example/v1", "sk-main", Some("sk-usage"), Some("https://usage.example/api"), None, ); ProviderService::add( state, AppType::Codex, provider_to_mutation_input(provider), false, ) .expect("add provider"); let saved = state .db .get_provider_by_id("codex-distinct", AppType::Codex.as_str()) .expect("query saved provider") .expect("saved provider should exist"); let script = saved .meta .as_ref() .and_then(|meta| meta.usage_script.as_ref()) .expect("usage script should remain"); assert_eq!(script.api_key.as_deref(), Some("sk-usage")); assert_eq!( script.base_url.as_deref(), Some("https://usage.example/api") ); }); } #[test] #[serial] fn add_does_not_clear_token_plan_credentials() { with_test_home(|state, _| { let provider = codex_provider_with_usage( "codex-token-plan", "https://api.plan.example/v1", "sk-plan", Some("sk-plan"), Some("https://api.plan.example/v1"), Some("token_plan"), ); ProviderService::add( state, AppType::Codex, provider_to_mutation_input(provider), false, ) .expect("add provider"); let saved = state .db .get_provider_by_id("codex-token-plan", AppType::Codex.as_str()) .expect("query saved provider") .expect("saved provider should exist"); let script = saved .meta .as_ref() .and_then(|meta| meta.usage_script.as_ref()) .expect("usage script should remain"); assert_eq!(script.api_key.as_deref(), Some("sk-plan")); assert_eq!( script.base_url.as_deref(), Some("https://api.plan.example/v1") ); assert_eq!(script.access_key_id.as_deref(), Some("ak-test")); assert_eq!(script.secret_access_key.as_deref(), Some("sk-test")); }); } #[test] fn validate_provider_settings_rejects_missing_auth() { let provider = Provider::with_id( "codex".into(), "Codex".into(), json!({ "config": "base_url = \"https://example.com\"" }), None, ); let err = ProviderService::validate_provider_settings(&AppType::Codex, &provider) .expect_err("missing auth should be rejected"); assert!( err.to_string().contains("auth"), "expected auth error, got {err:?}" ); } #[test] fn extract_gemini_common_config_strips_credentials_keeps_shareable() { // Gemini 的共享片段会被 deep-merge 回**其它** Gemini 供应商的 env // (live.rs::apply_common_config_to_settings),因此任何凭据都不得进入片段。 // 之前这里只硬编码跳过 GEMINI_API_KEY/GOOGLE_GEMINI_BASE_URL,而 // GOOGLE_API_KEY 是 provider.rs 认可的一等 Gemini 凭据 → 会泄露到别的供应商。 let settings = json!({ "env": { "GEMINI_API_KEY": "g-gem", "GOOGLE_API_KEY": "g-legacy-real-key", "GOOGLE_GEMINI_BASE_URL": "https://gemini.example", "GOOGLE_APPLICATION_CREDENTIALS": "/path/creds.json", "SOME_PROXY_AUTH_TOKEN": "tok-proxy", // 可共享的非机密配置必须保留 "GEMINI_TIMEOUT_MS": "30000" } }); let snippet = ProviderService::extract_gemini_common_config(&settings).expect("extract should work"); let value: Value = serde_json::from_str(&snippet).expect("snippet is valid JSON"); for leaked in [ "GEMINI_API_KEY", "GOOGLE_API_KEY", "GOOGLE_APPLICATION_CREDENTIALS", "SOME_PROXY_AUTH_TOKEN", ] { assert!( value.get(leaked).is_none(), "credential {leaked} must not leak into the shared Gemini snippet" ); } assert_eq!( value.get("GEMINI_TIMEOUT_MS").and_then(|v| v.as_str()), Some("30000"), "shareable non-secret config must be preserved" ); } /// 造一个「已被污染」的现场:片段里带 A 账号的凭据 + 一个合法可共享键。 #[test] fn sensitive_key_matcher_covers_common_credential_namings() { for key in [ // 裸 `_KEY`:最常见的写法,却曾被"只枚举 `_API_KEY` 这些子类"漏在外面 "OPENAI_KEY", "GROQ_KEY", "XAI_KEY", // 不带分隔符的复合写法 "VOLC_ACCESSKEY", "ALIYUN_SECRETKEY", "SOME_APITOKEN", // personal access token:既不含 TOKEN 也不含 KEY "GITHUB_PAT", "gitlab_pat", // 口令类缩写 "MYSQL_PWD", "DB_PASS", "GPG_PASSPHRASE", "AWS_CREDS", ] { assert!( ProviderService::is_sensitive_config_key(key), "{key} must be treated as a credential" ); } // 后缀必须带下划线,不能把正常配置一起卷进来 for key in [ "PATH", "OLDPWD", "GEMINI_COMPAT", "SSL_BYPASS", "GEMINI_TIMEOUT_MS", "CLAUDE_CODE_MAX_OUTPUT_TOKENS", ] { assert!( !ProviderService::is_sensitive_config_key(key), "{key} is ordinary shareable config and must not be stripped" ); } } fn seed_leaked_gemini_state(db: &Arc) { db.set_config_snippet( "gemini", Some( json!({ "GOOGLE_API_KEY": "key-A-leaked", "SOME_PROXY_AUTH_TOKEN": "tok-A-leaked", "GEMINI_TIMEOUT_MS": "30000" }) .to_string(), ), ) .expect("seed snippet"); // 受害者 B:泄漏的密钥已经被合并进它的 env let victim = Provider::with_id( "b".into(), "Relay B".into(), json!({ "env": { "GOOGLE_GEMINI_BASE_URL": "https://relay-b.example", "GOOGLE_API_KEY": "key-A-leaked", "GEMINI_TIMEOUT_MS": "30000" }}), None, ); db.reconcile_provider_fixture("gemini", &victim) .expect("save victim"); // 供应商 C:自己写了同名键但值不同,不能被误删 let unrelated = Provider::with_id( "c".into(), "Own Key C".into(), json!({ "env": { "GOOGLE_GEMINI_BASE_URL": "https://c.example", "GOOGLE_API_KEY": "key-C-owned" }}), None, ); db.reconcile_provider_fixture("gemini", &unrelated) .expect("save c"); } #[tokio::test] #[serial] async fn scrub_gemini_removes_leaked_credentials_from_snippet_and_providers() { 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()); seed_leaked_gemini_state(&db); ProviderService::scrub_leaked_gemini_common_config(&state) .await .expect("scrub must succeed"); // 片段:凭据清掉,可共享配置保留 let snippet = db .get_config_snippet("gemini") .expect("read snippet") .expect("snippet must still exist"); let snippet: Value = serde_json::from_str(&snippet).expect("valid json"); assert!(snippet.get("GOOGLE_API_KEY").is_none()); assert!(snippet.get("SOME_PROXY_AUTH_TOKEN").is_none()); assert_eq!( snippet.get("GEMINI_TIMEOUT_MS").and_then(Value::as_str), Some("30000"), "shareable config must survive the scrub" ); // 受害者 B:扩散过去的那一份被清掉 let providers = db.get_all_providers("gemini").expect("providers"); let victim_env = &providers["b"].settings_config["env"]; assert!( victim_env.get("GOOGLE_API_KEY").is_none(), "leaked key must be removed from the victim provider" ); assert_eq!( victim_env.get("GEMINI_TIMEOUT_MS").and_then(Value::as_str), Some("30000"), "non-credential config must not be touched" ); } #[tokio::test] #[serial] async fn scrub_gemini_keeps_a_providers_own_differently_valued_key() { 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()); seed_leaked_gemini_state(&db); ProviderService::scrub_leaked_gemini_common_config(&state) .await .expect("scrub must succeed"); // 这条最容易写错成「按键名一刀切」:C 自己的密钥值与片段不同,是它自己的凭据 let providers = db.get_all_providers("gemini").expect("providers"); assert_eq!( providers["c"].settings_config["env"] .get("GOOGLE_API_KEY") .and_then(Value::as_str), Some("key-C-owned"), "a provider's own key must not be deleted by name matching" ); } #[tokio::test] #[serial] async fn scrub_gemini_audit_records_key_names_but_never_values() { 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()); seed_leaked_gemini_state(&db); ProviderService::scrub_leaked_gemini_common_config(&state) .await .expect("scrub must succeed"); let audit_text = db .get_setting("gemini_common_config_scrub_audit_v1") .expect("read audit") .expect("an audit record must exist so the deletion is not silent"); // 值绝不能进这条记录:`settings` 会随 WebDAV/S3 同步上传,留值等于把一次 // 清除换成一份跨设备扩散、没有界面入口、永不过期的明文副本。 assert!( !audit_text.contains("key-A-leaked") && !audit_text.contains("tok-A-leaked"), "the audit record must never carry credential values: {audit_text}" ); // 但必须说清楚删了什么、从哪删的,否则用户只能靠翻日志 let audit: Value = serde_json::from_str(&audit_text).expect("audit is JSON"); let removed: Vec<&str> = audit["removedFromSnippet"] .as_array() .expect("removedFromSnippet array") .iter() .filter_map(Value::as_str) .collect(); assert!( removed.contains(&"GOOGLE_API_KEY") && removed.contains(&"SOME_PROXY_AUTH_TOKEN"), "every key removed from the snippet must be named: {audit}" ); let victim = audit["providers"] .as_array() .expect("providers array") .iter() .find(|entry| entry["id"] == json!("b")) .expect("every provider whose config gets rewritten must be recorded"); assert_eq!( victim["removedKeys"], json!(["GOOGLE_API_KEY"]), "the record must name what was taken from each provider: {audit}" ); } #[tokio::test] #[serial] async fn scrub_gemini_never_overwrites_an_existing_audit_record() { 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()); seed_leaked_gemini_state(&db); // 上一轮改到一半就中止的情形:完成标记没置位,下次启动会重跑,但那时 // 读到的"原始状态"已经残缺。无条件覆盖会拿残缺记录盖掉第一轮那份完整的。 db.set_setting( "gemini_common_config_scrub_audit_v1", "{\"from\":\"an earlier, complete run\"}", ) .expect("seed an existing audit record"); ProviderService::scrub_leaked_gemini_common_config(&state) .await .expect("scrub must succeed"); assert_eq!( db.get_setting("gemini_common_config_scrub_audit_v1") .expect("read audit") .as_deref(), Some("{\"from\":\"an earlier, complete run\"}"), "an audit record from an earlier run must survive a retry" ); } #[tokio::test] #[serial] async fn scrub_gemini_cleans_the_live_env_without_a_current_provider() { 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()); seed_leaked_gemini_state(&db); // 没有当前供应商——这正是 sync_current_provider_for_app 直接返回 Ok 而 // 根本不写文件的分支。此时 live 若清不掉,片段又已被清空,下次切换的 // backfill 就会把残留永久写进受害供应商的配置。 crate::gemini_config::write_gemini_env_atomic(&HashMap::from([ ("GOOGLE_API_KEY".to_string(), "key-A-leaked".to_string()), ("GEMINI_TIMEOUT_MS".to_string(), "30000".to_string()), // 只存在于 live 的手工修改:定向删除必须保住它,全量重投影会抹掉 ( "HTTPS_PROXY".to_string(), "http://127.0.0.1:7890".to_string(), ), ])) .expect("seed live env"); ProviderService::scrub_leaked_gemini_common_config(&state) .await .expect("scrub must succeed"); let live = crate::gemini_config::read_gemini_env().expect("read live env"); assert!( !live.contains_key("GOOGLE_API_KEY"), "the leaked credential must be gone from ~/.gemini/.env: {live:?}" ); assert_eq!( live.get("HTTPS_PROXY").map(String::as_str), Some("http://127.0.0.1:7890"), "a hand-added live-only var must survive targeted removal: {live:?}" ); } #[tokio::test] #[serial] async fn scrub_gemini_live_cleanup_preserves_the_rest_of_the_env_file() { 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()); seed_leaked_gemini_state(&db); // 这是一次用户没主动触发的启动期清理,不该顺手重写与泄漏无关的内容。 // read→HashMap→write 的往返会把注释、空行、无法识别的行全丢掉并按键名重排。 let original = "\ # my own notes GOOGLE_API_KEY=key-C-owned GOOGLE_API_KEY=key-A-leaked this line is not KEY=VALUE at all GEMINI_TIMEOUT_MS=30000 "; crate::gemini_config::write_gemini_env_text_atomic(original).expect("seed live env"); ProviderService::scrub_leaked_gemini_common_config(&state) .await .expect("scrub must succeed"); let raw = std::fs::read_to_string(crate::gemini_config::get_gemini_env_path()) .expect("read live env"); assert!( !raw.contains("key-A-leaked"), "the leaked line must be gone: {raw:?}" ); assert!( raw.contains("# my own notes"), "comments must survive a targeted removal: {raw:?}" ); assert!( raw.contains("this line is not KEY=VALUE at all"), "unparseable lines must survive a targeted removal: {raw:?}" ); // 被泄漏值遮住的那条重新生效——正是想要的结果,遮住它的恰恰是泄漏值 assert_eq!( crate::gemini_config::read_gemini_env() .expect("read live env") .get("GOOGLE_API_KEY") .map(String::as_str), Some("key-C-owned"), "only the matching line may be dropped: {raw:?}" ); } #[tokio::test] #[serial] async fn scrub_gemini_aborts_before_clearing_the_snippet_when_the_live_backup_fails() { 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()); seed_leaked_gemini_state(&db); // 关代理时这份快照会被原样写回 live。若清不动它却照样清了片段、置了完成标记, // 代理一停凭据就复活,而一次性标记保证不会再清第二次。 db.save_live_backup("gemini", "}not json{") .await .expect("seed backup"); let result = ProviderService::scrub_leaked_gemini_common_config(&state).await; assert!( result.is_err(), "a backup that cannot be cleaned must abort the scrub" ); // 片段是「该剥哪些键」的唯一知识来源,中止后必须原样留着,否则下次重试 // 会因为 poison 为空而直接短路,反倒把标记置上 let snippet = db .get_config_snippet("gemini") .expect("read snippet") .expect("snippet must still exist"); assert!( snippet.contains("key-A-leaked"), "the snippet must be left intact so the next boot can retry: {snippet}" ); assert!( db.get_setting("gemini_common_config_credentials_scrubbed_v1") .expect("read flag") .is_none(), "the one-shot flag must not be set when the scrub aborted" ); } #[tokio::test] #[serial] async fn scrub_gemini_leaves_no_residue_for_backfill_to_persist() { 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()); seed_leaked_gemini_state(&db); ProviderService::scrub_leaked_gemini_common_config(&state) .await .expect("scrub must succeed"); // 顺序陷阱回归:如果只清了片段,切走供应商时 remove_common_config_from_settings // 就不再认识这个键,live 里的残留会被 backfill 永久写进供应商配置。 // 清理必须是原子的——清完之后,任何地方都不该再有那个值。 let snippet = db .get_config_snippet("gemini") .expect("read snippet") .unwrap_or_default(); assert!(!snippet.contains("key-A-leaked")); for (id, provider) in db.get_all_providers("gemini").expect("providers") { assert!( !provider .settings_config .to_string() .contains("key-A-leaked"), "provider '{id}' still carries the leaked value" ); } } #[tokio::test] #[serial] async fn scrub_gemini_is_idempotent_and_skips_on_second_run() { 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()); seed_leaked_gemini_state(&db); ProviderService::scrub_leaked_gemini_common_config(&state) .await .expect("first run"); // 第二次必须是 no-op:用户清理后重新填的凭据不能被再抹一遍 db.set_config_snippet( "gemini", Some(json!({"GOOGLE_API_KEY": "restored"}).to_string()), ) .expect("user re-adds a value"); ProviderService::scrub_leaked_gemini_common_config(&state) .await .expect("second run"); let snippet = db .get_config_snippet("gemini") .expect("read snippet") .expect("snippet exists"); assert!( snippet.contains("restored"), "the one-shot flag must prevent a second scrub: {snippet}" ); } #[test] fn extract_claude_common_config_strips_all_credentials_keeps_shareable() { // env 混入多种凭据(Anthropic/OpenRouter/Google/OpenAI/Gemini + AWS/Vertex) // 与可共享配置;顶层混入非标准的 apiKey/api_key 凭据与正常设置。 let settings = json!({ "env": { "ANTHROPIC_API_KEY": "sk-ant", "ANTHROPIC_AUTH_TOKEN": "tok-ant", "OPENROUTER_API_KEY": "sk-or", "GOOGLE_API_KEY": "g-key", "OPENAI_API_KEY": "sk-oai", "GEMINI_API_KEY": "g-gem", "AWS_ACCESS_KEY_ID": "AKIA", "AWS_SECRET_ACCESS_KEY": "secret", "AWS_SESSION_TOKEN": "sess", "GOOGLE_APPLICATION_CREDENTIALS": "/path/creds.json", "AWS_BEARER_TOKEN_BEDROCK": "bedrock-tok", "ANTHROPIC_BASE_URL": "https://example.com", "ANTHROPIC_MODEL": "claude-x", "CLAUDE_CODE_SUBAGENT_MODEL": "gpt-5.4-mini", "CLAUDE_CODE_MAX_CONTEXT_TOKENS": "400000", "CLAUDE_CODE_AUTO_COMPACT_WINDOW": "400000", // 可共享、非机密配置(复数 _TOKENS 不应被误剥) "ENABLE_TOOL_SEARCH": "true", "CLAUDE_CODE_MAX_OUTPUT_TOKENS": "8192" }, "apiKey": "sk-top", "api_key": "sk-top2", "theme": "dark", "includeCoAuthoredBy": false }); let snippet = ProviderService::extract_claude_common_config(&settings) .expect("extract should succeed"); let value: Value = serde_json::from_str(&snippet).expect("snippet is valid JSON"); // 所有凭据都不得出现在共享片段里 let env = value.get("env"); for leaked in [ "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "OPENROUTER_API_KEY", "GOOGLE_API_KEY", "OPENAI_API_KEY", "GEMINI_API_KEY", "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN", "GOOGLE_APPLICATION_CREDENTIALS", "AWS_BEARER_TOKEN_BEDROCK", ] { assert!( env.and_then(|e| e.get(leaked)).is_none(), "credential {leaked} must not leak into common config" ); } assert!( value.get("apiKey").is_none() && value.get("api_key").is_none(), "top-level credentials must be stripped" ); // 端点/模型(provider-specific 非机密)也应剥掉 assert!(env.and_then(|e| e.get("ANTHROPIC_BASE_URL")).is_none()); assert!(env.and_then(|e| e.get("ANTHROPIC_MODEL")).is_none()); assert!(env .and_then(|e| e.get("CLAUDE_CODE_SUBAGENT_MODEL")) .is_none()); assert!(env .and_then(|e| e.get("CLAUDE_CODE_MAX_CONTEXT_TOKENS")) .is_none()); assert!(env .and_then(|e| e.get("CLAUDE_CODE_AUTO_COMPACT_WINDOW")) .is_none()); // 可共享的非机密配置必须保留(含复数 _TOKENS 不被误剥) assert_eq!( env.and_then(|e| e.get("ENABLE_TOOL_SEARCH")) .and_then(|v| v.as_str()), Some("true") ); assert_eq!( env.and_then(|e| e.get("CLAUDE_CODE_MAX_OUTPUT_TOKENS")) .and_then(|v| v.as_str()), Some("8192") ); assert_eq!(value.get("theme").and_then(|v| v.as_str()), Some("dark")); assert_eq!(value.get("includeCoAuthoredBy"), Some(&json!(false))); } /// Regression for issue #4272: Fable tier env keys must not enter the shared /// Claude common-config snippet (same class as haiku/sonnet/opus model pins). #[test] fn extract_claude_common_config_strips_fable_model_env_keys() { let settings = json!({ "env": { "ANTHROPIC_DEFAULT_HAIKU_MODEL": "haiku-mapped", "ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME": "Haiku Mapped", "ANTHROPIC_DEFAULT_SONNET_MODEL": "sonnet-mapped[1M]", "ANTHROPIC_DEFAULT_SONNET_MODEL_NAME": "Sonnet Mapped", "ANTHROPIC_DEFAULT_OPUS_MODEL": "opus-mapped[1M]", "ANTHROPIC_DEFAULT_OPUS_MODEL_NAME": "Opus Mapped", "ANTHROPIC_DEFAULT_FABLE_MODEL": "deepseek-v4-flash[1M]", "ANTHROPIC_DEFAULT_FABLE_MODEL_NAME": "deepseek-v4-flash", "ANTHROPIC_MODEL": "default-mapped", "ENABLE_TOOL_SEARCH": "true" }, "theme": "dark" }); let snippet = ProviderService::extract_claude_common_config(&settings) .expect("extract should succeed"); let value: Value = serde_json::from_str(&snippet).expect("snippet is valid JSON"); let env = value.get("env"); for stripped in [ "ANTHROPIC_DEFAULT_HAIKU_MODEL", "ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME", "ANTHROPIC_DEFAULT_SONNET_MODEL", "ANTHROPIC_DEFAULT_SONNET_MODEL_NAME", "ANTHROPIC_DEFAULT_OPUS_MODEL", "ANTHROPIC_DEFAULT_OPUS_MODEL_NAME", "ANTHROPIC_DEFAULT_FABLE_MODEL", "ANTHROPIC_DEFAULT_FABLE_MODEL_NAME", "ANTHROPIC_MODEL", ] { assert!( env.and_then(|e| e.get(stripped)).is_none(), "provider-specific model key {stripped} must not enter common config" ); } assert_eq!( env.and_then(|e| e.get("ENABLE_TOOL_SEARCH")) .and_then(|v| v.as_str()), Some("true") ); assert_eq!(value.get("theme").and_then(|v| v.as_str()), Some("dark")); } #[test] fn validate_provider_settings_rejects_negative_cost_multiplier() { let mut provider = Provider::with_id( "claude".into(), "Claude".into(), json!({ "env": { "ANTHROPIC_AUTH_TOKEN": "token", "ANTHROPIC_BASE_URL": "https://claude.example" } }), None, ); provider.meta = Some(ProviderMeta { cost_multiplier: Some("-1".to_string()), ..ProviderMeta::default() }); let err = ProviderService::validate_provider_settings(&AppType::Claude, &provider) .expect_err("negative multiplier should be rejected"); assert!(matches!( err, AppError::Localized { key: "error.invalidMultiplier", .. } )); } #[test] fn extract_credentials_returns_expected_values() { let provider = Provider::with_id( "claude".into(), "Claude".into(), json!({ "env": { "ANTHROPIC_AUTH_TOKEN": "token", "ANTHROPIC_BASE_URL": "https://claude.example" } }), None, ); let (api_key, base_url) = ProviderService::extract_credentials(&provider, &AppType::Claude).unwrap(); assert_eq!(api_key, "token"); assert_eq!(base_url, "https://claude.example"); } #[test] fn extract_codex_common_config_strips_provider_fields_and_injected_artifacts() { // 顶层 experimental_bearer_token 模拟无活跃路由时的 fallback 注入; // web_search = "disabled" 是 cc-switch 对黑名单网关注入的哨兵; // 顶层 wire_api 模拟无 model_provider 时的 fallback 写法; // [mcp.servers] 是历史错误格式,sync_all_enabled 清不掉它。 let config_toml = r#"model_provider = "azure" model = "gpt-4" wire_api = "chat" disable_response_storage = true experimental_bearer_token = "sk-live-secret" model_catalog_json = "cc-switch-model-catalog.json" web_search = "disabled" [model_providers.azure] name = "Azure OpenAI" base_url = "https://azure.example/v1" wire_api = "responses" [mcp_servers.my_server] base_url = "http://localhost:8080" [mcp.servers.legacy_server] command = "legacy-cmd" "#; let settings = json!({ "config": config_toml }); let extracted = ProviderService::extract_codex_common_config(&settings) .expect("extract_codex_common_config should succeed"); assert!( !extracted .lines() .any(|line| line.trim_start().starts_with("model_provider")), "should remove top-level model_provider" ); assert!( !extracted .lines() .any(|line| line.trim_start().starts_with("model =")), "should remove top-level model" ); assert!( !extracted.contains("[model_providers"), "should remove entire model_providers table" ); // MCP 归 DB mcp_servers 表所有,不得进共享片段(含历史错误格式 [mcp.servers]) assert!( !extracted.contains("mcp_servers") && !extracted.contains("http://localhost:8080"), "should strip mcp_servers from the shared snippet, got: {extracted}" ); assert!( !extracted.contains("[mcp") && !extracted.contains("legacy-cmd"), "should strip the legacy [mcp.servers] form from the shared snippet, got: {extracted}" ); // 顶层 wire_api 是供应商路由语义(model_providers 整表已剥, // 剩余任何 wire_api 都意味着泄漏) assert!( !extracted.contains("wire_api"), "should strip top-level wire_api from the shared snippet, got: {extracted}" ); // 注入产物不得进共享片段(bearer token 泄漏为密钥级问题) assert!( !extracted.contains("experimental_bearer_token") && !extracted.contains("sk-live-secret"), "should strip top-level fallback bearer token, got: {extracted}" ); assert!( !extracted.contains("model_catalog_json"), "should strip catalog projection pointer, got: {extracted}" ); assert!( !extracted.contains("web_search"), "should strip the cc-switch web_search disabled sentinel, got: {extracted}" ); // 真正可共享的键保留 assert!( extracted.contains("disable_response_storage = true"), "shareable keys must survive extraction, got: {extracted}" ); } #[test] fn extract_codex_common_config_keeps_user_set_web_search() { let config_toml = "web_search = \"enabled\"\ndisable_response_storage = true\n"; let settings = json!({ "config": config_toml }); let extracted = ProviderService::extract_codex_common_config(&settings) .expect("extract should succeed"); assert!( extracted.contains("web_search = \"enabled\""), "a user-set web_search value is a shareable preference, got: {extracted}" ); } #[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.reconcile_provider_fixture("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, listen_port: 0, ..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"); let proxy_info = 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, provider_to_mutation_input(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(format!("http://127.0.0.1:{}", proxy_info.port).as_str()), "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" ); } #[tokio::test] #[serial] async fn update_current_codex_provider_refreshes_and_clears_catalog_during_takeover() { 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 mut original = Provider::with_id( "p1".into(), "Codex A".into(), json!({ "auth": { "OPENAI_API_KEY": "token-a" }, "config": r#"model_provider = "custom" model = "old-model" [model_providers.custom] name = "Codex A" base_url = "https://api.a.example/v1" wire_api = "responses" requires_openai_auth = true "#, "modelCatalog": { "models": [{ "model": "old-model" }] } }), None, ); original.meta = Some(ProviderMeta { api_format: Some("openai_responses".into()), ..Default::default() }); db.reconcile_provider_fixture("codex", &original) .expect("save provider"); db.set_current_provider("codex", "p1") .expect("set current provider"); crate::settings::set_current_provider(&AppType::Codex, Some("p1")) .expect("set local current provider"); db.update_proxy_config(ProxyConfig { live_takeover_active: true, listen_port: 0, ..Default::default() }) .await .expect("update proxy config"); { let mut config = db .get_proxy_config_for_app("codex") .await .expect("get app proxy config"); config.enabled = true; db.update_proxy_config_for_app(config) .await .expect("enable Codex proxy config"); } db.save_live_backup( "codex", &serde_json::to_string(&original.settings_config).expect("serialize backup"), ) .await .expect("seed live backup"); state .proxy_service .start() .await .expect("start proxy service"); state .proxy_service .sync_codex_live_from_provider_while_proxy_active(&original) .await .expect("seed taken-over Codex live config"); assert!( state .proxy_service .detect_takeover_in_live_config_for_app(&AppType::Codex), "seeded Codex live config should be recognized as takeover-owned" ); let mut updated = original.clone(); updated.settings_config["config"] = json!( r#"model_provider = "custom" model = "gpt-5.4" [model_providers.custom] name = "Codex A" base_url = "https://api.updated.example/v1" wire_api = "responses" requires_openai_auth = true "# ); updated.settings_config["modelCatalog"] = json!({ "models": [{ "model": "gpt-5.4", "displayName": "GPT 5.4" }] }); ProviderService::update( &state, AppType::Codex, None, provider_to_mutation_input(updated.clone()), ) .expect("update current Codex provider mapping"); let catalog_path = crate::codex_config::get_codex_model_catalog_path(); let catalog: Value = read_json_file(&catalog_path).expect("read generated catalog"); assert_eq!(catalog["models"][0]["slug"], "gpt-5.4"); assert_eq!( catalog["models"][0]["input_modalities"], json!(["text", "image"]), "unknown/GPT models must fail open to image input" ); let live_config = fs::read_to_string(crate::codex_config::get_codex_config_path()) .expect("read Codex config.toml"); assert!(live_config.contains("model_catalog_json")); updated.settings_config["modelCatalog"] = json!({ "models": [] }); ProviderService::update( &state, AppType::Codex, None, provider_to_mutation_input(updated), ) .expect("remove current Codex provider mapping"); let live_config = fs::read_to_string(crate::codex_config::get_codex_config_path()) .expect("read Codex config.toml after mapping removal"); assert!( !live_config.contains("model_catalog_json"), "removing mappings during takeover must clear the stale catalog pointer" ); state .proxy_service .stop() .await .expect("stop proxy service"); } #[cfg(any(target_os = "macos", windows))] #[tokio::test] #[serial] async fn update_current_claude_desktop_provider_syncs_profile_when_proxy_takeover_is_active() { 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 mut original = Provider::with_id( "p1".into(), "Desktop A".into(), json!({ "env": { "ANTHROPIC_AUTH_TOKEN": "token-a", "ANTHROPIC_BASE_URL": "https://opencode.ai/zen/go" } }), None, ); original.meta = Some(ProviderMeta { api_format: Some("openai_chat".into()), claude_desktop_mode: Some(ClaudeDesktopMode::Proxy), claude_desktop_model_routes: std::collections::HashMap::from([( "claude-sonnet-4-6".into(), ClaudeDesktopModelRoute { model: "deepseek-v4-flash".into(), label_override: Some("DeepSeek V4 Flash".into()), supports_1m: None, }, )]), ..Default::default() }); db.reconcile_provider_fixture("claude-desktop", &original) .expect("save provider"); db.set_current_provider("claude-desktop", "p1") .expect("set current provider"); crate::settings::set_current_provider(&AppType::ClaudeDesktop, Some("p1")) .expect("set local current provider"); // Claude Desktop keeps backup state from takeover startup; this sentinel only // marks takeover as active so provider updates rewrite the 3P profile. db.save_live_backup("claude-desktop", "{}") .await .expect("seed live backup"); { let mut config = db .get_proxy_config_for_app("claude-desktop") .await .expect("get app proxy config"); config.enabled = true; db.update_proxy_config_for_app(config) .await .expect("update app proxy config"); } state .proxy_service .start() .await .expect("start proxy service"); let mut updated = Provider::with_id( "p1".into(), "Desktop A".into(), json!({ "env": { "ANTHROPIC_AUTH_TOKEN": "token-updated", "ANTHROPIC_BASE_URL": "https://opencode.ai/zen/go" } }), None, ); updated.meta = Some(ProviderMeta { api_format: Some("openai_chat".into()), claude_desktop_mode: Some(ClaudeDesktopMode::Proxy), claude_desktop_model_routes: std::collections::HashMap::from([( "claude-sonnet-4-6".into(), ClaudeDesktopModelRoute { model: "deepseek-v4-flash".into(), label_override: Some("DeepSeek V4 Flash Updated".into()), supports_1m: Some(true), }, )]), ..Default::default() }); ProviderService::update(&state, AppType::ClaudeDesktop, None, updated.clone()) .expect("update current provider"); let backup = db .get_live_backup("claude-desktop") .await .expect("get live backup") .expect("backup exists"); assert_eq!( backup.original_config, "{}", "Claude Desktop provider edits should not rewrite takeover backup" ); let profile_path = claude_desktop_profile_path(home.dir.path()); let profile: Value = read_json_file(&profile_path).expect("read desktop profile"); assert_eq!( profile["inferenceGatewayBaseUrl"], json!("http://127.0.0.1:15721/claude-desktop"), "desktop profile should stay pointed at the local gateway during takeover" ); assert_eq!(profile["inferenceGatewayAuthScheme"], json!("bearer")); assert_eq!( profile["inferenceModels"], json!([{ "name": "claude-sonnet-4-6", "labelOverride": "DeepSeek V4 Flash Updated", "supports1m": true }]), "provider edits should propagate into the Claude Desktop 3P profile during takeover" ); } #[test] #[serial] fn rename_rejects_missing_original_provider() { with_test_home(|state, _| { let original = openclaw_provider("deepseek"); ProviderService::add( state, AppType::OpenClaw, provider_to_mutation_input(original.clone()), false, ) .expect("seed db-only provider"); let mut renamed = original.clone(); renamed.id = "deepseek-copy".to_string(); let err = ProviderService::update( state, AppType::OpenClaw, Some("missing-provider"), provider_to_mutation_input(renamed), ) .expect_err("stale originalId should be rejected"); assert!( err.to_string().contains("Original provider"), "expected missing original provider error, got {err:?}" ); assert!( state .db .get_provider_by_id("deepseek-copy", AppType::OpenClaw.as_str()) .expect("query renamed provider") .is_none(), "rename must not create a new row when originalId is stale" ); }); } #[test] #[serial] fn db_only_additive_update_survives_live_config_parse_errors() { with_test_home(|state, home| { let provider = openclaw_provider("deepseek"); ProviderService::add( state, AppType::OpenClaw, provider_to_mutation_input(provider.clone()), false, ) .expect("seed db-only provider"); let stored = state .db .get_provider_by_id("deepseek", AppType::OpenClaw.as_str()) .expect("query stored provider") .expect("provider should exist"); assert_eq!( stored .meta .as_ref() .and_then(|meta| meta.live_config_managed), Some(false), "db-only provider should be marked as not live-managed" ); let openclaw_dir = home.join(".openclaw"); fs::create_dir_all(&openclaw_dir).expect("create openclaw dir"); fs::write(openclaw_dir.join("openclaw.json"), "{ invalid json5") .expect("write malformed config"); let mut updated = stored.clone(); updated.name = "DeepSeek Edited".to_string(); updated.meta.get_or_insert_with(ProviderMeta::default); ProviderService::update( state, AppType::OpenClaw, None, provider_to_mutation_input(updated), ) .expect("db-only update should ignore live parse errors"); let saved = state .db .get_provider_by_id("deepseek", AppType::OpenClaw.as_str()) .expect("query updated provider") .expect("updated provider should exist"); assert_eq!(saved.name, "DeepSeek Edited"); }); } #[test] #[serial] fn sync_current_provider_for_app_skips_db_only_opencode_provider() { with_test_home(|state, _| { let provider = opencode_provider("db-only-opencode"); ProviderService::add( state, AppType::OpenCode, provider_to_mutation_input(provider.clone()), false, ) .expect("seed db-only opencode provider"); ProviderService::sync_current_provider_for_app(state, AppType::OpenCode) .expect("sync additive opencode providers"); let live_providers = crate::opencode_config::get_providers() .expect("read opencode providers after sync"); assert!( !live_providers.contains_key(&provider.id), "db-only opencode provider should not be written to live during sync" ); }); } #[test] #[serial] fn sync_current_provider_for_app_skips_db_only_openclaw_provider() { with_test_home(|state, _| { let provider = openclaw_provider("db-only-openclaw"); ProviderService::add( state, AppType::OpenClaw, provider_to_mutation_input(provider.clone()), false, ) .expect("seed db-only openclaw provider"); ProviderService::sync_current_provider_for_app(state, AppType::OpenClaw) .expect("sync additive openclaw providers"); let live_providers = crate::openclaw_config::get_providers() .expect("read openclaw providers after sync"); assert!( !live_providers.contains_key(&provider.id), "db-only openclaw provider should not be written to live during sync" ); }); } #[test] #[serial] fn sync_current_provider_for_app_preserves_legacy_live_opencode_provider() { with_test_home(|state, _| { let provider = opencode_provider("legacy-opencode"); crate::opencode_config::set_provider(&provider.id, provider.settings_config.clone()) .expect("seed opencode live provider"); state .db .reconcile_provider_fixture(AppType::OpenCode.as_str(), &provider) .expect("seed legacy opencode provider in db"); let mut updated = provider.clone(); updated.settings_config["options"]["apiKey"] = Value::String("updated-key".to_string()); state .db .reconcile_provider_fixture(AppType::OpenCode.as_str(), &updated) .expect("update legacy opencode provider in db"); ProviderService::sync_current_provider_for_app(state, AppType::OpenCode) .expect("sync legacy opencode provider"); let live_providers = crate::opencode_config::get_providers().expect("read opencode providers"); assert_eq!( live_providers .get(&provider.id) .and_then(|config| config.get("options")) .and_then(|options| options.get("apiKey")), Some(&Value::String("updated-key".to_string())), "legacy provider that already exists in live should still be synced" ); }); } #[test] #[serial] fn sync_current_provider_for_app_restores_legacy_opencode_provider_after_live_reset() { with_test_home(|state, _| { let provider = opencode_provider("legacy-opencode-reset"); state .db .reconcile_provider_fixture(AppType::OpenCode.as_str(), &provider) .expect("seed legacy opencode provider in db"); ProviderService::sync_current_provider_for_app(state, AppType::OpenCode) .expect("sync legacy opencode provider after reset"); let live_providers = crate::opencode_config::get_providers().expect("read opencode providers"); assert!( live_providers.contains_key(&provider.id), "legacy opencode provider should be restored when live config is reset" ); }); } #[test] #[serial] fn sync_current_provider_for_app_restores_legacy_openclaw_provider_after_live_reset() { with_test_home(|state, _| { let mut provider = openclaw_provider("legacy-openclaw-reset"); provider.settings_config["models"] = json!([ { "id": "claude-sonnet-4", "name": "Claude Sonnet 4" } ]); state .db .reconcile_provider_fixture(AppType::OpenClaw.as_str(), &provider) .expect("seed legacy openclaw provider in db"); ProviderService::sync_current_provider_for_app(state, AppType::OpenClaw) .expect("sync legacy openclaw provider after reset"); let live_providers = crate::openclaw_config::get_providers().expect("read openclaw providers"); assert!( live_providers.contains_key(&provider.id), "legacy openclaw provider should be restored when live config is reset" ); }); } #[test] #[serial] fn import_opencode_providers_from_live_marks_provider_as_live_managed() { with_test_home(|state, _| { let provider = opencode_provider("imported-opencode"); crate::opencode_config::set_provider(&provider.id, provider.settings_config.clone()) .expect("seed opencode live provider"); let imported = import_opencode_providers_from_live(state) .expect("import opencode providers from live"); assert_eq!(imported, 1); let saved = state .db .get_provider_by_id(&provider.id, AppType::OpenCode.as_str()) .expect("query imported opencode provider") .expect("imported opencode provider should exist"); assert_eq!( saved .meta .as_ref() .and_then(|meta| meta.live_config_managed), Some(true), "providers imported from live should be treated as live-managed" ); }); } #[test] #[serial] fn import_opencode_providers_from_live_updates_existing_provider_from_live() { with_test_home(|state, _| { let provider = opencode_provider("existing-opencode"); state .db .reconcile_provider_fixture(AppType::OpenCode.as_str(), &provider) .expect("seed existing opencode provider"); let mut live_settings = provider.settings_config.clone(); live_settings.as_object_mut().unwrap().remove("name"); live_settings["npm"] = Value::String("@ai-sdk/anthropic".to_string()); live_settings["models"]["gpt-4o"]["name"] = Value::String("Claude Sonnet".to_string()); crate::opencode_config::set_provider(&provider.id, live_settings) .expect("seed edited live opencode provider"); let updated = import_opencode_providers_from_live(state) .expect("import opencode providers from live"); assert_eq!(updated, 1); let saved = state .db .get_provider_by_id(&provider.id, AppType::OpenCode.as_str()) .expect("query updated opencode provider") .expect("opencode provider should exist"); assert_eq!(saved.name, provider.name); assert_eq!(saved.settings_config["npm"], json!("@ai-sdk/anthropic")); assert_eq!( saved.settings_config["models"]["gpt-4o"]["name"], json!("Claude Sonnet") ); }); } #[test] #[serial] fn import_openclaw_providers_from_live_marks_provider_as_live_managed() { with_test_home(|state, _| { let mut provider = openclaw_provider("imported-openclaw"); provider.settings_config["models"] = json!([ { "id": "claude-sonnet-4", "name": "Claude Sonnet 4" } ]); crate::openclaw_config::set_provider(&provider.id, provider.settings_config.clone()) .expect("seed openclaw live provider"); let imported = import_openclaw_providers_from_live(state) .expect("import openclaw providers from live"); assert_eq!(imported, 1); let saved = state .db .get_provider_by_id(&provider.id, AppType::OpenClaw.as_str()) .expect("query imported openclaw provider") .expect("imported openclaw provider should exist"); assert_eq!( saved .meta .as_ref() .and_then(|meta| meta.live_config_managed), Some(true), "providers imported from live should be treated as live-managed" ); }); } #[test] #[serial] fn import_openclaw_providers_from_live_updates_existing_provider_from_live() { with_test_home(|state, _| { let mut provider = openclaw_provider("existing-openclaw"); provider.settings_config["models"] = json!([ { "id": "claude-sonnet-4", "name": "Claude Sonnet 4" } ]); state .db .reconcile_provider_fixture(AppType::OpenClaw.as_str(), &provider) .expect("seed existing openclaw provider"); let mut live_settings = provider.settings_config.clone(); live_settings["baseUrl"] = Value::String("https://api.example.com/v1".to_string()); live_settings["models"][0]["name"] = Value::String("Claude Sonnet 4.1".to_string()); crate::openclaw_config::set_provider(&provider.id, live_settings) .expect("seed edited live openclaw provider"); let updated = import_openclaw_providers_from_live(state) .expect("import openclaw providers from live"); assert_eq!(updated, 1); let saved = state .db .get_provider_by_id(&provider.id, AppType::OpenClaw.as_str()) .expect("query updated openclaw provider") .expect("openclaw provider should exist"); assert_eq!(saved.name, provider.name); assert_eq!( saved.settings_config["baseUrl"], json!("https://api.example.com/v1") ); assert_eq!( saved.settings_config["models"][0]["name"], json!("Claude Sonnet 4.1") ); }); } #[test] #[serial] fn import_hermes_providers_from_live_updates_existing_provider_from_live() { with_test_home(|state, _| { let provider = hermes_provider("existing-hermes"); state .db .reconcile_provider_fixture(AppType::Hermes.as_str(), &provider) .expect("seed existing hermes provider"); let mut live_settings = provider.settings_config.clone(); live_settings["base_url"] = Value::String("https://api.hermes.example/v1".to_string()); live_settings["models"]["gpt-4o"]["name"] = Value::String("GPT-4o Updated".to_string()); crate::hermes_config::set_provider(&provider.id, live_settings) .expect("seed edited live hermes provider"); let updated = import_hermes_providers_from_live(state) .expect("import hermes providers from live"); assert_eq!(updated, 1); let saved = state .db .get_provider_by_id(&provider.id, AppType::Hermes.as_str()) .expect("query updated hermes provider") .expect("hermes provider should exist"); assert_eq!(saved.name, provider.name); assert_eq!( saved.settings_config["base_url"], json!("https://api.hermes.example/v1") ); // models are denormalized from YAML dict to UI-friendly array by // get_providers(), so access by index rather than dict key assert_eq!( saved.settings_config["models"][0]["name"], json!("GPT-4o Updated") ); assert_eq!(saved.settings_config["models"][0]["id"], json!("gpt-4o")); }); } #[test] #[serial] fn legacy_additive_provider_still_errors_on_live_config_parse_failure() { with_test_home(|state, home| { let provider = openclaw_provider("legacy-provider"); state .db .reconcile_provider_fixture(AppType::OpenClaw.as_str(), &provider) .expect("seed legacy provider without live_config_managed marker"); let openclaw_dir = home.join(".openclaw"); fs::create_dir_all(&openclaw_dir).expect("create openclaw dir"); fs::write(openclaw_dir.join("openclaw.json"), "{ invalid json5") .expect("write malformed config"); let mut updated = provider.clone(); updated.name = "Legacy Edited".to_string(); let err = ProviderService::update( state, AppType::OpenClaw, None, provider_to_mutation_input(updated), ) .expect_err("legacy providers should still surface live parse errors"); assert!( err.to_string().contains("Failed to parse OpenClaw config"), "expected parse error, got {err:?}" ); }); } #[test] #[serial] fn update_persists_non_current_omo_variants_in_database() { with_test_home(|state, _| { for category in ["omo", "omo-slim"] { let provider = opencode_omo_provider(&format!("{category}-provider"), category); state .db .reconcile_provider_fixture(AppType::OpenCode.as_str(), &provider) .unwrap_or_else(|err| panic!("seed {category} provider: {err}")); let mut updated = provider.clone(); updated.name = format!("Updated {category}"); updated.settings_config["agents"]["writer"]["model"] = Value::String(format!("{category}-next-model")); ProviderService::update( state, AppType::OpenCode, None, provider_to_mutation_input(updated), ) .unwrap_or_else(|err| panic!("update {category} provider: {err}")); let saved = state .db .get_provider_by_id(&provider.id, AppType::OpenCode.as_str()) .unwrap_or_else(|err| panic!("query updated {category} provider: {err}")) .unwrap_or_else(|| panic!("{category} provider should exist")); assert_eq!(saved.name, format!("Updated {category}")); assert_eq!( saved.settings_config["agents"]["writer"]["model"], Value::String(format!("{category}-next-model")), "{category} updates should persist in the database" ); } }); } #[test] #[serial] fn update_current_omo_variant_rewrites_config_from_saved_provider() { with_test_home(|state, home| { for category in ["omo", "omo-slim"] { let provider = opencode_omo_provider(&format!("{category}-current"), category); state .db .reconcile_provider_fixture(AppType::OpenCode.as_str(), &provider) .unwrap_or_else(|err| panic!("seed current {category} provider: {err}")); state .db .set_omo_provider_current(AppType::OpenCode.as_str(), &provider.id, category) .unwrap_or_else(|err| panic!("set current {category} provider: {err}")); let mut updated = provider.clone(); updated.name = format!("Current {category} updated"); updated.settings_config["agents"]["writer"]["model"] = Value::String(format!("{category}-saved-model")); updated.settings_config["otherFields"]["theme"] = Value::String(format!("{category}-light")); ProviderService::update( state, AppType::OpenCode, None, provider_to_mutation_input(updated), ) .unwrap_or_else(|err| panic!("update current {category} provider: {err}")); let saved = state .db .get_provider_by_id(&provider.id, AppType::OpenCode.as_str()) .unwrap_or_else(|err| panic!("query current {category} provider: {err}")) .unwrap_or_else(|| panic!("current {category} provider should exist")); assert_eq!(saved.name, format!("Current {category} updated")); let written = fs::read_to_string(omo_config_path(home, category)) .unwrap_or_else(|err| panic!("read written {category} config: {err}")); let written_json: Value = serde_json::from_str(&written) .unwrap_or_else(|err| panic!("parse written {category} config: {err}")); assert_eq!( written_json["agents"]["writer"]["model"], Value::String(format!("{category}-saved-model")), "{category} config should be written from the saved provider state" ); assert_eq!( written_json["theme"], Value::String(format!("{category}-light")), "{category} top-level config should reflect updated otherFields" ); } }); } #[test] #[serial] fn update_current_omo_variant_does_not_persist_database_when_file_write_fails() { with_test_home(|state, home| { let provider = opencode_omo_provider("omo-current", "omo"); state .db .reconcile_provider_fixture(AppType::OpenCode.as_str(), &provider) .unwrap_or_else(|err| panic!("seed current omo provider: {err}")); state .db .set_omo_provider_current(AppType::OpenCode.as_str(), &provider.id, "omo") .unwrap_or_else(|err| panic!("set current omo provider: {err}")); let config_dir = home.join(".config").join("opencode"); fs::create_dir_all(config_dir.parent().expect("config dir parent")) .expect("create .config dir"); fs::write(&config_dir, "not a directory").expect("block opencode config dir"); let mut updated = provider.clone(); updated.name = "Current omo updated".to_string(); updated.settings_config["agents"]["writer"]["model"] = Value::String("omo-saved-model".to_string()); ProviderService::update( state, AppType::OpenCode, None, provider_to_mutation_input(updated), ) .expect_err("update should fail when current omo file write fails"); let saved = state .db .get_provider_by_id(&provider.id, AppType::OpenCode.as_str()) .unwrap_or_else(|err| panic!("query current omo provider: {err}")) .unwrap_or_else(|| panic!("current omo provider should exist")); assert_eq!(saved.name, provider.name); assert_eq!( saved.settings_config["agents"]["writer"]["model"], provider.settings_config["agents"]["writer"]["model"], "database should remain unchanged when file write fails" ); }); } #[test] #[serial] fn update_current_omo_variant_rolls_back_file_when_plugin_sync_fails() { with_test_home(|state, home| { let provider = opencode_omo_provider("omo-current", "omo"); state .db .reconcile_provider_fixture(AppType::OpenCode.as_str(), &provider) .unwrap_or_else(|err| panic!("seed current omo provider: {err}")); state .db .set_omo_provider_current(AppType::OpenCode.as_str(), &provider.id, "omo") .unwrap_or_else(|err| panic!("set current omo provider: {err}")); let config_path = omo_config_path(home, "omo"); fs::create_dir_all(config_path.parent().expect("omo config parent")) .expect("create omo config dir"); let previous_content = serde_json::to_string_pretty(&json!({ "theme": "legacy-live-theme", "agents": { "writer": { "model": "legacy-live-model" } }, "categories": { "default": ["writer"] } })) .expect("serialize previous config"); fs::write(&config_path, &previous_content).expect("seed previous omo config"); let opencode_config_path = home.join(".config").join("opencode").join("opencode.json"); fs::write(&opencode_config_path, "{ invalid json").expect("seed malformed opencode"); let mut updated = provider.clone(); updated.name = "Current omo updated".to_string(); updated.settings_config["agents"]["writer"]["model"] = Value::String("omo-saved-model".to_string()); updated.settings_config["otherFields"]["theme"] = Value::String("omo-light".to_string()); ProviderService::update( state, AppType::OpenCode, None, provider_to_mutation_input(updated), ) .expect_err("update should fail when plugin sync fails"); let saved = state .db .get_provider_by_id(&provider.id, AppType::OpenCode.as_str()) .unwrap_or_else(|err| panic!("query current omo provider: {err}")) .unwrap_or_else(|| panic!("current omo provider should exist")); assert_eq!(saved.name, provider.name); assert_eq!( saved.settings_config["agents"]["writer"]["model"], provider.settings_config["agents"]["writer"]["model"], "database should remain unchanged when plugin sync fails" ); let written = fs::read_to_string(&config_path).expect("read rolled back omo config content"); assert_eq!( written, previous_content, "OMO config should roll back to its previous on-disk contents" ); }); } } impl ProviderService { fn normalize_provider_if_claude(app_type: &AppType, provider: &mut Provider) { if matches!(app_type, AppType::Claude) { let mut v = provider.settings_config.clone(); if normalize_claude_models_in_value(&mut v) { provider.settings_config = v; } } } /// Check whether a provider exists in live config, tolerating parse errors /// only for providers that are explicitly marked as DB-only. fn check_live_config_exists( app_type: &AppType, provider_id: &str, live_config_managed: Option, ) -> Result { if live_config_managed == Some(false) { Ok(provider_exists_in_live_config(app_type, provider_id).unwrap_or(false)) } else { provider_exists_in_live_config(app_type, provider_id) } } fn provider_live_config_managed(provider: &Provider) -> Option { provider .meta .as_ref() .and_then(|meta| meta.live_config_managed) } fn set_provider_live_config_managed(provider: &mut Provider, managed: bool) { provider .meta .get_or_insert_with(Default::default) .live_config_managed = Some(managed); } fn persist_live_config_managed( state: &AppState, app_type: &AppType, provider_id: &str, managed: bool, ) -> Result<(), AppError> { // This is a narrow metadata transformation, so a concurrent content // edit can be preserved by rereading and reapplying it. Every attempt // still uses the single-lock/single-transaction fingerprint primitive. for attempt in 0..3 { let mut provider = state .db .get_provider_aggregate(app_type.as_str(), provider_id)? .ok_or_else(|| { AppError::NotFound(format!("provider '{}/{}'", app_type.as_str(), provider_id)) })? .provider; if Self::provider_live_config_managed(&provider) == Some(managed) { return Ok(()); } let fingerprint = provider_row_fingerprint(&provider); Self::set_provider_live_config_managed(&mut provider, managed); remove_hydrated_endpoints_from_row_update(&mut provider); let input = provider_to_mutation_input(provider); match update_provider_record_if_unchanged(state, app_type, fingerprint, input) { Err(AppError::Conflict(_)) if attempt < 2 => continue, result => return result, } } unreachable!("bounded live-config marker retry always returns") } fn normalize_usage_script_credential_overrides(app_type: &AppType, provider: &mut Provider) { let current_credentials = provider.resolve_usage_credentials(app_type); let Some(usage_script) = provider .meta .as_mut() .and_then(|meta| meta.usage_script.as_mut()) else { return; }; if usage_script.template_type.as_deref() == Some("token_plan") { return; } if usage_script.api_key.as_deref().is_some_and(|api_key| { Self::should_clear_usage_api_key_override(api_key, ¤t_credentials) }) { usage_script.api_key = None; } if usage_script.base_url.as_deref().is_some_and(|base_url| { Self::should_clear_usage_base_url_override(base_url, ¤t_credentials) }) { usage_script.base_url = None; } } fn should_clear_usage_api_key_override( script_api_key: &str, current_credentials: &(String, String), ) -> bool { let candidate = script_api_key.trim(); if candidate.is_empty() { return true; } let matches_provider_key = |api_key: &str| { let api_key = api_key.trim(); !api_key.is_empty() && api_key == candidate }; matches_provider_key(¤t_credentials.1) } fn should_clear_usage_base_url_override( script_base_url: &str, current_credentials: &(String, String), ) -> bool { let candidate = Self::normalize_usage_base_url_for_compare(script_base_url); if candidate.is_empty() { return true; } let matches_provider_base_url = |base_url: &str| { let base_url = Self::normalize_usage_base_url_for_compare(base_url); !base_url.is_empty() && base_url == candidate }; matches_provider_base_url(¤t_credentials.0) } fn normalize_usage_base_url_for_compare(base_url: &str) -> String { base_url.trim().trim_end_matches('/').to_string() } /// List all providers for an app type pub fn list( state: &AppState, app_type: AppType, ) -> Result, AppError> { state.db.get_all_providers(app_type.as_str()) } /// Get current provider ID /// /// 使用有效的当前供应商 ID(验证过存在性)。 /// 优先从本地 settings 读取,验证后 fallback 到数据库的 is_current 字段。 /// 这确保了云同步场景下多设备可以独立选择供应商,且返回的 ID 一定有效。 /// /// 对于累加模式应用(OpenCode, OpenClaw),不存在"当前供应商"概念,直接返回空字符串。 pub fn current(state: &AppState, app_type: AppType) -> Result { // Additive mode apps have no "current" provider concept if app_type.is_additive_mode() { return Ok(String::new()); } if matches!(app_type, AppType::Pi) { return PiCatalogCoordinator::current_native_provider(state) .map(|provider| provider.unwrap_or_default()); } crate::settings::get_effective_current_provider(&state.db, &app_type) .map(|opt| opt.unwrap_or_default()) } /// Add a new provider pub fn add( state: &AppState, app_type: AppType, input: ProviderMutationInput, add_to_live: bool, ) -> Result { if matches!(app_type, AppType::Pi) { let provider_key = input.id.clone(); PiCatalogCoordinator::apply( state, PiCatalogMutation::CreateProvider { input, provider_key, activate_if_first: true, }, )?; return Ok(true); } let _provider_mutation_guard = lock_additive_provider_mutation(state, &app_type); let mut provider: Provider = input.into(); // Normalize Claude model keys Self::normalize_provider_if_claude(&app_type, &mut provider); Self::validate_provider_settings(&app_type, &provider)?; normalize_provider_common_config_for_storage(state.db.as_ref(), &app_type, &mut provider)?; Self::normalize_usage_script_credential_overrides(&app_type, &mut provider); if app_type.is_additive_mode() { Self::set_provider_live_config_managed(&mut provider, add_to_live); } // Strict create owns both the provider row and initial endpoints. create_provider_record( state, &app_type, provider_to_mutation_input(provider.clone()), )?; // Additive mode apps (OpenCode, OpenClaw): optionally write to live config. if app_type.is_additive_mode() { // OMO / OMO Slim providers use exclusive mode and write to dedicated config file. if matches!(app_type, AppType::OpenCode) && matches!(provider.category.as_deref(), Some("omo") | Some("omo-slim")) { // Do not auto-enable newly added OMO / OMO Slim providers. // Users must explicitly switch/apply an OMO provider to activate it. return Ok(true); } if !add_to_live { return Ok(true); } write_live_with_common_config(state.db.as_ref(), &app_type, &provider)?; return Ok(true); } // 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() { // No current provider, set as current and sync state .db .set_current_provider(app_type.as_str(), &provider.id)?; write_live_with_common_config(state.db.as_ref(), &app_type, &provider)?; } Ok(true) } /// Update a provider pub fn update( state: &AppState, app_type: AppType, original_id: Option<&str>, input: ProviderMutationInput, ) -> Result { // Reject endpoint-bearing edit payloads before any live or DB side // effect. Endpoints have their own typed mutation API. ProviderRowUpdate::from_input(&input)?; if matches!(app_type, AppType::Pi) { let original_id = original_id.unwrap_or(input.id.as_str()); if original_id != input.id { return Err(AppError::InvalidInput( "Pi provider identity and native projection key cannot be renamed".to_string(), )); } PiCatalogCoordinator::apply(state, PiCatalogMutation::UpdateProvider { input })?; return Ok(true); } let _provider_mutation_guard = lock_additive_provider_mutation(state, &app_type); let mut provider: Provider = input.into(); let original_id = original_id.unwrap_or(provider.id.as_str()).to_string(); let provider_id_changed = original_id != provider.id; let existing_provider = state .db .get_provider_by_id(&original_id, app_type.as_str())?; // Normalize Claude model keys Self::normalize_provider_if_claude(&app_type, &mut provider); Self::validate_provider_settings(&app_type, &provider)?; normalize_provider_common_config_for_storage(state.db.as_ref(), &app_type, &mut provider)?; Self::normalize_usage_script_credential_overrides(&app_type, &mut provider); if provider_id_changed { if !app_type.is_additive_mode() { return Err(AppError::Message( "Only additive-mode providers support changing provider key".to_string(), )); } let Some(existing_provider) = existing_provider else { return Err(AppError::Message(format!( "Original provider '{}' does not exist in app '{}'", original_id, app_type.as_str() ))); }; // OMO / OMO Slim providers are activated via a dedicated current-state mechanism // (set_omo_provider_current) that is NOT captured by provider_exists_in_live_config, // which only checks opencode.json. A rename would orphan that current-state marker // and silently break subsequent OMO file syncs. Block it unconditionally. if matches!(app_type, AppType::OpenCode) && matches!( existing_provider.category.as_deref(), Some("omo") | Some("omo-slim") ) { return Err(AppError::Message( "Provider key cannot be changed for OMO/OMO Slim providers".to_string(), )); } // A rename changes durable identity, so "cannot inspect live // config" must never be treated as "not live". DB-only // same-ID edits deliberately retain their tolerant path below, // but rename proves both identities absent from a readable live // config before committing the SQLite transaction. let original_in_live = provider_exists_in_live_config(&app_type, &original_id)?; if original_in_live { return Err(AppError::Message( "Provider key cannot be changed after the provider has been added to the app config" .to_string(), )); } let next_id_in_live = provider_exists_in_live_config(&app_type, &provider.id)?; if state .db .get_provider_by_id(&provider.id, app_type.as_str())? .is_some() || next_id_in_live { return Err(AppError::Message(format!( "Provider '{}' already exists in app '{}'", provider.id, app_type.as_str() ))); } Self::set_provider_live_config_managed(&mut provider, false); let source = ProviderKey::new(app_type.as_str(), original_id.clone())?; state .db .rename_db_only_additive_provider(RenameProvider::from_input( source, &provider_to_mutation_input(provider.clone()), )?)?; if crate::settings::get_current_provider(&app_type).as_deref() == Some(&original_id) { crate::settings::set_current_provider(&app_type, Some(provider.id.as_str()))?; } return Ok(true); } // Additive mode apps (OpenCode, OpenClaw): only sync to live when the provider // already exists in live config. Editing a DB-only provider must not auto-add it. if app_type.is_additive_mode() { let omo_variant = if matches!(app_type, AppType::OpenCode) { match provider.category.as_deref() { Some("omo") => Some(&crate::services::omo::STANDARD), Some("omo-slim") => Some(&crate::services::omo::SLIM), _ => None, } } else { None }; if let Some(variant) = omo_variant { let is_current = state.db.is_omo_provider_current( app_type.as_str(), &provider.id, variant.category, )?; if is_current { crate::services::OmoService::write_provider_config_to_file(&provider, variant)?; } if let Err(err) = update_provider_record( state, &app_type, &provider_to_mutation_input(provider.clone()), ) { if is_current { if let Err(rollback_err) = crate::services::OmoService::write_config_to_file(state, variant) { log::warn!( "Failed to roll back {} config after DB save error: {}", variant.label, rollback_err ); } } return Err(err); } return Ok(true); } let live_config_managed = Self::check_live_config_exists( &app_type, &provider.id, Self::provider_live_config_managed(&provider).or_else(|| { existing_provider .as_ref() .and_then(Self::provider_live_config_managed) }), )?; Self::set_provider_live_config_managed(&mut provider, live_config_managed); // Save to database after live-config presence is resolved so parse errors // do not report failure after already mutating DB state. update_provider_record( state, &app_type, &provider_to_mutation_input(provider.clone()), )?; if !live_config_managed { return Ok(true); } write_live_with_common_config(state.db.as_ref(), &app_type, &provider)?; return Ok(true); } // Save to database update_provider_record( state, &app_type, &provider_to_mutation_input(provider.clone()), )?; // 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()); if is_current { // 如果 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 live_taken_over = state .proxy_service .detect_takeover_in_live_config_for_app(&app_type); // Backup or live placeholders mean the live file is currently owned // by proxy takeover, including the short activation window before // proxy_config.enabled is committed. let should_sync_via_proxy = has_live_backup || live_taken_over; if should_sync_via_proxy { if matches!(app_type, AppType::ClaudeDesktop) { write_live_with_common_config(state.db.as_ref(), &app_type, &provider)?; } else { futures::executor::block_on( state .proxy_service .update_live_backup_from_provider(app_type.as_str(), &provider), ) .map_err(|e| AppError::Message(format!("更新 Live 备份失败: {e}")))?; } if futures::executor::block_on(state.proxy_service.is_running()) { 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 if live_taken_over && matches!(app_type, AppType::Codex) { // Codex model mappings are projected into a generated // model_catalog_json file. Refresh takeover-owned Live // immediately so adding/removing mappings cannot leave // the previous catalog pointer and capabilities active. futures::executor::block_on( state .proxy_service .sync_codex_live_from_provider_while_proxy_active(&provider), ) .map_err(|e| AppError::Message(format!("同步 Codex Live 配置失败: {e}")))?; } } } else { write_live_with_common_config(state.db.as_ref(), &app_type, &provider)?; // 重写 live 后只重投影本应用的 MCP:全量 sync_all_enabled 会把 // 无关应用的 live 损坏(如 ~/.claude.json 坏 JSON)牵连进保存 // 流程。走到这里 DB 与 live 都已按新配置落盘,保存事实上已 // 成功;投影失败降级为警告,避免制造"保存失败"假象(MCP // 投影可自愈:下次切换 / 任一 MCP 启停都会重新投影)。 if let Err(err) = McpService::sync_enabled_for_app(state, &app_type) { log::warn!( "保存供应商后重投影 {app_type:?} MCP 失败(将在下次同步时自愈): {err}" ); } } } Ok(true) } /// Delete a provider /// /// 同时检查本地 settings 和数据库的当前供应商,防止删除任一端正在使用的供应商。 /// 对于累加模式应用(OpenCode, OpenClaw),可以随时删除任意供应商,同时从 live 配置中移除。 pub fn delete(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> { if matches!(app_type, AppType::Pi) { PiCatalogCoordinator::apply( state, PiCatalogMutation::DeleteProvider { provider_id: id.to_string(), }, )?; return Ok(()); } let _provider_mutation_guard = lock_additive_provider_mutation(state, &app_type); // Additive mode apps - no current provider concept if app_type.is_additive_mode() { // Single DB read shared across all additive-mode sub-paths below. let existing = state.db.get_provider_by_id(id, app_type.as_str())?; if matches!(app_type, AppType::OpenCode) { let provider_category = existing.as_ref().and_then(|p| p.category.clone()); let omo_variant = match provider_category.as_deref() { Some("omo") => Some(&crate::services::omo::STANDARD), Some("omo-slim") => Some(&crate::services::omo::SLIM), _ => None, }; if let Some(variant) = omo_variant { let was_current = state.db.is_omo_provider_current( app_type.as_str(), id, variant.category, )?; state.db.delete_provider(app_type.as_str(), id)?; if was_current { crate::services::OmoService::delete_config_file(variant)?; } return Ok(()); } } // Non-OMO path for both OpenCode and OpenClaw: // remove from live first (atomicity), then DB. // // Use check_live_config_exists rather than trusting the flag alone: the flag // can be stale (Some(false) for a provider that was written to live before the // live_config_managed flip was introduced). check_live_config_exists reads the // actual file when the flag is Some(false), so it handles historical data correctly. let live_managed = existing .as_ref() .and_then(Self::provider_live_config_managed); if Self::check_live_config_exists(&app_type, id, live_managed)? { match app_type { AppType::OpenCode => remove_opencode_provider_from_live(id)?, AppType::OpenClaw => remove_openclaw_provider_from_live(id)?, AppType::Hermes => remove_hermes_provider_from_live(id)?, _ => {} } } state.db.delete_provider(app_type.as_str(), id)?; return Ok(()); } // For other apps: Check both local settings and database let local_current = crate::settings::get_current_provider(&app_type); let db_current = state.db.get_current_provider(app_type.as_str())?; if local_current.as_deref() == Some(id) || db_current.as_deref() == Some(id) { return Err(AppError::Message( "无法删除当前正在使用的供应商".to_string(), )); } state.db.delete_provider(app_type.as_str(), id) } /// Remove provider from live config only (for additive mode apps like OpenCode, OpenClaw) /// /// Does NOT delete from database - provider remains in the list. /// This is used when user wants to "remove" a provider from active config /// but keep it available for future use. pub fn remove_from_live_config( state: &AppState, app_type: AppType, id: &str, ) -> Result<(), AppError> { let _provider_mutation_guard = lock_additive_provider_mutation(state, &app_type); match app_type { AppType::OpenCode => { let provider_category = state .db .get_provider_by_id(id, app_type.as_str())? .and_then(|p| p.category); let omo_variant = match provider_category.as_deref() { Some("omo") => Some(&crate::services::omo::STANDARD), Some("omo-slim") => Some(&crate::services::omo::SLIM), _ => None, }; if let Some(variant) = omo_variant { state .db .clear_omo_provider_current(app_type.as_str(), id, variant.category)?; let still_has_current = state .db .get_current_omo_provider("opencode", variant.category)? .is_some(); if still_has_current { crate::services::OmoService::write_config_to_file(state, variant)?; } else { crate::services::OmoService::delete_config_file(variant)?; } } else { remove_opencode_provider_from_live(id)?; } } AppType::OpenClaw => { remove_openclaw_provider_from_live(id)?; } AppType::Hermes => { remove_hermes_provider_from_live(id)?; } _ => { return Err(AppError::Message(format!( "App {} does not support remove from live config", app_type.as_str() ))); } } if state .db .get_provider_aggregate(app_type.as_str(), id)? .is_some() { Self::persist_live_config_managed(state, &app_type, id, false)?; } Ok(()) } /// Switch to a provider /// /// Switch flow: /// 1. Validate target provider exists /// 2. Check if proxy takeover mode is active AND proxy server is running /// 3. If takeover mode active: hot-switch proxy target and refresh proxy-safe Live labels /// 4. If normal mode: /// a. **Backfill mechanism**: Backfill current live config to current provider /// b. Update local settings current_provider_xxx (device-level) /// c. Update database is_current (as default for new devices) /// d. Write target provider config to live files /// e. Sync MCP configuration pub fn switch(state: &AppState, app_type: AppType, id: &str) -> Result { if matches!(app_type, AppType::Pi) { let provider = state .db .get_provider_aggregate(AppType::Pi.as_str(), id)? .ok_or_else(|| AppError::NotFound(format!("Pi provider '{id}'")))?; let config: crate::pi_config::model::PiManagedProviderConfig = serde_json::from_value(provider.provider.settings_config).map_err(|error| { AppError::Config(format!("managed Pi provider '{id}' is invalid: {error}")) })?; let model_id = config .models .first() .ok_or_else(|| { AppError::InvalidInput(format!("Pi provider '{id}' has no selectable models")) })? .id .clone(); PiCatalogCoordinator::apply( state, PiCatalogMutation::SetDefault { provider_id: id.to_string(), model_id, }, )?; return Ok(SwitchResult::default()); } // The same per-app lock also guards additive provider key changes and // bulk live sync. Acquire it before observing the provider map so a // queued rename cannot leave this switch holding a stale source key. let _switch_guard = if app_type.is_additive_mode() || matches!( app_type, AppType::Claude | AppType::Codex | AppType::Gemini | AppType::GrokBuild ) { Some(futures::executor::block_on( state.proxy_service.lock_switch_for_app(app_type.as_str()), )) } else { None }; // Check if provider exists let providers = state.db.get_all_providers(app_type.as_str())?; let _provider = providers .get(id) .ok_or_else(|| AppError::Message(format!("供应商 {id} 不存在")))?; // OMO providers are switched through their own exclusive path. if matches!(app_type, AppType::OpenCode) && _provider.category.as_deref() == Some("omo") { return Self::switch_normal(state, app_type, id, &providers); } // OMO Slim providers are switched through their own exclusive path. if matches!(app_type, AppType::OpenCode) && _provider.category.as_deref() == Some("omo-slim") { return Self::switch_normal(state, app_type, id, &providers); } if matches!(app_type, AppType::ClaudeDesktop) { return Self::switch_normal(state, app_type, id, &providers); } // Backup or live placeholders mean the live file is owned by proxy // takeover, even if the proxy server is temporarily stopped or is in the // activation window before enabled=true is committed. let is_app_taken_over = futures::executor::block_on(state.db.get_live_backup(app_type.as_str())) .ok() .flatten() .is_some(); let live_taken_over = state .proxy_service .detect_takeover_in_live_config_for_app(&app_type); let should_hot_switch = is_app_taken_over || live_taken_over; // Block switching to official providers when proxy takeover is active. // Using a proxy with official APIs (Anthropic/OpenAI/Google) may cause account bans. if should_hot_switch && _provider.category.as_deref() == Some("official") && !official_provider_supports_proxy_takeover(&app_type, _provider) { return Err(AppError::localized( "switch.official_blocked_by_proxy", "代理接管模式下不能切换到官方供应商,使用代理访问官方 API 可能导致账号被封禁。请先关闭代理接管,或选择第三方供应商。", "Cannot switch to official provider while proxy takeover is active. Using proxy with official APIs may cause account bans.", )); } if should_hot_switch { // Proxy takeover mode: hot-switch without restoring upstream Live config. // The proxy layer may still refresh proxy-safe Live fields so client labels // follow the selected provider while endpoints remain local. log::info!( "代理接管模式:热切换 {} 的目标供应商为 {}", app_type.as_str(), id ); futures::executor::block_on( state .proxy_service .hot_switch_provider_inner(app_type.as_str(), id), ) .map_err(|e| AppError::Message(format!("热切换失败: {e}")))?; // The proxy server will route requests to the new provider via is_current. // MCP sync is intentionally skipped while Live config is owned by takeover. return Ok(SwitchResult::default()); } // Normal mode: full switch with Live config write Self::switch_normal(state, app_type, id, &providers) } /// Normal switch flow (non-proxy mode) fn switch_normal( state: &AppState, app_type: AppType, id: &str, providers: &indexmap::IndexMap, ) -> Result { let provider = providers .get(id) .ok_or_else(|| AppError::Message(format!("供应商 {id} 不存在")))?; // OMO ↔ OMO Slim are mutually exclusive; activating one removes the other's config file. if matches!(app_type, AppType::OpenCode) { let omo_pair = match provider.category.as_deref() { Some("omo") => Some((&crate::services::omo::STANDARD, &crate::services::omo::SLIM)), Some("omo-slim") => { Some((&crate::services::omo::SLIM, &crate::services::omo::STANDARD)) } _ => None, }; if let Some((enable, disable)) = omo_pair { state .db .set_omo_provider_current(app_type.as_str(), id, enable.category)?; crate::services::OmoService::write_config_to_file(state, enable)?; let _ = crate::services::OmoService::delete_config_file(disable); return Ok(SwitchResult::default()); } } let mut result = SwitchResult::default(); // 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)?; if let Some(current_id) = current_id { if current_id != id { // Additive mode apps - all providers coexist in the same file, // no backfill needed (backfill is for exclusive mode apps like Claude/Codex/Gemini) if !app_type.is_additive_mode() { // Only backfill when switching to a different provider if let Ok(live_config) = read_live_settings(app_type.clone()) { if let Some(mut current_provider) = state .db .get_provider_aggregate(app_type.as_str(), ¤t_id)? .map(|aggregate| aggregate.provider) { let fingerprint = provider_row_fingerprint(¤t_provider); // 切走前先把 live 里的可共享改动(含用户直接在应用内 // 装插件/加 hook/改偏好)同步进通用配置片段,再做剥离回填。 // 详见 sync_common_config_snippet_from_live 的文档。 Self::sync_common_config_snippet_from_live( state, &app_type, ¤t_provider, &live_config, &mut result, ); current_provider.settings_config = strip_common_config_from_live_settings( state.db.as_ref(), &app_type, ¤t_provider, live_config, ); remove_hydrated_endpoints_from_row_update(&mut current_provider); if let Err(e) = update_provider_record_if_unchanged( state, &app_type, fingerprint, provider_to_mutation_input(current_provider), ) { log::warn!("Backfill failed: {e}"); result .warnings .push(format!("backfill_failed:{current_id}")); } } } } } } // Additive mode apps skip setting is_current (no such concept) if !app_type.is_additive_mode() { // Update local settings (device-level, takes priority) crate::settings::set_current_provider(&app_type, Some(id))?; // Update database is_current (as default for new devices) state.db.set_current_provider(app_type.as_str(), id)?; } // Sync to live (write_gemini_live handles security flag internally for Gemini) write_live_with_common_config(state.db.as_ref(), &app_type, provider)?; // Hermes is additive, so "switching" doesn't overwrite a live config file // — we instead update the top-level `model:` section to point at this // provider's first declared model. Without this, clicking "switch" would // only shuffle entries in custom_providers[] while Hermes keeps using // whatever `model.provider` was set before. if matches!(app_type, AppType::Hermes) { if let Err(e) = crate::hermes_config::apply_switch_defaults(&provider.id, &provider.settings_config) { log::warn!( "Failed to update Hermes model defaults after switching to '{}': {e}", provider.id ); result .warnings .push(format!("hermes_model_defaults_failed:{}", provider.id)); } } // For additive-mode providers that were DB-only (live_config_managed == Some(false)), // flip the flag to true now that the provider has been successfully written to the live // file. This ensures sync_all_providers_to_live() will include it on future syncs. // // If persisting the marker fails, roll back the just-written live config so we don't leave // the provider in a silent inconsistent state (present in live, but still marked DB-only). if app_type.is_additive_mode() && Self::provider_live_config_managed(provider) != Some(true) { if let Err(e) = Self::persist_live_config_managed(state, &app_type, &provider.id, true) { let rollback_result = match app_type { AppType::OpenCode => remove_opencode_provider_from_live(&provider.id), AppType::OpenClaw => remove_openclaw_provider_from_live(&provider.id), AppType::Hermes => remove_hermes_provider_from_live(&provider.id), _ => Ok(()), }; match rollback_result { Ok(()) => { return Err(AppError::Message(format!( "Failed to persist live_config_managed for '{}' after writing live config; live changes were rolled back: {e}", provider.id ))); } Err(rollback_err) => { return Err(AppError::Message(format!( "Failed to persist live_config_managed for '{}' after writing live config: {e}; additionally failed to roll back live config: {rollback_err}", provider.id ))); } } } } // 切换重写了目标应用的 live,只重投影该应用的 MCP(Codex 的 // [mcp_servers] 与 live 同文件,整体替换后必须补回;其余应用的 // MCP 文件独立于 live,投影是幂等维护)。不用全量 sync_all_enabled: // 无关应用的 live 损坏(如 ~/.claude.json 坏 JSON)不该阻断切换。 // 走到这里 DB is_current 与 live 都已落盘,切换事实上已成功; // 投影失败上抛会让前端报"切换失败"制造分裂假象,故降级为警告 // (MCP 投影可自愈:下次切换 / 任一 MCP 启停都会重新投影)。 if let Err(err) = McpService::sync_enabled_for_app(state, &app_type) { log::warn!("切换供应商后重投影 {app_type:?} MCP 失败(将在下次同步时自愈): {err}"); } Ok(result) } /// Sync current provider to live configuration (re-export) pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> { sync_current_to_live(state) } pub fn sync_current_provider_for_app( state: &AppState, app_type: AppType, ) -> Result<(), AppError> { let _provider_mutation_guard = lock_additive_provider_mutation(state, &app_type); if app_type.is_additive_mode() { return sync_current_provider_for_app_to_live(state, &app_type); } let current_id = match crate::settings::get_effective_current_provider(&state.db, &app_type)? { Some(id) => id, None => return Ok(()), }; let providers = state.db.get_all_providers(app_type.as_str())?; let Some(provider) = providers.get(¤t_id) else { return Ok(()); }; let has_live_backup = futures::executor::block_on(state.db.get_live_backup(app_type.as_str())) .ok() .flatten() .is_some(); let live_taken_over = state .proxy_service .detect_takeover_in_live_config_for_app(&app_type); // See the save path above: backup/placeholders are the ownership signal // here, not just proxy_config.enabled. if has_live_backup || live_taken_over { if matches!(app_type, AppType::ClaudeDesktop) { write_live_with_common_config(state.db.as_ref(), &app_type, provider)?; return Ok(()); } futures::executor::block_on( state .proxy_service .update_live_backup_from_provider(app_type.as_str(), provider), ) .map_err(|e| AppError::Message(format!("更新 Live 备份失败: {e}")))?; return Ok(()); } sync_current_provider_for_app_to_live(state, &app_type) } pub fn migrate_legacy_common_config_usage( state: &AppState, app_type: AppType, legacy_snippet: &str, ) -> Result<(), AppError> { if app_type.is_additive_mode() || legacy_snippet.trim().is_empty() { return Ok(()); } let providers = state.db.get_all_provider_aggregates(app_type.as_str())?; for aggregate in providers.values() { let provider = &aggregate.provider; if provider .meta .as_ref() .and_then(|meta| meta.common_config_enabled) .is_some() { continue; } if !live::provider_uses_common_config(&app_type, provider, Some(legacy_snippet)) { continue; } let fingerprint = provider_row_fingerprint(provider); let mut updated_provider = provider.clone(); updated_provider .meta .get_or_insert_with(Default::default) .common_config_enabled = Some(true); match live::remove_common_config_from_settings( &app_type, &updated_provider.settings_config, legacy_snippet, ) { Ok(settings) => updated_provider.settings_config = settings, Err(err) => { log::warn!( "Failed to normalize legacy common config for {} provider '{}': {err}", app_type.as_str(), updated_provider.id ); } } remove_hydrated_endpoints_from_row_update(&mut updated_provider); update_provider_record_if_unchanged( state, &app_type, fingerprint, provider_to_mutation_input(updated_provider), )?; } Ok(()) } pub fn migrate_legacy_common_config_usage_if_needed( state: &AppState, app_type: AppType, ) -> Result<(), AppError> { if app_type.is_additive_mode() { return Ok(()); } let Some(snippet) = state.db.get_config_snippet(app_type.as_str())? else { return Ok(()); }; if snippet.trim().is_empty() { return Ok(()); } Self::migrate_legacy_common_config_usage(state, app_type, &snippet) } /// 切走某供应商前,把它 live 配置里的可共享部分重新提取并**整体替换**到 /// 通用配置片段,使在 live 应用里直接做的改动不会因切换而丢失。 /// /// 采用"整体重提取 + 替换"而非"只合并新增",是为了同时覆盖三种情况: /// - **新增**:用户直接在应用里装了插件、加了 hook、改了 env/主题/权限等共享 /// 偏好,被捕获进通用配置,切到别的供应商也带得过去; /// - **删除**:被删掉的键不在新提取结果里,于是从片段里消失、下次切换不会被 /// 重新注入——否则会出现"插件怎么删也删不掉"的反直觉 bug; /// - **密钥安全**:提取器已剥掉 auth / model / endpoint,密钥永不进共享片段。 /// /// 之所以"整体替换"是安全的:每次写 live 都会把当前片段合并进去,所以切走时 /// 读到的 live 一定是"片段 + 本地改动"的超集,重提取只会丢掉用户真正删掉的键, /// 不会误删其它供应商共享的内容。 /// /// **作用域**:Claude + Codex。Codex 提取器(`extract_codex_common_config`) /// 已剥离全部供应商专属与 cc-switch 注入内容:`model` / `model_provider` / /// 顶层 `base_url` / 整张 `model_providers` 表(含端点与统一会话桶)、 /// `mcp_servers`(SSOT 在 DB 表)、顶层 `experimental_bearer_token` /// fallback、`model_catalog_json`、`web_search = "disabled"` 哨兵——密钥与 /// 注入产物不会进共享片段。Gemini 暂未纳入,如需支持应单独验证后再加。 /// /// 仅对**显式勾选"写入通用配置"**(`meta.common_config_enabled == Some(true)`)的 /// 供应商生效;用户**显式清空**过片段(`_cleared`)时跳过,避免把用户主动清掉的 /// 配置又塞回来。所有失败均为非致命,只记 warning,绝不阻断切换。 fn sync_common_config_snippet_from_live( state: &AppState, app_type: &AppType, provider: &Provider, live_config: &Value, result: &mut SwitchResult, ) { // 作用域限定 Claude + Codex(见函数文档)。 if !matches!(app_type, AppType::Claude | AppType::Codex) { return; } let opted_in = provider .meta .as_ref() .and_then(|meta| meta.common_config_enabled) == Some(true); if !opted_in { return; } match state.db.is_config_snippet_cleared(app_type.as_str()) { Ok(true) => return, // 用户显式清空过通用配置,尊重其选择,不再自动塞回 Ok(false) => {} Err(err) => { log::warn!( "Failed to read common config cleared flag for {}: {err}", app_type.as_str() ); return; } } let new_snippet = match Self::extract_common_config_snippet_from_settings( app_type.clone(), live_config, ) { Ok(snippet) => snippet, Err(err) => { log::warn!( "Failed to extract common config from live for {} provider '{}': {err}", app_type.as_str(), provider.id ); return; } }; // 未变化则跳过,避免无谓写库(不切 live 配置时这是常态路径)。 let current = state .db .get_config_snippet(app_type.as_str()) .ok() .flatten(); if current.as_deref() == Some(new_snippet.as_str()) { return; } if let Err(err) = state .db .set_config_snippet(app_type.as_str(), Some(new_snippet)) { log::warn!( "Failed to persist synced common config for {} provider '{}': {err}", app_type.as_str(), provider.id ); result .warnings .push(format!("common_config_sync_failed:{}", provider.id)); } } /// Extract common config snippet from current provider /// /// Extracts the current provider's configuration and removes provider-specific fields /// (API keys, model settings, endpoints) to create a reusable common config snippet. pub fn extract_common_config_snippet( state: &AppState, app_type: AppType, ) -> Result { // Get current provider let current_id = Self::current(state, app_type.clone())?; if current_id.is_empty() { return Err(AppError::Message("No current provider".to_string())); } let providers = state.db.get_all_providers(app_type.as_str())?; let provider = providers .get(¤t_id) .ok_or_else(|| AppError::Message(format!("Provider {current_id} not found")))?; match app_type { AppType::Claude => Self::extract_claude_common_config(&provider.settings_config), AppType::ClaudeDesktop => Ok(String::new()), AppType::Codex => Self::extract_codex_common_config(&provider.settings_config), AppType::Gemini => Self::extract_gemini_common_config(&provider.settings_config), AppType::GrokBuild => Ok(String::new()), AppType::OpenCode => Self::extract_opencode_common_config(&provider.settings_config), AppType::OpenClaw => Self::extract_openclaw_common_config(&provider.settings_config), AppType::Hermes => Ok(String::new()), // Hermes doesn't use common config snippets AppType::Pi => Ok(String::new()), // Pi owns a shared exact-key catalog, not snippets } } /// Extract common config snippet from a config value (e.g. editor content). pub fn extract_common_config_snippet_from_settings( app_type: AppType, settings_config: &Value, ) -> Result { match app_type { AppType::Claude => Self::extract_claude_common_config(settings_config), AppType::ClaudeDesktop => Ok(String::new()), AppType::Codex => Self::extract_codex_common_config(settings_config), AppType::Gemini => Self::extract_gemini_common_config(settings_config), AppType::GrokBuild => Ok(String::new()), AppType::OpenCode => Self::extract_opencode_common_config(settings_config), AppType::OpenClaw => Self::extract_openclaw_common_config(settings_config), AppType::Hermes => Ok(String::new()), // Hermes doesn't use common config snippets AppType::Pi => Ok(String::new()), } } /// 判断一个 env / 顶层配置键名是否为凭据/机密:凡命中一律不得写入共享的 /// 通用配置片段。**故意从严**——多剥一个非机密键只是它不被共享(可恢复的小 /// 不便),漏剥一个凭据则会把密钥注入到每个供应商(不可恢复的泄漏)。因此用 /// 模式匹配覆盖整类,而非枚举具体名字(枚举永远会漏掉下一个 `*_API_KEY`)。 /// /// 覆盖:Anthropic / OpenRouter / Google / OpenAI / Gemini 等 `*_API_KEY` /// (Claude provider 的凭据见 `Provider::resolve_usage_credentials`,确实支持 /// `OPENROUTER_API_KEY` / `GOOGLE_API_KEY` 等回退)、各类 `*_AUTH_TOKEN` / /// 单数 `*_TOKEN`、AWS Bedrock / Vertex 凭据、以及通用 secret / password / /// 私钥命名。 pub(crate) fn is_sensitive_config_key(name: &str) -> bool { let upper = name.to_ascii_uppercase(); // 单数 `_TOKEN` 命中 AWS_SESSION_TOKEN 等,但**不**误伤复数 `_TOKENS` // (CLAUDE_CODE_MAX_OUTPUT_TOKENS / MAX_THINKING_TOKENS 是正常可共享配置)。 const SENSITIVE_SUFFIXES: &[&str] = &[ // 裸 `_KEY` 是最常见的凭据写法(OPENAI_KEY / GROQ_KEY / XAI_KEY…), // 必须单列:只枚举 `_API_KEY` / `_ACCESS_KEY` 这些子类,等于把最普通 // 的那一种漏在外面。下面几条 `_*_KEY` 被它蕴含,保留是为了说明覆盖面。 "_KEY", "_API_KEY", "_ACCESS_KEY", "_ACCESS_KEY_ID", "_KEY_ID", "_PRIVATE_KEY", // 不带分隔符的复合写法各走各的后缀:`_KEY` 够不着 `..._APIKEY` // (倒数第四个字符是 I 不是下划线)。VOLC_ACCESSKEY 是火山引擎文档 // 里的正式变量名,本仓库就实现了火山 AK/SK 用量查询。 "_APIKEY", "_ACCESSKEY", "_SECRETKEY", "_APITOKEN", "_AUTH_TOKEN", "_TOKEN", // GITHUB_PAT / GITLAB_PAT 等 personal access token 的惯用写法, // 既不含 TOKEN 也不含 KEY,前面每一条规则都够不着。 "_PAT", // 口令类的常见缩写。`_PASS` 不会误伤 `*_BYPASS`(那个以 `_BYPASS` // 结尾),`_PWD` 也不会误伤 shell 的 PWD / OLDPWD。 "_PWD", "_PASS", "_PASSPHRASE", "_CREDS", ]; const SENSITIVE_EXACT: &[&str] = &[ "APIKEY", "API_KEY", "TOKEN", "SECRET", "PASSWORD", "CREDENTIALS", ]; // contains:覆盖 AWS_SECRET_ACCESS_KEY / *_CLIENT_SECRET / // GOOGLE_APPLICATION_CREDENTIALS / AWS_BEARER_TOKEN_BEDROCK 等变体。 const SENSITIVE_CONTAINS: &[&str] = &[ "SECRET", "PASSWORD", "PASSWD", "CREDENTIAL", "PRIVATE_KEY", "BEARER_TOKEN", ]; SENSITIVE_EXACT.contains(&upper.as_str()) || SENSITIVE_SUFFIXES.iter().any(|s| upper.ends_with(s)) || SENSITIVE_CONTAINS.iter().any(|c| upper.contains(c)) } /// Extract common config for Claude (JSON format) fn extract_claude_common_config(settings: &Value) -> Result { let mut config = settings.clone(); // 供应商专属的**非机密**字段(模型 + 端点),不应共享。凭据/机密不在此列举, // 改由 `is_sensitive_config_key`(模式匹配)统一剥离,新供应商的 `*_API_KEY` // 等无需再手工补名单即可被覆盖。 const ENV_PROVIDER_SPECIFIC_EXCLUDES: &[&str] = &[ "ANTHROPIC_MODEL", "ANTHROPIC_REASONING_MODEL", // legacy: 已废弃,但旧配置可能残留 "ANTHROPIC_DEFAULT_HAIKU_MODEL", "ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME", "ANTHROPIC_DEFAULT_OPUS_MODEL", "ANTHROPIC_DEFAULT_OPUS_MODEL_NAME", "ANTHROPIC_DEFAULT_SONNET_MODEL", "ANTHROPIC_DEFAULT_SONNET_MODEL_NAME", // Fable 是 v3.16.3 新增的第四档模型映射,与 haiku/sonnet/opus 同属供应商专属, // 不得进入通用配置片段,否则会污染其它供应商(issue #4272)。 "ANTHROPIC_DEFAULT_FABLE_MODEL", "ANTHROPIC_DEFAULT_FABLE_MODEL_NAME", "CLAUDE_CODE_SUBAGENT_MODEL", // Context limits follow the actual upstream model. Sharing these // across providers can cap GPT/Kimi to the wrong window and make // Claude Code compact too early or miss the upstream limit. "CLAUDE_CODE_MAX_CONTEXT_TOKENS", "CLAUDE_CODE_AUTO_COMPACT_WINDOW", "ANTHROPIC_BASE_URL", ]; const TOP_LEVEL_EXCLUDES: &[&str] = &[ "apiBaseUrl", // Legacy model fields "primaryModel", "smallFastModel", ]; // Remove env fields: provider-specific (models/endpoint) + 任何凭据键。 if let Some(env) = config.get_mut("env").and_then(|v| v.as_object_mut()) { let sensitive: Vec = env .keys() .filter(|k| Self::is_sensitive_config_key(k)) .cloned() .collect(); for key in ENV_PROVIDER_SPECIFIC_EXCLUDES { env.remove(*key); } for key in &sensitive { env.remove(key); } // If env is empty after removal, remove the env object itself if env.is_empty() { config.as_object_mut().map(|obj| obj.remove("env")); } } // Remove top-level fields: legacy model fields + 任何凭据键 // (例如非标准的顶层 apiKey / api_key / *_TOKEN)。 if let Some(obj) = config.as_object_mut() { let sensitive: Vec = obj .keys() .filter(|k| Self::is_sensitive_config_key(k)) .cloned() .collect(); for key in TOP_LEVEL_EXCLUDES { obj.remove(*key); } for key in &sensitive { obj.remove(key); } } // Check if result is empty if config.as_object().is_none_or(|obj| obj.is_empty()) { return Ok("{}".to_string()); } serde_json::to_string_pretty(&config) .map_err(|e| AppError::Message(format!("Serialization failed: {e}"))) } /// Extract common config for Codex (TOML format) fn extract_codex_common_config(settings: &Value) -> Result { // Codex config is stored as { "auth": {...}, "config": "toml string" } let config_toml = settings .get("config") .and_then(|v| v.as_str()) .unwrap_or(""); if config_toml.is_empty() { return Ok(String::new()); } let mut doc = config_toml .parse::() .map_err(|e| AppError::Message(format!("TOML parse error: {e}")))?; // Remove provider-specific fields. let root = doc.as_table_mut(); root.remove("model"); root.remove("model_provider"); // Legacy/alt formats might use a top-level base_url. root.remove("base_url"); // wire_api 与 base_url 同属供应商路由语义:无 model_provider 时 // update_codex_toml_field / 前端 setCodexWireApi 都会把它落在顶层, // 进了片段会改写其它供应商的协议选择(chat vs responses)。 root.remove("wire_api"); // Remove entire model_providers table (provider-specific configuration) root.remove("model_providers"); // MCP 服务器归 DB mcp_servers 表所有:进了共享片段会绕过按应用的 // 启用状态被合并进所有勾选通用配置的供应商,且在通用配置编辑框里 // 显示为一份"重复"的 MCP 配置。 root.remove("mcp_servers"); // 历史错误格式 [mcp.servers] 一并剥离(与 strip_codex_mcp_servers_from_settings // 一致):sync_all_enabled 只管理 [mcp_servers.*],legacy 形态一旦进了 // 片段就会被合并进所有供应商,且没有任何同步路径能清掉这个孤儿。 if let Some(mcp_tbl) = root .get_mut("mcp") .and_then(|item| item.as_table_like_mut()) { mcp_tbl.remove("servers"); if mcp_tbl.is_empty() { root.remove("mcp"); } } // cc-switch 写 live 时注入的产物一律不进共享片段: // - experimental_bearer_token 正常写在 [model_providers.] 内(上面 // 整表已剥),但无活跃路由 / 内建保留 id / 路由表缺失三种 fallback // 会落在顶层——不剥等于把 API 密钥写进共享片段。 root.remove("experimental_bearer_token"); // - model_catalog_json 指向按供应商生成的 catalog 投影文件(DB 为 SSOT)。 root.remove("model_catalog_json"); // - web_search 只剥 cc-switch 注入的 "disabled" 哨兵;用户手设的其它值 // 属于可共享偏好,保留。 if root .get(crate::codex_config::CODEX_WEB_SEARCH_FIELD) .and_then(|item| item.as_str()) == Some(crate::codex_config::CODEX_WEB_SEARCH_DISABLED) { root.remove(crate::codex_config::CODEX_WEB_SEARCH_FIELD); } // Clean up multiple empty lines (keep at most one blank line). let mut cleaned = String::new(); let mut blank_run = 0usize; for line in doc.to_string().lines() { if line.trim().is_empty() { blank_run += 1; if blank_run <= 1 { cleaned.push('\n'); } continue; } blank_run = 0; cleaned.push_str(line); cleaned.push('\n'); } Ok(cleaned.trim().to_string()) } /// Extract common config for Gemini (JSON format) /// /// Extracts `.env` values while excluding provider-specific credentials: /// - GOOGLE_GEMINI_BASE_URL /// - GEMINI_API_KEY fn extract_gemini_common_config(settings: &Value) -> Result { let env = settings.get("env").and_then(|v| v.as_object()); let mut snippet = serde_json::Map::new(); if let Some(env) = env { for (key, value) in env { // 端点按名剥离(它不是凭据,模式匹配够不着);凭据全部交给 // `is_sensitive_config_key` 统一模式匹配(与 Claude 提取器一致)。 // 只列固定名单会漏掉下一个 `*_API_KEY` —— 例如 `GOOGLE_API_KEY` // (provider.rs 认可的一等 Gemini 凭据),而共享片段会被 deep-merge // 回其它 Gemini 供应商,漏剥即等于把 A 账号的密钥写进 B 供应商并 // 发往 B 的 base_url。`GEMINI_API_KEY` 不必单列:`_KEY` 后缀已覆盖。 if key == "GOOGLE_GEMINI_BASE_URL" || Self::is_sensitive_config_key(key) { continue; } let Value::String(v) = value else { continue; }; let trimmed = v.trim(); if !trimmed.is_empty() { snippet.insert(key.to_string(), Value::String(trimmed.to_string())); } } } if snippet.is_empty() { return Ok("{}".to_string()); } serde_json::to_string_pretty(&Value::Object(snippet)) .map_err(|e| AppError::Message(format!("Serialization failed: {e}"))) } /// 一次性清理:把历史泄漏进 Gemini 共享片段的凭据从所有存储位置抹掉。 /// /// 背景:`extract_gemini_common_config` 曾只剥离两个固定键名,`GOOGLE_API_KEY` /// 等一等凭据会进入共享片段,再被 `apply_common_config_to_settings` 深合并进 /// **其它** Gemini 供应商的 env,随请求发往对方的 base_url。 /// /// 光修提取器不够:Gemini 的片段一旦生成就**永不自动重提取**(启动期 /// auto-extract 与导入后补提取都要求 `snippet.is_none()`,切换时的回写又只对 /// Claude / Codex 生效),所以存量片段会一直带着密钥继续注入。 /// /// 两个关键约束: /// /// 1. **不能只清片段**。合并与剥离是一对靠「值相等」严格抵消的操作:切走供应商时 /// `remove_common_config_from_settings` 依据片段内容把注入的键删掉。片段里一旦 /// 没了这个键,backfill 就会把 live 中残留的密钥原样写进受害供应商的 /// `settings_config`——泄漏从瞬时污染变成永久污染。所以片段、各供应商配置、 /// live 文件必须一起清。 /// 2. **按值相等定向删除,不按键名一刀切**。复用 `remove_common_config_from_settings` /// 可以只清掉扩散出去的那一份,保留某个供应商自己写的、值不同的同名键。 /// /// 步骤顺序本身是安全属性的一部分:**清片段必须排在最后**。片段是 /// `remove_common_config_from_settings` 唯一的"该剥哪些键"来源,一旦清空,任何 /// 残留(live 文件里的、下一轮重试要处理的)都再也无法被识别和剥离。所以所有 /// 可能失败的步骤都排在它前面,失败即带错返回,让下次启动能原样重来。 /// /// 清理后部分供应商会显示缺少 API Key,需用户重填——这是正确行为:那把密钥本就 /// 不属于它们。(受害者原有的同名键在合并时已被覆盖,无法恢复。)动手前会往 /// settings 的 `gemini_common_config_scrub_audit_v1` 写一条审计记录,内容是 /// **键名与受影响的供应商 id,不含值**:`settings` 会随 WebDAV/S3 同步上传, /// 而这里处理的正是必须销毁的凭据,留值等于把一次清除换成一份跨设备扩散、 /// 没有界面入口、永不过期的明文副本。 pub async fn scrub_leaked_gemini_common_config(state: &AppState) -> Result<(), AppError> { const FLAG: &str = "gemini_common_config_credentials_scrubbed_v1"; const AUDIT_KEY: &str = "gemini_common_config_scrub_audit_v1"; let app = AppType::Gemini; if state.db.get_bool_flag(FLAG).unwrap_or(false) { return Ok(()); } let Some(snippet_text) = state.db.get_config_snippet(app.as_str())? else { state.db.set_setting(FLAG, "true")?; return Ok(()); }; // 片段解析不了就不动它,只标记完成——乱改用户数据比留着更糟 let Ok(Value::Object(entries)) = serde_json::from_str::(&snippet_text) else { state.db.set_setting(FLAG, "true")?; return Ok(()); }; let mut poison = serde_json::Map::new(); let mut clean = serde_json::Map::new(); for (key, value) in entries { if Self::is_sensitive_config_key(&key) { poison.insert(key, value); } else { clean.insert(key, value); } } if poison.is_empty() { state.db.set_setting(FLAG, "true")?; return Ok(()); } log::warn!( "检测到 {} 个凭据键残留在 Gemini 通用配置片段中,开始一次性清理", poison.len() ); let poison_keys: Vec = poison.keys().cloned().collect(); let poison_value = Value::Object(poison); let poison_text = serde_json::to_string(&poison_value) .map_err(|e| AppError::Message(format!("Serialization failed: {e}")))?; // 1) 先算出各供应商清理后的配置,但**先不落库** let providers = state.db.get_all_provider_aggregates(app.as_str())?; let mut pending: Vec<(String, Provider, Value, String)> = Vec::new(); for (id, aggregate) in providers { let provider = aggregate.provider; let fingerprint = provider_row_fingerprint(&provider); let cleaned = match live::remove_common_config_from_settings( &app, &provider.settings_config, &poison_text, ) { Ok(cleaned) => cleaned, Err(err) => { log::warn!("清理供应商 '{id}' 的泄漏凭据失败: {err}"); continue; } }; if cleaned != provider.settings_config { pending.push((id, provider, cleaned, fingerprint)); } } // 2) 落库前留一份审计记录:**只记键名与受影响的供应商,不记值**。 // // 「按值相等定向删除」在一种合法场景下也会命中:用户有意在多个供应商里 // 复用同一把 key。所以必须留下"删了什么、从哪删的",否则用户只能靠翻 // 日志。但不能留值——`settings` 表不在 `SYNC_SKIP_TABLES` 里,会随 // WebDAV/S3 同步上传,而这里处理的恰恰是必须销毁的泄漏凭据:留值等于 // 把一次清除换成一份没有界面入口、永不过期、还会跨设备扩散的明文副本。 // 密钥本来就该轮换,可恢复性不值这个代价。 let removed_env_keys = |before: &Value, after: &Value| -> Vec { let before_env = before.get("env").and_then(Value::as_object); let after_env = after.get("env").and_then(Value::as_object); match (before_env, after_env) { (Some(before_env), Some(after_env)) => before_env .keys() .filter(|key| !after_env.contains_key(*key)) .cloned() .collect(), (Some(before_env), None) => before_env.keys().cloned().collect(), _ => Vec::new(), } }; let audit = serde_json::json!({ "removedFromSnippet": poison_keys, "providers": pending .iter() .map(|(id, provider, cleaned, _)| serde_json::json!({ "id": id, "removedKeys": removed_env_keys(&provider.settings_config, cleaned), })) .collect::>(), }); let audit_text = serde_json::to_string(&audit) .map_err(|e| AppError::Message(format!("Serialization failed: {e}")))?; // 只在没有记录时写。provider 的写入不是一个事务(每次类型化行更新各自 // 提交),上一轮可能改到一半就中止;此时完成标记没置位,下次启动会重跑, // 而重跑看到的"原始状态"已经残缺。无条件 INSERT OR REPLACE 会拿这份残缺 // 记录盖掉第一轮那份完整的。 if state.db.get_setting(AUDIT_KEY)?.is_none() { state.db.set_setting(AUDIT_KEY, &audit_text)?; } // 3) 各供应商 settings_config:按值相等定向删除扩散出去的副本 for (id, mut provider, cleaned, fingerprint) in pending { provider.settings_config = cleaned; remove_hydrated_endpoints_from_row_update(&mut provider); let update = provider_to_mutation_input(provider); update_provider_record_if_unchanged(state, &app, fingerprint, update)?; log::info!("已从 Gemini 供应商 '{id}' 中清除泄漏的共享凭据"); } // 4) 代理接管中的 live 快照里也可能有一份副本。这一步的失败**必须传播**: // // 关代理时 `restore_live_config_for_app_with_fallback_inner`(proxy.rs:869) // 会把这份快照原样写回 `~/.gemini/.env`。若它仍带毒而我们照样清了片段、置了 // 完成标记,那么代理一停凭据就当场复活,而一次性标记又保证不会再清第二次; // 此后片段里已没有这个键,下一次切换的 backfill 就把它永久写进受害供应商的 // 配置——还是本函数开头那个顺序陷阱,只是换了扇门进来。 // // 带错返回是安全的失败方式:调用方(lib.rs:1189)只记 warn 不中断启动, // 片段和标记都原样留着,下次启动照原样重来。 if let Some(backup) = state.db.get_live_backup(app.as_str()).await? { let original: Value = serde_json::from_str(&backup.original_config) .map_err(|e| AppError::Message(format!("解析 Gemini 代理接管备份失败: {e}")))?; let cleaned = live::remove_common_config_from_settings(&app, &original, &poison_text)?; if cleaned != original { let text = serde_json::to_string(&cleaned) .map_err(|e| AppError::Message(format!("Serialization failed: {e}")))?; state.db.save_live_backup(app.as_str(), &text).await?; log::info!("已从 Gemini 代理接管备份中清除泄漏的共享凭据"); } } // 5) `~/.gemini/.env`:**定向**删除,且必须在清片段之前做,失败即中止。 // // 为什么不用 `sync_current_provider_for_app` 重投影:它在没有当前供应商 // 时直接返回 Ok 而根本不写文件,泄漏值会原样留在 live 里;等片段被清空 // 之后,下次切换时 `remove_common_config_from_settings` 再也认不出这个 // 键,backfill 就把它永久写进受害供应商的配置——正是本函数开头说的那个 // 顺序陷阱,只是由"没修"变成"修了一半更糟"。定向删除还顺带保住了只存在 // 于 live、与供应商无关的手工 env(重投影会把它们抹掉)。 // // 删除走 `remove_gemini_env_entries` 的**保序**实现而不是 read→HashMap→ // write 往返:后者会顺手抹掉注释、空行和无法识别的行,并按键名重排整个 // 文件。全量投影时那无所谓,但这里是一次用户没主动触发的启动期清理,不该 // 连带改写与泄漏无关的内容。 // // 失败就带着错误返回:片段此刻还留着毒键,完成标记也没置位,下次启动能 // 照原样重来。清片段是不可逆的一步,必须排在所有会失败的步骤之后。 let poison_env: HashMap = poison_value .as_object() .map(|map| { map.iter() .filter_map(|(key, value)| { value.as_str().map(|text| (key.clone(), text.to_string())) }) .collect() }) .unwrap_or_default(); if crate::gemini_config::remove_gemini_env_entries(&poison_env)? { log::info!("已从 ~/.gemini/.env 中清除泄漏的共享凭据"); } // 6) 片段本身:保留可共享的部分。全部清空时删行而不是写 "{}"——留着空行会让 // should_auto_extract_config_snippet 永远为 false,用户的合法共享配置再也 // 重建不回来。同理绝不置 cleared 标记。 if clean.is_empty() { state.db.set_config_snippet(app.as_str(), None)?; } else { let cleaned_snippet = serde_json::to_string_pretty(&Value::Object(clean)) .map_err(|e| AppError::Message(format!("Serialization failed: {e}")))?; state .db .set_config_snippet(app.as_str(), Some(cleaned_snippet))?; } state.db.set_setting(FLAG, "true")?; log::info!("Gemini 通用配置凭据清理完成"); Ok(()) } /// Extract common config for OpenCode (JSON format) fn extract_opencode_common_config(settings: &Value) -> Result { // OpenCode uses a different config structure with npm, options, models // For common config, we exclude provider-specific fields like apiKey let mut config = settings.clone(); // Remove provider-specific fields if let Some(obj) = config.as_object_mut() { if let Some(options) = obj.get_mut("options").and_then(|v| v.as_object_mut()) { options.remove("apiKey"); options.remove("baseURL"); } // Keep npm and models as they might be common } if config.is_null() || (config.is_object() && config.as_object().unwrap().is_empty()) { return Ok("{}".to_string()); } serde_json::to_string_pretty(&config) .map_err(|e| AppError::Message(format!("Serialization failed: {e}"))) } /// Extract common config for OpenClaw (JSON format) fn extract_openclaw_common_config(settings: &Value) -> Result { // OpenClaw uses a different config structure with baseUrl, apiKey, api, models // For common config, we exclude provider-specific fields like apiKey let mut config = settings.clone(); // Remove provider-specific fields if let Some(obj) = config.as_object_mut() { obj.remove("apiKey"); obj.remove("baseUrl"); // Keep api and models as they might be common } if config.is_null() || (config.is_object() && config.as_object().unwrap().is_empty()) { return Ok("{}".to_string()); } serde_json::to_string_pretty(&config) .map_err(|e| AppError::Message(format!("Serialization failed: {e}"))) } /// Import default configuration from live files (re-export) /// /// Returns `Ok(true)` if imported, `Ok(false)` if skipped. pub fn import_default_config(state: &AppState, app_type: AppType) -> Result { import_default_config(state, app_type) } pub fn should_import_default_config_on_startup( state: &AppState, app_type: &AppType, ) -> Result { should_import_default_config_on_startup(state, app_type) } /// Read current live settings (re-export) pub fn read_live_settings(app_type: AppType) -> Result { read_live_settings(app_type) } /// Get custom endpoints list (re-export) pub fn get_custom_endpoints( state: &AppState, app_type: AppType, provider_id: &str, ) -> Result, AppError> { endpoints::get_custom_endpoints(state, app_type, provider_id) } /// Add custom endpoint (re-export) pub fn add_custom_endpoint( state: &AppState, app_type: AppType, provider_id: &str, url: String, ) -> Result<(), AppError> { if matches!(app_type, AppType::Pi) { PiCatalogCoordinator::apply( state, PiCatalogMutation::AddEndpoint { provider_id: provider_id.to_string(), url, }, )?; return Ok(()); } endpoints::add_custom_endpoint(state, app_type, provider_id, url) } /// Remove custom endpoint (re-export) pub fn remove_custom_endpoint( state: &AppState, app_type: AppType, provider_id: &str, url: String, ) -> Result<(), AppError> { if matches!(app_type, AppType::Pi) { PiCatalogCoordinator::apply( state, PiCatalogMutation::RemoveEndpoint { provider_id: provider_id.to_string(), url, }, )?; return Ok(()); } endpoints::remove_custom_endpoint(state, app_type, provider_id, url) } /// Update endpoint last used timestamp (re-export) pub fn update_endpoint_last_used( state: &AppState, app_type: AppType, provider_id: &str, url: String, ) -> Result<(), AppError> { let _pi_switch_guard = matches!(app_type, AppType::Pi).then(|| { futures::executor::block_on( state .proxy_service .lock_switch_for_app(AppType::Pi.as_str()), ) }); endpoints::update_endpoint_last_used(state, app_type, provider_id, url) } /// Update provider sort order pub fn update_sort_order( state: &AppState, app_type: AppType, updates: Vec, ) -> Result { // Validate the whole payload before opening Pi's odd catalog epoch. // Returning early with an unclosed epoch would leave gateway admission // fenced until the next successful catalog mutation. let updates = updates .into_iter() .map(|update| { ProviderKey::new(app_type.as_str(), update.id).map(|key| (key, update.sort_index)) }) .collect::, _>>()?; let _pi_switch_guard = matches!(app_type, AppType::Pi).then(|| { futures::executor::block_on( state .proxy_service .lock_switch_for_app(AppType::Pi.as_str()), ) }); let pi_epoch = matches!(app_type, AppType::Pi) .then(|| futures::executor::block_on(state.proxy_service.begin_pi_catalog_mutation())); if let Err(error) = state.db.update_provider_sort_index(&updates) { if let Some(epoch) = pi_epoch { let _ = futures::executor::block_on( state.proxy_service.reconcile_pi_runtime_at_epoch(epoch), ); } return Err(error); } if let Some(epoch) = pi_epoch { futures::executor::block_on(state.proxy_service.reconcile_pi_runtime_at_epoch(epoch))?; } Ok(true) } /// Query provider usage (re-export) pub async fn query_usage( state: &AppState, app_type: AppType, provider_id: &str, ) -> Result { usage::query_usage(state, app_type, provider_id).await } /// Test usage script (re-export) #[allow(clippy::too_many_arguments)] pub async fn test_usage_script( state: &AppState, app_type: AppType, provider_id: &str, script_code: &str, timeout: u64, api_key: Option<&str>, base_url: Option<&str>, access_token: Option<&str>, user_id: Option<&str>, template_type: Option<&str>, ) -> Result { usage::test_usage_script( state, app_type, provider_id, script_code, timeout, api_key, base_url, access_token, user_id, template_type, ) .await } pub(crate) fn write_gemini_live(provider: &Provider) -> Result<(), AppError> { write_gemini_live(provider) } fn validate_provider_settings(app_type: &AppType, provider: &Provider) -> Result<(), AppError> { match app_type { AppType::Claude => { if !provider.settings_config.is_object() { return Err(AppError::localized( "provider.claude.settings.not_object", "Claude 配置必须是 JSON 对象", "Claude configuration must be a JSON object", )); } } AppType::ClaudeDesktop => { crate::claude_desktop_config::validate_provider(provider)?; } AppType::Codex => { let settings = provider.settings_config.as_object().ok_or_else(|| { AppError::localized( "provider.codex.settings.not_object", "Codex 配置必须是 JSON 对象", "Codex configuration must be a JSON object", ) })?; let auth = settings.get("auth").ok_or_else(|| { AppError::localized( "provider.codex.auth.missing", format!("供应商 {} 缺少 auth 配置", provider.id), format!("Provider {} is missing auth configuration", provider.id), ) })?; if !auth.is_object() { return Err(AppError::localized( "provider.codex.auth.not_object", format!("供应商 {} 的 auth 配置必须是 JSON 对象", provider.id), format!( "Provider {} auth configuration must be a JSON object", provider.id ), )); } if let Some(config_value) = settings.get("config") { if !(config_value.is_string() || config_value.is_null()) { return Err(AppError::localized( "provider.codex.config.invalid_type", "Codex config 字段必须是字符串", "Codex config field must be a string", )); } if let Some(cfg_text) = config_value.as_str() { crate::codex_config::validate_config_toml(cfg_text)?; } } } AppType::Gemini => { use crate::gemini_config::validate_gemini_settings; validate_gemini_settings(&provider.settings_config)? } AppType::GrokBuild => { let settings = provider.settings_config.as_object().ok_or_else(|| { AppError::localized( "provider.grokbuild.settings.not_object", "Grok Build 配置必须是 JSON 对象", "Grok Build configuration must be a JSON object", ) })?; let config = settings .get("config") .and_then(Value::as_str) .ok_or_else(|| { AppError::localized( "provider.grokbuild.config.missing", "Grok Build 配置缺少 config 字段", "Grok Build configuration is missing the config field", ) })?; if provider.category.as_deref() == Some("official") { // 官方条目走 Grok CLI 自带 OAuth:空 config 合法, // 回填快照只要求 TOML 语法合法。 crate::grok_config::validate_config_toml_syntax(config)?; } else { crate::grok_config::validate_config_toml(config)?; } } AppType::OpenCode => { // OpenCode uses a different config structure: { npm, options, models } // Basic validation - must be an object if !provider.settings_config.is_object() { return Err(AppError::localized( "provider.opencode.settings.not_object", "OpenCode 配置必须是 JSON 对象", "OpenCode configuration must be a JSON object", )); } } AppType::OpenClaw => { // OpenClaw uses config structure: { baseUrl, apiKey, api, models } // Basic validation - must be an object if !provider.settings_config.is_object() { return Err(AppError::localized( "provider.openclaw.settings.not_object", "OpenClaw 配置必须是 JSON 对象", "OpenClaw configuration must be a JSON object", )); } } AppType::Hermes => { // Hermes: accept any JSON object for now if !provider.settings_config.is_object() { return Err(AppError::localized( "provider.hermes.settings.not_object", "Hermes 配置必须是 JSON 对象", "Hermes configuration must be a JSON object", )); } } AppType::Pi => { let config: crate::pi_config::model::PiManagedProviderConfig = serde_json::from_value(provider.settings_config.clone()).map_err(|error| { AppError::localized( "provider.pi.settings.invalid", format!("Pi 配置无法解析: {error}"), format!("Pi configuration cannot be decoded: {error}"), ) })?; crate::pi_config::model::validate_pi_managed_provider(&config).map_err( |error| { AppError::localized( "provider.pi.settings.invalid", format!("Pi 配置无效: {error}"), format!("Invalid Pi configuration: {error}"), ) }, )?; } } // Validate and clean UsageScript configuration (common for all app types) if let Some(meta) = &provider.meta { if let Some(multiplier) = meta.cost_multiplier.as_deref() { validate_cost_multiplier(multiplier)?; } if let Some(source) = meta.pricing_model_source.as_deref() { validate_pricing_source(source)?; } if let Some(usage_script) = &meta.usage_script { validate_usage_script(usage_script)?; } } Ok(()) } #[allow(dead_code)] fn extract_credentials( provider: &Provider, app_type: &AppType, ) -> Result<(String, String), AppError> { match app_type { AppType::Claude => { let env = provider .settings_config .get("env") .and_then(|v| v.as_object()) .ok_or_else(|| { AppError::localized( "provider.claude.env.missing", "配置格式错误: 缺少 env", "Invalid configuration: missing env section", ) })?; let api_key = env .get("ANTHROPIC_AUTH_TOKEN") .or_else(|| env.get("ANTHROPIC_API_KEY")) .and_then(|v| v.as_str()) .ok_or_else(|| { AppError::localized( "provider.claude.api_key.missing", "缺少 API Key", "API key is missing", ) })? .to_string(); let base_url = env .get("ANTHROPIC_BASE_URL") .and_then(|v| v.as_str()) .ok_or_else(|| { AppError::localized( "provider.claude.base_url.missing", "缺少 ANTHROPIC_BASE_URL 配置", "Missing ANTHROPIC_BASE_URL configuration", ) })? .to_string(); Ok((api_key, base_url)) } AppType::GrokBuild => { let config_toml = provider .settings_config .get("config") .and_then(Value::as_str) .ok_or_else(|| { AppError::localized( "provider.grokbuild.config.missing", "Grok Build 配置缺少 config 字段", "Grok Build configuration is missing the config field", ) })?; let (base_url, api_key) = crate::grok_config::extract_credentials(config_toml) .ok_or_else(|| { AppError::localized( "provider.grokbuild.credentials.missing", "Grok Build 配置缺少 Base URL 或 API Key", "Grok Build configuration is missing the base URL or API key", ) })?; Ok((api_key, base_url)) } AppType::ClaudeDesktop => { let credentials = crate::claude_desktop_config::direct_gateway_credentials(provider)?; Ok((credentials.api_key, credentials.base_url)) } AppType::Codex => { let _auth = provider .settings_config .get("auth") .and_then(|v| v.as_object()) .ok_or_else(|| { AppError::localized( "provider.codex.auth.missing", "配置格式错误: 缺少 auth", "Invalid configuration: missing auth section", ) })?; let config_toml = provider .settings_config .get("config") .and_then(|v| v.as_str()) .unwrap_or(""); let api_key = crate::codex_config::extract_codex_api_key( provider.settings_config.get("auth"), Some(config_toml), ) .ok_or_else(|| { AppError::localized( "provider.codex.api_key.missing", "缺少 API Key", "API key is missing", ) })?; let base_url = if config_toml.contains("base_url") { let re = Regex::new(r#"base_url\s*=\s*["']([^"']+)["']"#).map_err(|e| { AppError::localized( "provider.regex_init_failed", format!("正则初始化失败: {e}"), format!("Failed to initialize regex: {e}"), ) })?; re.captures(config_toml) .and_then(|caps| caps.get(1)) .map(|m| m.as_str().to_string()) .ok_or_else(|| { AppError::localized( "provider.codex.base_url.invalid", "config.toml 中 base_url 格式错误", "base_url in config.toml has invalid format", ) })? } else { return Err(AppError::localized( "provider.codex.base_url.missing", "config.toml 中缺少 base_url 配置", "base_url is missing from config.toml", )); }; Ok((api_key, base_url)) } AppType::Gemini => { use crate::gemini_config::json_to_env; let env_map = json_to_env(&provider.settings_config)?; let api_key = env_map.get("GEMINI_API_KEY").cloned().ok_or_else(|| { AppError::localized( "gemini.missing_api_key", "缺少 GEMINI_API_KEY", "Missing GEMINI_API_KEY", ) })?; let base_url = env_map .get("GOOGLE_GEMINI_BASE_URL") .cloned() .unwrap_or_else(|| "https://generativelanguage.googleapis.com".to_string()); Ok((api_key, base_url)) } AppType::OpenCode => { // OpenCode uses options.apiKey and options.baseURL let options = provider .settings_config .get("options") .and_then(|v| v.as_object()) .ok_or_else(|| { AppError::localized( "provider.opencode.options.missing", "配置格式错误: 缺少 options", "Invalid configuration: missing options section", ) })?; let api_key = options .get("apiKey") .and_then(|v| v.as_str()) .ok_or_else(|| { AppError::localized( "provider.opencode.api_key.missing", "缺少 API Key", "API key is missing", ) })? .to_string(); let base_url = options .get("baseURL") .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); Ok((api_key, base_url)) } AppType::OpenClaw | AppType::Hermes => { // OpenClaw/Hermes use apiKey and baseUrl directly on the object let api_key = provider .settings_config .get("apiKey") .and_then(|v| v.as_str()) .ok_or_else(|| { AppError::localized( "provider.openclaw.api_key.missing", "缺少 API Key", "API key is missing", ) })? .to_string(); let base_url = provider .settings_config .get("baseUrl") .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); Ok((api_key, base_url)) } AppType::Pi => { let config: crate::pi_config::model::PiManagedProviderConfig = serde_json::from_value(provider.settings_config.clone()).map_err(|error| { AppError::Config(format!("invalid Pi provider configuration: {error}")) })?; let model = config.models.first().ok_or_else(|| { AppError::Config("Pi provider has no configured model".to_string()) })?; let effective = crate::pi_config::model::effective_pi_model(&config, &model.id) .map_err(|error| AppError::Config(format!("invalid Pi model: {error}")))?; Ok((effective.api_key.unwrap_or_default(), effective.base_url)) } } } } /// Normalize Claude model keys in a JSON value /// /// Reads old key (ANTHROPIC_SMALL_FAST_MODEL), writes new keys (DEFAULT_*), and deletes old key. pub(crate) fn normalize_claude_models_in_value(settings: &mut Value) -> bool { let mut changed = false; let env = match settings.get_mut("env").and_then(|v| v.as_object_mut()) { Some(obj) => obj, None => return changed, }; let model = env .get("ANTHROPIC_MODEL") .and_then(|v| v.as_str()) .map(|s| s.to_string()); let small_fast = env .get("ANTHROPIC_SMALL_FAST_MODEL") .and_then(|v| v.as_str()) .map(|s| s.to_string()); let current_haiku = env .get("ANTHROPIC_DEFAULT_HAIKU_MODEL") .and_then(|v| v.as_str()) .map(|s| s.to_string()); let current_sonnet = env .get("ANTHROPIC_DEFAULT_SONNET_MODEL") .and_then(|v| v.as_str()) .map(|s| s.to_string()); let current_opus = env .get("ANTHROPIC_DEFAULT_OPUS_MODEL") .and_then(|v| v.as_str()) .map(|s| s.to_string()); let target_haiku = current_haiku .or_else(|| small_fast.clone()) .or_else(|| model.clone()); let target_sonnet = current_sonnet .or_else(|| model.clone()) .or_else(|| small_fast.clone()); let target_opus = current_opus .or_else(|| model.clone()) .or_else(|| small_fast.clone()); if env.get("ANTHROPIC_DEFAULT_HAIKU_MODEL").is_none() { if let Some(v) = target_haiku { env.insert( "ANTHROPIC_DEFAULT_HAIKU_MODEL".to_string(), Value::String(v), ); changed = true; } } if env.get("ANTHROPIC_DEFAULT_SONNET_MODEL").is_none() { if let Some(v) = target_sonnet { env.insert( "ANTHROPIC_DEFAULT_SONNET_MODEL".to_string(), Value::String(v), ); changed = true; } } if env.get("ANTHROPIC_DEFAULT_OPUS_MODEL").is_none() { if let Some(v) = target_opus { env.insert("ANTHROPIC_DEFAULT_OPUS_MODEL".to_string(), Value::String(v)); changed = true; } } if env.remove("ANTHROPIC_SMALL_FAST_MODEL").is_some() { changed = true; } changed } #[derive(Debug, Clone, Deserialize)] pub struct ProviderSortUpdate { pub id: String, #[serde(rename = "sortIndex")] pub sort_index: usize, } // ============================================================================ // 统一供应商(Universal Provider)服务方法 // ============================================================================ use crate::provider::UniversalProvider; use std::collections::HashMap; impl ProviderService { /// 获取所有统一供应商 pub fn list_universal( state: &AppState, ) -> Result, AppError> { state.db.get_all_universal_providers() } /// 获取单个统一供应商 pub fn get_universal( state: &AppState, id: &str, ) -> Result, AppError> { state.db.get_universal_provider(id) } /// 添加或更新统一供应商(不自动同步,需手动调用 sync_universal_to_apps) pub fn upsert_universal( state: &AppState, provider: UniversalProvider, ) -> Result { // 保存统一供应商 state.db.save_universal_provider(&provider)?; Ok(true) } /// 删除统一供应商 pub fn delete_universal(state: &AppState, id: &str) -> Result { // 获取统一供应商(用于删除生成的子供应商) let provider = state.db.get_universal_provider(id)?; // 删除统一供应商 state.db.delete_universal_provider(id)?; // 删除生成的子供应商 if let Some(p) = provider { if p.apps.claude { let claude_id = format!("universal-claude-{id}"); let _ = state.db.delete_provider("claude", &claude_id); } if p.apps.codex { let codex_id = format!("universal-codex-{id}"); let _ = state.db.delete_provider("codex", &codex_id); } if p.apps.gemini { let gemini_id = format!("universal-gemini-{id}"); let _ = state.db.delete_provider("gemini", &gemini_id); } } Ok(true) } /// 同步统一供应商到各应用 pub fn sync_universal_to_apps(state: &AppState, id: &str) -> Result { let provider = state .db .get_universal_provider(id)? .ok_or_else(|| AppError::Message(format!("统一供应商 {id} 不存在")))?; // 同步到 Claude if let Some(mut claude_provider) = provider.to_claude_provider() { // 合并已有配置 let precondition = if let Some(existing) = state.db.get_provider_by_id(&claude_provider.id, "claude")? { let fingerprint = provider_row_fingerprint(&existing); let mut merged = existing.settings_config.clone(); Self::merge_json(&mut merged, &claude_provider.settings_config); claude_provider.settings_config = merged; ReconcilePrecondition::ExpectPresent { fingerprint } } else { ReconcilePrecondition::ExpectAbsent }; reconcile_provider_record_with_precondition( &state.db, "claude", provider_to_mutation_input(claude_provider), precondition, )?; } else { // 如果禁用了 Claude,删除对应的子供应商 let claude_id = format!("universal-claude-{id}"); let _ = state.db.delete_provider("claude", &claude_id); } // 同步到 Codex if let Some(mut codex_provider) = provider.to_codex_provider() { // 合并已有配置 let precondition = if let Some(existing) = state.db.get_provider_by_id(&codex_provider.id, "codex")? { let fingerprint = provider_row_fingerprint(&existing); let mut merged = existing.settings_config.clone(); Self::merge_json(&mut merged, &codex_provider.settings_config); codex_provider.settings_config = merged; ReconcilePrecondition::ExpectPresent { fingerprint } } else { ReconcilePrecondition::ExpectAbsent }; reconcile_provider_record_with_precondition( &state.db, "codex", provider_to_mutation_input(codex_provider), precondition, )?; } else { let codex_id = format!("universal-codex-{id}"); let _ = state.db.delete_provider("codex", &codex_id); } // 同步到 Gemini if let Some(mut gemini_provider) = provider.to_gemini_provider() { // 合并已有配置 let precondition = if let Some(existing) = state.db.get_provider_by_id(&gemini_provider.id, "gemini")? { let fingerprint = provider_row_fingerprint(&existing); let mut merged = existing.settings_config.clone(); Self::merge_json(&mut merged, &gemini_provider.settings_config); gemini_provider.settings_config = merged; ReconcilePrecondition::ExpectPresent { fingerprint } } else { ReconcilePrecondition::ExpectAbsent }; reconcile_provider_record_with_precondition( &state.db, "gemini", provider_to_mutation_input(gemini_provider), precondition, )?; } else { let gemini_id = format!("universal-gemini-{id}"); let _ = state.db.delete_provider("gemini", &gemini_id); } Ok(true) } /// 递归合并 JSON:base 为底,patch 覆盖同名字段 fn merge_json(base: &mut serde_json::Value, patch: &serde_json::Value) { use serde_json::Value; match (base, patch) { (Value::Object(base_map), Value::Object(patch_map)) => { for (k, v_patch) in patch_map { match base_map.get_mut(k) { Some(v_base) => Self::merge_json(v_base, v_patch), None => { base_map.insert(k.clone(), v_patch.clone()); } } } } // 其它类型:直接覆盖 (base_val, patch_val) => { *base_val = patch_val.clone(); } } } }