mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
fix(proxy): skip backup/restore when Live is already a proxy placeholder (#3689)
When previous stop_with_restore() failed to restore the user's original
Live (e.g. app crash mid-stop, settings.json unwritable, or any pre-existing
state where Live carries the proxy placeholders), the next
start_with_takeover would read the still-placeholder Live and overwrite the
good backup row with the proxy config itself. After that, every subsequent
stop would restore the proxy placeholder back to Live — making the proxy
toggle a no-op and leaving the client pinned at http://127.0.0.1:15721.
Fix: in both backup write paths (`backup_live_configs` and
`backup_live_config_strict`) detect that Live is already a proxy
placeholder and skip the save, preserving any existing good backup. In
`restore_live_config_for_app_with_fallback_inner`, detect the same
condition in the parsed backup and fall through to the existing
SSOT (current provider DB) path that was added in c3d810a.
Both sides share a new `live_has_proxy_placeholder_for_app` dispatch
helper so the placeholder check stays in lockstep with the existing
per-app detection functions.
Co-authored-by: Yongmao Luo <yongmao.luo@columbia.edu>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+323
-21
@@ -1086,32 +1086,48 @@ impl ProxyService {
|
||||
async fn backup_live_configs(&self) -> Result<(), String> {
|
||||
// Claude
|
||||
if let Ok(config) = self.read_claude_live() {
|
||||
let json_str = serde_json::to_string(&config)
|
||||
.map_err(|e| format!("序列化 Claude 配置失败: {e}"))?;
|
||||
self.db
|
||||
.save_live_backup("claude", &json_str)
|
||||
.await
|
||||
.map_err(|e| format!("备份 Claude 配置失败: {e}"))?;
|
||||
// 跳过已被代理接管的 Live:避免把代理占位符当作"原始 Live"存进备份槽。
|
||||
// 否则下次 start_with_takeover 在异常历史状态下(Live 已是占位符)再次
|
||||
// 调用本函数,会用代理配置覆盖一个原本正常的备份;之后 stop 恢复时
|
||||
// 即便走到备份路径也会把代理占位符再写回 Live,永久卡在 127.0.0.1:15721。
|
||||
if Self::live_has_proxy_placeholder_for_app(&AppType::Claude, &config) {
|
||||
log::warn!("claude Live 已被代理接管,不备份(避免把代理配置固化进备份槽);下次 stop 会从 SSOT 重建 Live");
|
||||
} else {
|
||||
let json_str = serde_json::to_string(&config)
|
||||
.map_err(|e| format!("序列化 Claude 配置失败: {e}"))?;
|
||||
self.db
|
||||
.save_live_backup("claude", &json_str)
|
||||
.await
|
||||
.map_err(|e| format!("备份 Claude 配置失败: {e}"))?;
|
||||
}
|
||||
}
|
||||
|
||||
// Codex
|
||||
if let Ok(config) = self.read_codex_live() {
|
||||
let json_str = serde_json::to_string(&config)
|
||||
.map_err(|e| format!("序列化 Codex 配置失败: {e}"))?;
|
||||
self.db
|
||||
.save_live_backup("codex", &json_str)
|
||||
.await
|
||||
.map_err(|e| format!("备份 Codex 配置失败: {e}"))?;
|
||||
if Self::live_has_proxy_placeholder_for_app(&AppType::Codex, &config) {
|
||||
log::warn!("codex Live 已被代理接管,不备份(避免把代理配置固化进备份槽);下次 stop 会从 SSOT 重建 Live");
|
||||
} else {
|
||||
let json_str = serde_json::to_string(&config)
|
||||
.map_err(|e| format!("序列化 Codex 配置失败: {e}"))?;
|
||||
self.db
|
||||
.save_live_backup("codex", &json_str)
|
||||
.await
|
||||
.map_err(|e| format!("备份 Codex 配置失败: {e}"))?;
|
||||
}
|
||||
}
|
||||
|
||||
// Gemini
|
||||
if let Ok(config) = self.read_gemini_live() {
|
||||
let json_str = serde_json::to_string(&config)
|
||||
.map_err(|e| format!("序列化 Gemini 配置失败: {e}"))?;
|
||||
self.db
|
||||
.save_live_backup("gemini", &json_str)
|
||||
.await
|
||||
.map_err(|e| format!("备份 Gemini 配置失败: {e}"))?;
|
||||
if Self::live_has_proxy_placeholder_for_app(&AppType::Gemini, &config) {
|
||||
log::warn!("gemini Live 已被代理接管,不备份(避免把代理配置固化进备份槽);下次 stop 会从 SSOT 重建 Live");
|
||||
} else {
|
||||
let json_str = serde_json::to_string(&config)
|
||||
.map_err(|e| format!("序列化 Gemini 配置失败: {e}"))?;
|
||||
self.db
|
||||
.save_live_backup("gemini", &json_str)
|
||||
.await
|
||||
.map_err(|e| format!("备份 Gemini 配置失败: {e}"))?;
|
||||
}
|
||||
}
|
||||
|
||||
log::info!("已备份所有应用的 Live 配置");
|
||||
@@ -1127,6 +1143,15 @@ impl ProxyService {
|
||||
_ => return Err("该应用不支持代理功能".to_string()),
|
||||
};
|
||||
|
||||
// 跳过已被代理接管的 Live:避免把代理占位符当作"原始 Live"存进备份槽
|
||||
// (见 backup_live_configs 中的注释)。
|
||||
if Self::live_has_proxy_placeholder_for_app(app_type, &config) {
|
||||
log::warn!(
|
||||
"{app_type_str} Live 已被代理接管,不备份(避免把代理配置固化进备份槽);下次 stop 会从 SSOT 重建 Live"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let json_str = serde_json::to_string(&config)
|
||||
.map_err(|e| format!("序列化 {app_type_str} 配置失败: {e}"))?;
|
||||
self.db
|
||||
@@ -1462,9 +1487,19 @@ impl ProxyService {
|
||||
if let Some(backup) = backup {
|
||||
let config: Value = serde_json::from_str(&backup.original_config)
|
||||
.map_err(|e| format!("解析 {app_type_str} 备份失败: {e}"))?;
|
||||
self.write_live_config_for_app(app_type, &config)?;
|
||||
log::info!("{app_type_str} Live 配置已从备份恢复");
|
||||
return Ok(());
|
||||
|
||||
// 备份若是代理占位符(异常历史:上次 stop 失败导致 Live 留在了代理状态,
|
||||
// 下次接管时又被错误地备份成"原始 Live"),不能直接用 — 否则 stop 后
|
||||
// Live 永远卡在 127.0.0.1:15721。落到下面的 SSOT 兜底重建。
|
||||
if Self::live_has_proxy_placeholder_for_app(app_type, &config) {
|
||||
log::warn!(
|
||||
"{app_type_str} 备份本身已是代理占位符(异常历史状态),跳过备份,改走 SSOT 重建 Live"
|
||||
);
|
||||
} else {
|
||||
self.write_live_config_for_app(app_type, &config)?;
|
||||
log::info!("{app_type_str} Live 配置已从备份恢复");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 兜底:备份缺失,但 Live 仍包含接管占位符(异常退出/历史 bug 场景)
|
||||
@@ -1845,6 +1880,21 @@ impl ProxyService {
|
||||
env.get("GEMINI_API_KEY").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)
|
||||
}
|
||||
|
||||
/// 判断给定的 Live/备份配置是否已被代理接管(包含占位符)
|
||||
///
|
||||
/// 用途:检测"备份里存的其实是代理配置"这种异常历史状态。
|
||||
/// 如果发现,备份不可信,备份路径不能写入(否则会把代理配置固化进备份槽),
|
||||
/// 恢复路径不能读取(否则会把代理占位符原样写回 Live,永久卡在代理地址)。
|
||||
/// 两种情况下都应该走 SSOT 兜底重建 Live。
|
||||
fn live_has_proxy_placeholder_for_app(app_type: &AppType, config: &Value) -> bool {
|
||||
match app_type {
|
||||
AppType::Claude => Self::is_claude_live_taken_over(config),
|
||||
AppType::Codex => Self::codex_live_has_proxy_placeholder(config),
|
||||
AppType::Gemini => Self::is_gemini_live_taken_over(config),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 从供应商配置更新 Live 备份(用于代理模式下的热切换)
|
||||
///
|
||||
/// 与 backup_live_configs() 不同,此方法从供应商的 settings_config 生成备份,
|
||||
@@ -5528,4 +5578,256 @@ requires_openai_auth = true
|
||||
"empty-auth restore must delete auth.json rather than write an empty one"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression: when the backup row itself contains the proxy placeholder
|
||||
/// (a corrupted state where previous start/stop cycles saved the proxy
|
||||
/// config as the "original Live"), restore must NOT write it back to Live.
|
||||
/// It should fall through to the SSOT (current provider) path and rebuild
|
||||
/// Live from the provider DB instead.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn restore_falls_through_to_ssot_when_backup_is_proxy_placeholder() {
|
||||
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());
|
||||
|
||||
// Seed DB with a current provider that has a real API key
|
||||
let provider = Provider::with_id(
|
||||
"p1".to_string(),
|
||||
"P1".to_string(),
|
||||
json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.minimaxi.com/anthropic",
|
||||
"ANTHROPIC_API_KEY": "real-key-from-db"
|
||||
}
|
||||
}),
|
||||
None,
|
||||
);
|
||||
db.save_provider("claude", &provider)
|
||||
.expect("save provider");
|
||||
db.set_current_provider("claude", "p1")
|
||||
.expect("set current provider");
|
||||
|
||||
// Seed backup with proxy placeholder (the corrupted state)
|
||||
let corrupted_backup = serde_json::to_string(&json!({
|
||||
"env": {
|
||||
"ANTHROPIC_AUTH_TOKEN": PROXY_TOKEN_PLACEHOLDER,
|
||||
"ANTHROPIC_BASE_URL": "http://127.0.0.1:15721"
|
||||
}
|
||||
}))
|
||||
.expect("serialize corrupted backup");
|
||||
db.save_live_backup("claude", &corrupted_backup)
|
||||
.await
|
||||
.expect("seed corrupted backup");
|
||||
|
||||
// Seed Live with the same proxy placeholder (matches the corrupted state)
|
||||
service
|
||||
.write_claude_live(&json!({
|
||||
"env": {
|
||||
"ANTHROPIC_AUTH_TOKEN": PROXY_TOKEN_PLACEHOLDER,
|
||||
"ANTHROPIC_BASE_URL": "http://127.0.0.1:15721"
|
||||
}
|
||||
}))
|
||||
.expect("seed taken-over live file");
|
||||
|
||||
// Restore: must NOT use the corrupted backup
|
||||
service
|
||||
.restore_live_config_for_app_with_fallback(&AppType::Claude)
|
||||
.await
|
||||
.expect("restore should succeed via SSOT");
|
||||
|
||||
// The backup should still be the corrupted one (we didn't touch it on this path)
|
||||
let backup_after = db
|
||||
.get_live_backup("claude")
|
||||
.await
|
||||
.expect("get backup")
|
||||
.expect("backup still exists");
|
||||
assert_eq!(
|
||||
backup_after.original_config, corrupted_backup,
|
||||
"restore must NOT overwrite the corrupted backup"
|
||||
);
|
||||
|
||||
// Live should now reflect the SSOT (provider DB), NOT the proxy URL
|
||||
let restored_live = service.read_claude_live().expect("read live");
|
||||
let restored_url = restored_live
|
||||
.get("env")
|
||||
.and_then(|env| env.get("ANTHROPIC_BASE_URL"))
|
||||
.and_then(|v| v.as_str());
|
||||
assert_eq!(
|
||||
restored_url,
|
||||
Some("https://api.minimaxi.com/anthropic"),
|
||||
"Live must be rebuilt from SSOT, not from the corrupted backup"
|
||||
);
|
||||
let restored_key = restored_live
|
||||
.get("env")
|
||||
.and_then(|env| env.get("ANTHROPIC_API_KEY"))
|
||||
.and_then(|v| v.as_str());
|
||||
assert_eq!(
|
||||
restored_key,
|
||||
Some("real-key-from-db"),
|
||||
"Live must carry the real API key from the provider DB"
|
||||
);
|
||||
assert_ne!(
|
||||
restored_live
|
||||
.get("env")
|
||||
.and_then(|env| env.get("ANTHROPIC_AUTH_TOKEN"))
|
||||
.and_then(|v| v.as_str()),
|
||||
Some(PROXY_TOKEN_PLACEHOLDER),
|
||||
"Live must not still carry the proxy placeholder"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression: when Live is already a proxy placeholder (a corrupted state
|
||||
/// where previous stop failed to restore), backup must NOT overwrite a
|
||||
/// previously-good backup with the proxy config. This prevents the bug
|
||||
/// where stop-then-start cycles permanently corrupt the backup.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn backup_skips_when_live_is_already_proxy_placeholder() {
|
||||
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());
|
||||
|
||||
// Seed a GOOD backup (the "real" original Live)
|
||||
let good_backup = serde_json::to_string(&json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.minimaxi.com/anthropic",
|
||||
"ANTHROPIC_AUTH_TOKEN": "real-token"
|
||||
}
|
||||
}))
|
||||
.expect("serialize good backup");
|
||||
db.save_live_backup("claude", &good_backup)
|
||||
.await
|
||||
.expect("seed good backup");
|
||||
|
||||
// Seed Live with proxy placeholder (the corrupted state)
|
||||
service
|
||||
.write_claude_live(&json!({
|
||||
"env": {
|
||||
"ANTHROPIC_AUTH_TOKEN": PROXY_TOKEN_PLACEHOLDER,
|
||||
"ANTHROPIC_BASE_URL": "http://127.0.0.1:15721"
|
||||
}
|
||||
}))
|
||||
.expect("seed taken-over live file");
|
||||
|
||||
// Call backup_live_config_strict: must skip
|
||||
service
|
||||
.backup_live_config_strict(&AppType::Claude)
|
||||
.await
|
||||
.expect("backup should succeed (no-op when live is placeholder)");
|
||||
|
||||
// The good backup must still be intact
|
||||
let backup_after = db
|
||||
.get_live_backup("claude")
|
||||
.await
|
||||
.expect("get backup")
|
||||
.expect("backup still exists");
|
||||
assert_eq!(
|
||||
backup_after.original_config, good_backup,
|
||||
"must not overwrite a good backup with a proxy placeholder"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression: when ALL apps have Live=proxy-placeholder (worst-case
|
||||
/// corrupted state), the bulk `backup_live_configs` path used by
|
||||
/// `start_with_takeover` must skip every save — instead of overwriting
|
||||
/// good backups with the proxy config.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn bulk_backup_skips_all_when_live_is_proxy_placeholder() {
|
||||
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());
|
||||
|
||||
// Seed good backups for all three apps
|
||||
let good_backup = serde_json::to_string(&json!({
|
||||
"env": {
|
||||
"ANTHROPIC_AUTH_TOKEN": "real-token"
|
||||
}
|
||||
}))
|
||||
.expect("serialize good backup");
|
||||
db.save_live_backup("claude", &good_backup)
|
||||
.await
|
||||
.expect("seed claude backup");
|
||||
|
||||
let codex_good_backup = serde_json::to_string(&json!({
|
||||
"auth": { "OPENAI_API_KEY": "real-codex-token" }
|
||||
}))
|
||||
.expect("serialize codex good backup");
|
||||
db.save_live_backup("codex", &codex_good_backup)
|
||||
.await
|
||||
.expect("seed codex backup");
|
||||
|
||||
let gemini_good_backup = serde_json::to_string(&json!({
|
||||
"env": { "GEMINI_API_KEY": "real-gemini-key" }
|
||||
}))
|
||||
.expect("serialize gemini good backup");
|
||||
db.save_live_backup("gemini", &gemini_good_backup)
|
||||
.await
|
||||
.expect("seed gemini backup");
|
||||
|
||||
// Seed all three Live files with proxy placeholders
|
||||
service
|
||||
.write_claude_live(&json!({
|
||||
"env": {
|
||||
"ANTHROPIC_AUTH_TOKEN": PROXY_TOKEN_PLACEHOLDER,
|
||||
"ANTHROPIC_BASE_URL": "http://127.0.0.1:15721"
|
||||
}
|
||||
}))
|
||||
.expect("seed claude live");
|
||||
let codex_dir = crate::codex_config::get_codex_config_dir();
|
||||
std::fs::create_dir_all(&codex_dir).expect("create codex dir");
|
||||
std::fs::write(
|
||||
crate::codex_config::get_codex_config_path(),
|
||||
r#"model_provider = "custom"
|
||||
|
||||
[model_providers.custom]
|
||||
name = "Custom"
|
||||
base_url = "http://127.0.0.1:15721/v1"
|
||||
wire_api = "chat"
|
||||
experimental_bearer_token = "PROXY_MANAGED"
|
||||
"#,
|
||||
)
|
||||
.expect("seed codex config.toml");
|
||||
std::fs::write(
|
||||
crate::codex_config::get_codex_auth_path(),
|
||||
r#"{"OPENAI_API_KEY":"PROXY_MANAGED"}"#,
|
||||
)
|
||||
.expect("seed codex auth.json");
|
||||
let gemini_env_path = crate::gemini_config::get_gemini_env_path();
|
||||
if let Some(parent) = gemini_env_path.parent() {
|
||||
std::fs::create_dir_all(parent).expect("create gemini dir");
|
||||
}
|
||||
std::fs::write(&gemini_env_path, "GEMINI_API_KEY=PROXY_MANAGED\n")
|
||||
.expect("seed gemini env");
|
||||
|
||||
// Call bulk backup: must skip all three apps
|
||||
service
|
||||
.backup_live_configs()
|
||||
.await
|
||||
.expect("bulk backup should succeed (no-op when all live are placeholders)");
|
||||
|
||||
// All three good backups must still be intact
|
||||
for (app_type, original) in [
|
||||
("claude", good_backup.as_str()),
|
||||
("codex", codex_good_backup.as_str()),
|
||||
("gemini", gemini_good_backup.as_str()),
|
||||
] {
|
||||
let backup_after = db
|
||||
.get_live_backup(app_type)
|
||||
.await
|
||||
.expect("get backup")
|
||||
.expect("backup still exists");
|
||||
assert_eq!(
|
||||
backup_after.original_config, original,
|
||||
"must not overwrite good backup for {app_type} with proxy placeholder"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user