fix(proxy): harden takeover-residue recovery across config-dir switches (#4076)

Changing app_config_dir relocates the SQLite database, so a restart
triggered while proxy takeover is active used to strand the live
configs: the new instance reads a fresh DB with no live backups, the
first-run import then persisted the PROXY_MANAGED placeholder as the
`default` provider, and the no-backup recovery path wrote that
placeholder right back to the live files — leaving Claude/Codex/Gemini
pointed at a dead local proxy with no automatic way out.

Three orthogonal fixes, defense in depth:

- restart_app now awaits cleanup_before_exit() before app.restart().
  Since #4069 the ExitRequested handler intentionally defers restart
  requests to Tauri's default re-exec without custom cleanup, which is
  correct for same-DB restarts but not for this command's dir-change
  use case: only the old instance holds the backups needed to restore
  the taken-over live files, so it must restore them while its event
  loop is still alive.
- import_default_config refuses to import a live config that is under
  proxy takeover (placeholder detected), instead of persisting it as
  the current provider.
- restore_live_from_ssot_for_app validates that the current provider's
  settings_config does not itself contain takeover placeholders before
  writing it back; polluted SSOT now falls through to the placeholder
  cleanup fallback.

Regression tests cover the import guard and the no-backup recovery
path (the latter fails before this change by writing PROXY_MANAGED
back to live).
This commit is contained in:
Jason Young
2026-06-11 21:32:24 +08:00
committed by GitHub
parent 4f3e85fcd1
commit 7bb59fa5a6
5 changed files with 119 additions and 1 deletions
+59
View File
@@ -1911,3 +1911,62 @@ fn provider_service_delete_current_provider_returns_error() {
other => panic!("expected Config/Message error, got {other:?}"),
}
}
#[test]
fn recover_from_crash_without_backup_cleans_placeholder_instead_of_writing_it_back() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let _home = ensure_test_home();
// 接管态 Claude Live,且 DB 中无备份(模拟切换 app_config_dir 后新库首启的场景)
let taken_over_live = json!({
"env": {
"ANTHROPIC_BASE_URL": "http://127.0.0.1:15721",
"ANTHROPIC_AUTH_TOKEN": "PROXY_MANAGED"
}
});
let settings_path = get_claude_settings_path();
std::fs::create_dir_all(settings_path.parent().expect("settings dir")).expect("create dir");
std::fs::write(
&settings_path,
serde_json::to_string_pretty(&taken_over_live).expect("serialize taken over live"),
)
.expect("write taken over live");
let state = create_test_state().expect("create test state");
// 模拟历史异常:接管态 Live 已被导入成 current providerSSOT 被污染)
let provider = Provider::with_id(
"default".to_string(),
"default".to_string(),
taken_over_live.clone(),
None,
);
state
.db
.save_provider(AppType::Claude.as_str(), &provider)
.expect("save placeholder provider");
state
.db
.set_current_provider(AppType::Claude.as_str(), "default")
.expect("set current provider");
futures::executor::block_on(state.proxy_service.recover_from_crash())
.expect("recover from crash");
let live_after: serde_json::Value =
read_json_file(&settings_path).expect("read live settings after recovery");
let env = live_after.get("env").cloned().unwrap_or_else(|| json!({}));
assert_ne!(
env.get("ANTHROPIC_AUTH_TOKEN").and_then(|v| v.as_str()),
Some("PROXY_MANAGED"),
"recovery must not write the placeholder back to live"
);
assert!(
env.get("ANTHROPIC_BASE_URL")
.and_then(|v| v.as_str())
.map(|url| !url.starts_with("http://127.0.0.1"))
.unwrap_or(true),
"recovery must drop the local proxy base URL"
);
}