Preserve common config during proxy takeover

Update takeover backup generation to rebuild effective provider settings with common config applied before saving restore snapshots.

Keep Codex mcp_servers entries when hot-switching providers under takeover so restore does not drop live-only MCP config.

Migrate legacy providers with inferred common-config usage to explicit commonConfigEnabled=true markers during startup and default imports, and cover the new behavior with proxy and provider regression tests.
This commit is contained in:
Jason
2026-03-12 16:06:07 +08:00
parent 3dad255a2a
commit e561084f62
6 changed files with 402 additions and 24 deletions
+2
View File
@@ -117,6 +117,8 @@ fn import_default_config_internal(state: &AppState, app_type: AppType) -> Result
_ => {}
}
}
ProviderService::migrate_legacy_common_config_usage_if_needed(state, app_type.clone())?;
}
Ok(imported)
+19
View File
@@ -613,6 +613,25 @@ pub fn run() {
}
}
// 5.1 Migrate legacy providers that relied on inferred common-config usage
for app_type in [
crate::app_config::AppType::Claude,
crate::app_config::AppType::Codex,
crate::app_config::AppType::Gemini,
] {
if let Err(e) =
crate::services::provider::ProviderService::migrate_legacy_common_config_usage_if_needed(
&app_state,
app_type.clone(),
)
{
log::warn!(
"✗ Failed to migrate legacy common-config usage for {}: {e}",
app_type.as_str()
);
}
}
// 迁移旧的 app_config_dir 配置到 Store
if let Err(e) = app_store::migrate_app_config_dir_from_settings(app.handle()) {
log::warn!("迁移 app_config_dir 失败: {e}");
+17 -6
View File
@@ -461,19 +461,18 @@ fn apply_common_config_to_settings(
}
}
pub(crate) fn write_live_with_common_config(
pub(crate) fn build_effective_settings_with_common_config(
db: &Database,
app_type: &AppType,
provider: &Provider,
) -> Result<(), AppError> {
) -> Result<Value, AppError> {
let snippet = db.get_config_snippet(app_type.as_str())?;
let mut effective_provider = provider.clone();
let mut effective_settings = provider.settings_config.clone();
if provider_uses_common_config(app_type, provider, snippet.as_deref()) {
if let Some(snippet_text) = snippet.as_deref() {
match apply_common_config_to_settings(app_type, &provider.settings_config, snippet_text)
{
Ok(settings) => effective_provider.settings_config = settings,
match apply_common_config_to_settings(app_type, &effective_settings, snippet_text) {
Ok(settings) => effective_settings = settings,
Err(err) => {
log::warn!(
"Failed to apply common config for {} provider '{}': {err}",
@@ -485,6 +484,18 @@ pub(crate) fn write_live_with_common_config(
}
}
Ok(effective_settings)
}
pub(crate) fn write_live_with_common_config(
db: &Database,
app_type: &AppType,
provider: &Provider,
) -> Result<(), AppError> {
let mut effective_provider = provider.clone();
effective_provider.settings_config =
build_effective_settings_with_common_config(db, app_type, provider)?;
write_live_snapshot(app_type, &effective_provider)
}
+22 -2
View File
@@ -28,8 +28,9 @@ pub use live::{
// Internal re-exports (pub(crate))
pub(crate) use live::sanitize_claude_settings_for_live;
pub(crate) use live::{
normalize_provider_common_config_for_storage, strip_common_config_from_live_settings,
sync_current_provider_for_app_to_live, write_live_with_common_config,
build_effective_settings_with_common_config, normalize_provider_common_config_for_storage,
strip_common_config_from_live_settings, sync_current_provider_for_app_to_live,
write_live_with_common_config,
};
// Internal re-exports
@@ -670,6 +671,25 @@ impl ProviderService {
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)
}
/// Extract common config snippet from current provider
///
/// Extracts the current provider's configuration and removes provider-specific fields
+268 -16
View File
@@ -8,7 +8,9 @@ use crate::database::Database;
use crate::provider::Provider;
use crate::proxy::server::ProxyServer;
use crate::proxy::types::*;
use crate::services::provider::write_live_with_common_config;
use crate::services::provider::{
build_effective_settings_with_common_config, write_live_with_common_config,
};
use serde_json::{json, Value};
use std::str::FromStr;
use std::sync::Arc;
@@ -1528,21 +1530,37 @@ impl ProxyService {
app_type: &str,
provider: &Provider,
) -> Result<(), String> {
let backup_json = match app_type {
"claude" => {
// Claude: settings_config 直接作为备份
serde_json::to_string(&provider.settings_config)
.map_err(|e| format!("序列化 Claude 配置失败: {e}"))?
let app_type_enum =
AppType::from_str(app_type).map_err(|_| format!("未知的应用类型: {app_type}"))?;
let mut effective_settings =
build_effective_settings_with_common_config(self.db.as_ref(), &app_type_enum, provider)
.map_err(|e| format!("构建 {app_type} 有效配置失败: {e}"))?;
if matches!(app_type_enum, AppType::Codex) {
let existing_backup = self
.db
.get_live_backup(app_type)
.await
.map_err(|e| format!("读取 {app_type} 现有备份失败: {e}"))?;
if let Some(existing_backup) = existing_backup {
let existing_value: Value = serde_json::from_str(&existing_backup.original_config)
.map_err(|e| format!("解析 {app_type} 现有备份失败: {e}"))?;
Self::preserve_codex_mcp_servers_in_backup(
&mut effective_settings,
&existing_value,
)?;
}
"codex" => {
// Codex: settings_config 包含 {"auth": ..., "config": ...},直接使用
serde_json::to_string(&provider.settings_config)
.map_err(|e| format!("序列化 Codex 配置失败: {e}"))?
}
"gemini" => {
// Gemini: 只提取 env 字段(与原始备份格式一致)
// proxy.rs 的 read_gemini_live() 返回 {"env": {...}}
let env_backup = if let Some(env) = provider.settings_config.get("env") {
}
let backup_json = match app_type_enum {
AppType::Claude => serde_json::to_string(&effective_settings)
.map_err(|e| format!("序列化 Claude 配置失败: {e}"))?,
AppType::Codex => serde_json::to_string(&effective_settings)
.map_err(|e| format!("序列化 Codex 配置失败: {e}"))?,
AppType::Gemini => {
// Gemini takeover 仅修改 .envsettings.json(含 mcpServers)保持原样。
let env_backup = if let Some(env) = effective_settings.get("env") {
json!({ "env": env })
} else {
json!({ "env": {} })
@@ -1550,7 +1568,9 @@ impl ProxyService {
serde_json::to_string(&env_backup)
.map_err(|e| format!("序列化 Gemini 配置失败: {e}"))?
}
_ => return Err(format!("未知的应用类型: {app_type}")),
AppType::OpenCode | AppType::OpenClaw => {
return Err(format!("未知的应用类型: {app_type}"));
}
};
self.db
@@ -1562,6 +1582,47 @@ impl ProxyService {
Ok(())
}
fn preserve_codex_mcp_servers_in_backup(
target_settings: &mut Value,
existing_backup: &Value,
) -> Result<(), String> {
let target_obj = target_settings
.as_object_mut()
.ok_or_else(|| "Codex 备份必须是 JSON 对象".to_string())?;
let target_config = target_obj
.get("config")
.and_then(|v| v.as_str())
.unwrap_or("");
let mut target_doc = if target_config.trim().is_empty() {
toml_edit::DocumentMut::new()
} else {
target_config
.parse::<toml_edit::DocumentMut>()
.map_err(|e| format!("解析新的 Codex config.toml 失败: {e}"))?
};
let existing_config = existing_backup
.get("config")
.and_then(|v| v.as_str())
.unwrap_or("");
if existing_config.trim().is_empty() {
target_obj.insert("config".to_string(), json!(target_doc.to_string()));
return Ok(());
}
let existing_doc = existing_config
.parse::<toml_edit::DocumentMut>()
.map_err(|e| format!("解析现有 Codex 备份失败: {e}"))?;
if let Some(mcp_servers) = existing_doc.get("mcp_servers") {
target_doc["mcp_servers"] = mcp_servers.clone();
}
target_obj.insert("config".to_string(), json!(target_doc.to_string()));
Ok(())
}
/// 代理模式下切换供应商(热切换,不写 Live)
pub async fn switch_proxy_target(
&self,
@@ -1914,6 +1975,7 @@ impl ProxyService {
#[cfg(test)]
mod tests {
use super::*;
use crate::provider::ProviderMeta;
use serial_test::serial;
use std::env;
use tempfile::TempDir;
@@ -2191,4 +2253,194 @@ model = "gpt-5.1-codex"
let expected = serde_json::to_string(&provider_b.settings_config).expect("serialize");
assert_eq!(backup.original_config, expected);
}
#[tokio::test]
#[serial]
async fn update_live_backup_from_provider_applies_claude_common_config() {
let _home = TempHome::new();
crate::settings::reload_settings().expect("reload settings");
let db = Arc::new(Database::memory().expect("init db"));
db.set_config_snippet(
"claude",
Some(
serde_json::json!({
"includeCoAuthoredBy": false
})
.to_string(),
),
)
.expect("set common config snippet");
let service = ProxyService::new(db.clone());
let mut provider = Provider::with_id(
"p1".to_string(),
"P1".to_string(),
json!({
"env": {
"ANTHROPIC_AUTH_TOKEN": "token",
"ANTHROPIC_BASE_URL": "https://claude.example"
}
}),
None,
);
provider.meta = Some(ProviderMeta {
common_config_enabled: Some(true),
..Default::default()
});
service
.update_live_backup_from_provider("claude", &provider)
.await
.expect("update live backup");
let backup = db
.get_live_backup("claude")
.await
.expect("get live backup")
.expect("backup exists");
let stored: Value =
serde_json::from_str(&backup.original_config).expect("parse backup json");
assert_eq!(
stored.get("includeCoAuthoredBy").and_then(|v| v.as_bool()),
Some(false),
"common config should be applied into Claude restore backup"
);
}
#[tokio::test]
#[serial]
async fn update_live_backup_from_provider_applies_codex_common_config() {
let _home = TempHome::new();
crate::settings::reload_settings().expect("reload settings");
let db = Arc::new(Database::memory().expect("init db"));
db.set_config_snippet(
"codex",
Some("disable_response_storage = true\n".to_string()),
)
.expect("set common config snippet");
let service = ProxyService::new(db.clone());
let mut provider = Provider::with_id(
"p1".to_string(),
"P1".to_string(),
json!({
"auth": {
"OPENAI_API_KEY": "token"
},
"config": r#"model_provider = "any"
model = "gpt-5"
[model_providers.any]
base_url = "https://codex.example/v1"
"#
}),
None,
);
provider.meta = Some(ProviderMeta {
common_config_enabled: Some(true),
..Default::default()
});
service
.update_live_backup_from_provider("codex", &provider)
.await
.expect("update live backup");
let backup = db
.get_live_backup("codex")
.await
.expect("get live backup")
.expect("backup exists");
let stored: Value =
serde_json::from_str(&backup.original_config).expect("parse backup json");
let config = stored
.get("config")
.and_then(|v| v.as_str())
.expect("config string");
assert!(
config.contains("disable_response_storage = true"),
"common config should be applied into Codex restore backup"
);
}
#[tokio::test]
#[serial]
async fn update_live_backup_from_provider_preserves_codex_mcp_servers() {
let _home = TempHome::new();
crate::settings::reload_settings().expect("reload settings");
let db = Arc::new(Database::memory().expect("init db"));
let service = ProxyService::new(db.clone());
db.save_live_backup(
"codex",
&serde_json::to_string(&json!({
"auth": {
"OPENAI_API_KEY": "old-token"
},
"config": r#"model_provider = "any"
model = "gpt-4"
[model_providers.any]
base_url = "https://old.example/v1"
[mcp_servers.echo]
command = "npx"
args = ["echo-server"]
"#
}))
.expect("serialize seed backup"),
)
.await
.expect("seed live backup");
let provider = Provider::with_id(
"p2".to_string(),
"P2".to_string(),
json!({
"auth": {
"OPENAI_API_KEY": "new-token"
},
"config": r#"model_provider = "any"
model = "gpt-5"
[model_providers.any]
base_url = "https://new.example/v1"
"#
}),
None,
);
service
.update_live_backup_from_provider("codex", &provider)
.await
.expect("update live backup");
let backup = db
.get_live_backup("codex")
.await
.expect("get live backup")
.expect("backup exists");
let stored: Value =
serde_json::from_str(&backup.original_config).expect("parse backup json");
let config = stored
.get("config")
.and_then(|v| v.as_str())
.expect("config string");
assert!(
config.contains("[mcp_servers.echo]"),
"existing Codex MCP section should survive proxy hot-switch backup update"
);
assert!(
config.contains("https://new.example/v1"),
"provider-specific base_url should still update to the new provider"
);
}
}
+74
View File
@@ -21,6 +21,80 @@ fn sanitize_provider_name(name: &str) -> String {
.to_lowercase()
}
#[test]
fn migrate_legacy_common_config_usage_marks_historical_provider_enabled() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let _home = ensure_test_home();
let mut config = MultiAppConfig::default();
{
let manager = config
.get_manager_mut(&AppType::Claude)
.expect("claude manager");
manager.current = "legacy-provider".to_string();
manager.providers.insert(
"legacy-provider".to_string(),
Provider::with_id(
"legacy-provider".to_string(),
"Legacy".to_string(),
json!({
"includeCoAuthoredBy": false,
"env": {
"ANTHROPIC_API_KEY": "legacy-key"
}
}),
None,
),
);
}
let state = create_test_state_with_config(&config).expect("create test state");
state
.db
.set_config_snippet(
AppType::Claude.as_str(),
Some(r#"{ "includeCoAuthoredBy": false }"#.to_string()),
)
.expect("set common config snippet");
ProviderService::migrate_legacy_common_config_usage_if_needed(&state, AppType::Claude)
.expect("migrate legacy common config");
let providers = state
.db
.get_all_providers(AppType::Claude.as_str())
.expect("get providers after migration");
let provider = providers
.get("legacy-provider")
.expect("legacy provider exists");
assert_eq!(
provider
.meta
.as_ref()
.and_then(|meta| meta.common_config_enabled),
Some(true),
"historical provider should be explicitly marked as using common config"
);
assert!(
provider
.settings_config
.get("includeCoAuthoredBy")
.is_none(),
"common config fields should be stripped from provider storage after migration"
);
assert_eq!(
provider
.settings_config
.get("env")
.and_then(|v| v.get("ANTHROPIC_API_KEY"))
.and_then(|v| v.as_str()),
Some("legacy-key"),
"provider-specific auth should remain untouched"
);
}
#[test]
fn provider_service_switch_codex_updates_live_and_config() {
let _guard = test_mutex().lock().expect("acquire test mutex");