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:
Yongmao Luo
2026-06-04 22:54:56 +08:00
committed by GitHub
parent 0527002cca
commit 8047f95416
+323 -21
View File
@@ -1086,32 +1086,48 @@ impl ProxyService {
async fn backup_live_configs(&self) -> Result<(), String> { async fn backup_live_configs(&self) -> Result<(), String> {
// Claude // Claude
if let Ok(config) = self.read_claude_live() { if let Ok(config) = self.read_claude_live() {
let json_str = serde_json::to_string(&config) // 跳过已被代理接管的 Live:避免把代理占位符当作"原始 Live"存进备份槽。
.map_err(|e| format!("序列化 Claude 配置失败: {e}"))?; // 否则下次 start_with_takeover 在异常历史状态下(Live 已是占位符)再次
self.db // 调用本函数,会用代理配置覆盖一个原本正常的备份;之后 stop 恢复时
.save_live_backup("claude", &json_str) // 即便走到备份路径也会把代理占位符再写回 Live,永久卡在 127.0.0.1:15721。
.await if Self::live_has_proxy_placeholder_for_app(&AppType::Claude, &config) {
.map_err(|e| format!("备份 Claude 配置失败: {e}"))?; 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 // Codex
if let Ok(config) = self.read_codex_live() { if let Ok(config) = self.read_codex_live() {
let json_str = serde_json::to_string(&config) if Self::live_has_proxy_placeholder_for_app(&AppType::Codex, &config) {
.map_err(|e| format!("序列化 Codex 配置失败: {e}"))?; log::warn!("codex Live 已被代理接管,不备份(避免把代理配置固化进备份槽);下次 stop 会从 SSOT 重建 Live");
self.db } else {
.save_live_backup("codex", &json_str) let json_str = serde_json::to_string(&config)
.await .map_err(|e| format!("序列化 Codex 配置失败: {e}"))?;
.map_err(|e| format!("备份 Codex 配置失败: {e}"))?; self.db
.save_live_backup("codex", &json_str)
.await
.map_err(|e| format!("备份 Codex 配置失败: {e}"))?;
}
} }
// Gemini // Gemini
if let Ok(config) = self.read_gemini_live() { if let Ok(config) = self.read_gemini_live() {
let json_str = serde_json::to_string(&config) if Self::live_has_proxy_placeholder_for_app(&AppType::Gemini, &config) {
.map_err(|e| format!("序列化 Gemini 配置失败: {e}"))?; log::warn!("gemini Live 已被代理接管,不备份(避免把代理配置固化进备份槽);下次 stop 会从 SSOT 重建 Live");
self.db } else {
.save_live_backup("gemini", &json_str) let json_str = serde_json::to_string(&config)
.await .map_err(|e| format!("序列化 Gemini 配置失败: {e}"))?;
.map_err(|e| format!("备份 Gemini 配置失败: {e}"))?; self.db
.save_live_backup("gemini", &json_str)
.await
.map_err(|e| format!("备份 Gemini 配置失败: {e}"))?;
}
} }
log::info!("已备份所有应用的 Live 配置"); log::info!("已备份所有应用的 Live 配置");
@@ -1127,6 +1143,15 @@ impl ProxyService {
_ => return Err("该应用不支持代理功能".to_string()), _ => 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) let json_str = serde_json::to_string(&config)
.map_err(|e| format!("序列化 {app_type_str} 配置失败: {e}"))?; .map_err(|e| format!("序列化 {app_type_str} 配置失败: {e}"))?;
self.db self.db
@@ -1462,9 +1487,19 @@ impl ProxyService {
if let Some(backup) = backup { if let Some(backup) = backup {
let config: Value = serde_json::from_str(&backup.original_config) let config: Value = serde_json::from_str(&backup.original_config)
.map_err(|e| format!("解析 {app_type_str} 备份失败: {e}"))?; .map_err(|e| format!("解析 {app_type_str} 备份失败: {e}"))?;
self.write_live_config_for_app(app_type, &config)?;
log::info!("{app_type_str} Live 配置已从备份恢复"); // 备份若是代理占位符(异常历史:上次 stop 失败导致 Live 留在了代理状态,
return Ok(()); // 下次接管时又被错误地备份成"原始 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 场景) // 2) 兜底:备份缺失,但 Live 仍包含接管占位符(异常退出/历史 bug 场景)
@@ -1845,6 +1880,21 @@ impl ProxyService {
env.get("GEMINI_API_KEY").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) 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 备份(用于代理模式下的热切换) /// 从供应商配置更新 Live 备份(用于代理模式下的热切换)
/// ///
/// 与 backup_live_configs() 不同,此方法从供应商的 settings_config 生成备份, /// 与 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" "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"
);
}
}
} }