From c7cecce7aad2a1ccc534f7dbea0db149a040d6c7 Mon Sep 17 00:00:00 2001 From: zhushihao <11657413+zhushihao@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:44:38 +0800 Subject: [PATCH] fix(exit): stop misclassifying clean macOS exits and keep takeover backups - Add macOS process identity via proc_pidpath + proc_pidinfo instead of /proc - Classify a clean exit without crash as CleanExit so it no longer triggers recovery warnings - Keep a clean live backup for apps whose takeover remains enabled on exit - Rebuild a missing or placeholder backup from the current provider before restart takeover - Restore the full user backup on crash restart instead of falling back to provider-only SSOT --- src-tauri/src/app_exit_monitor.rs | 105 ++++++- src-tauri/src/lib.rs | 1 + src-tauri/src/process_identity.rs | 54 +++- src-tauri/src/services/proxy.rs | 482 ++++++++++++++++++++++++++---- 4 files changed, 582 insertions(+), 60 deletions(-) diff --git a/src-tauri/src/app_exit_monitor.rs b/src-tauri/src/app_exit_monitor.rs index 4ae3cd7c..a9390ec7 100644 --- a/src-tauri/src/app_exit_monitor.rs +++ b/src-tauri/src/app_exit_monitor.rs @@ -31,6 +31,7 @@ pub enum PreviousRunClassification { ActivePreviousInstance, ConfirmedCrash, PlannedRestartOrUpdate, + CleanExit, UncleanExit, } @@ -250,16 +251,22 @@ fn classify_previous_run( .filter(|event| event.pid == marker.pid && event.timestamp >= marker.started_at); let mut confirmed_crash = crash_log_modified_after_marker; let mut planned_restart = false; + let mut clean_exit = false; for event in same_pid_events { if matches!(event.kind.as_str(), "panic" | "forced_exit") { confirmed_crash = true; } - if event.kind == "clean_exit" && is_planned_exit_reason(&event.reason) { - planned_restart = true; + if event.kind == "clean_exit" { + clean_exit = true; + if is_planned_exit_reason(&event.reason) { + planned_restart = true; + } } } if confirmed_crash { PreviousRunClassification::ConfirmedCrash + } else if clean_exit && !planned_restart { + PreviousRunClassification::CleanExit } else if planned_restart { PreviousRunClassification::PlannedRestartOrUpdate } else { @@ -509,6 +516,100 @@ mod tests { ); } + #[test] + fn clean_exit_without_restart_is_clean_not_unclean_or_crash() { + let marker = marker_with_identity(); + let events = vec![ExitEvent { + timestamp: "2026-08-25 10:01:00.000".to_string(), + kind: "clean_exit".to_string(), + reason: "user_requested_exit".to_string(), + exit_code: Some(0), + version: "test".to_string(), + os: "test".to_string(), + arch: "test".to_string(), + pid: marker.pid, + details: None, + }]; + + assert_eq!( + classify_previous_run( + Some(&marker), + &events, + false, + None, + marker.config_scope.as_deref().expect("marker scope"), + ), + PreviousRunClassification::CleanExit + ); + } + + #[test] + fn clean_exit_with_restart_reason_remains_a_planned_restart() { + let marker = marker_with_identity(); + let events = vec![ExitEvent { + timestamp: "2026-08-25 10:01:00.000".to_string(), + kind: "clean_exit".to_string(), + reason: "process_restart".to_string(), + exit_code: Some(0), + version: "test".to_string(), + os: "test".to_string(), + arch: "test".to_string(), + pid: marker.pid, + details: None, + }]; + + assert_eq!( + classify_previous_run( + Some(&marker), + &events, + false, + None, + marker.config_scope.as_deref().expect("marker scope"), + ), + PreviousRunClassification::PlannedRestartOrUpdate + ); + } + + #[test] + fn panic_after_clean_exit_is_still_a_confirmed_crash() { + let marker = marker_with_identity(); + let events = vec![ + ExitEvent { + timestamp: "2026-08-25 10:01:00.000".to_string(), + kind: "clean_exit".to_string(), + reason: "user_requested_exit".to_string(), + exit_code: Some(0), + version: "test".to_string(), + os: "test".to_string(), + arch: "test".to_string(), + pid: marker.pid, + details: None, + }, + ExitEvent { + timestamp: "2026-08-25 10:05:00.000".to_string(), + kind: "panic".to_string(), + reason: "test panic".to_string(), + exit_code: None, + version: "test".to_string(), + os: "test".to_string(), + arch: "test".to_string(), + pid: marker.pid, + details: None, + }, + ]; + + assert_eq!( + classify_previous_run( + Some(&marker), + &events, + false, + None, + marker.config_scope.as_deref().expect("marker scope"), + ), + PreviousRunClassification::ConfirmedCrash + ); + } + #[test] fn marker_ownership_requires_the_complete_current_process_identity() { let current = crate::process_identity::current_process_identity() diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a135028c..cdd3985d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -540,6 +540,7 @@ pub fn run() { RecoverySeverity::Info, "none", )), + app_exit_monitor::PreviousRunClassification::CleanExit => None, app_exit_monitor::PreviousRunClassification::NoPreviousRun => None, }; if let Some((kind, severity, next_step)) = mapped { diff --git a/src-tauri/src/process_identity.rs b/src-tauri/src/process_identity.rs index 4bb2b838..638253c4 100644 --- a/src-tauri/src/process_identity.rs +++ b/src-tauri/src/process_identity.rs @@ -168,7 +168,57 @@ pub(crate) fn tcp_listener_owner_pid(port: u16) -> Option { None } -#[cfg(all(unix, not(target_os = "windows")))] +#[cfg(target_os = "macos")] +pub(crate) fn process_identity(pid: u32) -> Option { + use libc::{proc_pidinfo, proc_pidpath, PROC_PIDTBSDINFO}; + + if pid == 0 { + return None; + } + + let mut path_buf = [0u8; 4096]; + let path_len = unsafe { + proc_pidpath( + pid as libc::pid_t, + path_buf.as_mut_ptr().cast(), + path_buf.len() as u32, + ) + }; + if path_len <= 0 { + return None; + } + let executable_path = String::from_utf8_lossy(&path_buf[..path_len as usize]).into_owned(); + + let mut info = std::mem::MaybeUninit::::uninit(); + let size = std::mem::size_of::(); + if unsafe { + proc_pidinfo( + pid as libc::c_int, + PROC_PIDTBSDINFO, + 0, + info.as_mut_ptr().cast(), + size as libc::c_int, + ) + } != size as libc::c_int + { + return None; + } + let info = unsafe { info.assume_init() }; + let started_at_ticks = info.pbi_start_tvsec.saturating_mul(1_000_000) + info.pbi_start_tvusec; + + Some(ProcessIdentity { + pid, + executable_path, + started_at_ticks, + }) +} + +#[cfg(target_os = "macos")] +pub(crate) fn tcp_listener_owner_pid(_port: u16) -> Option { + None +} + +#[cfg(all(unix, not(any(target_os = "windows", target_os = "macos"))))] pub(crate) fn process_identity(pid: u32) -> Option { let proc_dir = PathBuf::from("/proc").join(pid.to_string()); let executable_path = std::fs::read_link(proc_dir.join("exe")) @@ -189,7 +239,7 @@ pub(crate) fn process_identity(pid: u32) -> Option { }) } -#[cfg(all(unix, not(target_os = "windows")))] +#[cfg(all(unix, not(any(target_os = "windows", target_os = "macos"))))] pub(crate) fn tcp_listener_owner_pid(_port: u16) -> Option { None } diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index e350e68a..c2686c67 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -1069,7 +1069,9 @@ impl ProxyService { // 3) 备份 Live 配置(严格:目标 app 不存在则报错) if restore_existing_backup_before_takeover { - self.restore_live_config_for_app_inner(&app).await?; + self.restore_live_config_for_app_with_fallback_inner(&app) + .await?; + self.ensure_clean_live_backup_for_app_locked(&app).await?; } else { self.backup_live_config_strict(&app).await?; @@ -1083,7 +1085,10 @@ impl ProxyService { // 5) 写入接管配置(仅当前 app) if let Err(e) = self.takeover_live_config_strict(&app).await { log::error!("{app_type_str} 接管 Live 配置失败,尝试恢复: {e}"); - match self.restore_live_config_for_app_inner(&app).await { + match self + .restore_live_config_for_app_with_fallback_inner(&app) + .await + { Ok(()) => { // 恢复成功才清理备份,避免失败场景下丢失唯一可回滚来源 let _ = self.db.delete_live_backup(app_type_str).await; @@ -1323,7 +1328,10 @@ impl ProxyService { if !current_config.enabled || !has_backup || !live_matches_current_proxy { if has_backup { - self.restore_live_config_for_app_inner(app_type).await?; + self.restore_live_config_for_app_with_fallback_inner(app_type) + .await?; + self.ensure_clean_live_backup_for_app_locked(app_type) + .await?; } else { self.backup_live_config_strict(app_type).await?; if let Err(e) = self.sync_live_to_provider(app_type).await { @@ -1344,7 +1352,9 @@ impl ProxyService { let _ = self.db.set_current_provider(app_type_str, previous_id); let _ = crate::settings::set_current_provider(app_type, Some(previous_id)); } - let _ = self.restore_live_config_for_app_inner(app_type).await; + let _ = self + .restore_live_config_for_app_with_fallback_inner(app_type) + .await; return Err(e); } let provider = self @@ -1385,7 +1395,9 @@ impl ProxyService { config.enabled = false; let _ = self.db.update_proxy_config_for_app(config).await; } - let _ = self.restore_live_config_for_app_inner(app_type).await; + let _ = self + .restore_live_config_for_app_with_fallback_inner(app_type) + .await; return Err(e); } @@ -1831,11 +1843,41 @@ impl ProxyService { let _ = self.db.update_proxy_config(config).await; } - // 4. 删除备份(Live 配置已恢复,备份不再需要) - self.db - .delete_all_live_backups() - .await - .map_err(|e| format!("删除备份失败: {e}"))?; + // 4. 按应用保留/清理备份: + // - 仍启用接管的应用必须留一份干净备份,供下次启动自动恢复时使用; + // - 手动关闭接管的应用才删除备份,避免长期保存敏感登录材料。 + for (app_type_str, app_type) in [ + ("claude", AppType::Claude), + ("codex", AppType::Codex), + ("gemini", AppType::Gemini), + ("grokbuild", AppType::GrokBuild), + ] { + let enabled = self + .db + .get_proxy_config_for_app(app_type_str) + .await + .map(|config| config.enabled) + .unwrap_or(false); + if enabled { + match self.ensure_clean_live_backup_for_app(&app_type).await { + Ok(_) => { + log::info!("已保留 {app_type_str} 干净 Live 备份(下次启动自动接管)"); + } + Err(error) => { + log::error!( + "退出时重建 {app_type_str} Live 备份失败,将停用该应用的自动接管,并保留现有备份: {error}" + ); + if let Ok(mut config) = self.db.get_proxy_config_for_app(app_type_str).await + { + config.enabled = false; + let _ = self.db.update_proxy_config_for_app(config).await; + } + } + } + } else if let Err(error) = self.db.delete_live_backup(app_type_str).await { + log::warn!("删除 {app_type_str} Live 备份失败: {error}"); + } + } // 5. 重置健康状态 self.db @@ -1926,10 +1968,11 @@ impl ProxyService { // 跳过已被代理接管的 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(()); + log::warn!("{app_type_str} Live 已被代理接管;保留现有干净备份,缺失时从供应商重建"); + return self + .ensure_clean_live_backup_for_app_locked(app_type) + .await + .map(|_| ()); } let json_str = serde_json::to_string(&config) @@ -1942,6 +1985,78 @@ impl ProxyService { Ok(()) } + /// 确保指定应用存在一份干净的 Live 备份(含锁版本)。 + /// + /// 规则:已有干净备份则原样保留;备份缺失但 Live 干净则直接用 Live 建立备份; + /// Live 与备份都是代理占位符时,从当前供应商配置重建并保留用户配置。 + async fn ensure_clean_live_backup_for_app(&self, app_type: &AppType) -> Result { + let _guard = self.switch_locks.lock_for_app(app_type.as_str()).await; + self.ensure_clean_live_backup_for_app_locked(app_type).await + } + + /// 调用方必须已持有对应应用的切换锁。 + async fn ensure_clean_live_backup_for_app_locked( + &self, + app_type: &AppType, + ) -> Result { + let app_type_str = app_type.as_str(); + + if let Some(backup) = self + .db + .get_live_backup(app_type_str) + .await + .map_err(|e| format!("读取 {app_type_str} Live 备份失败: {e}"))? + { + if let Ok(config) = serde_json::from_str::(&backup.original_config) { + if !Self::live_has_proxy_placeholder_for_app(app_type, &config) { + return Ok(false); + } + } + } + + if let Ok(config) = self.read_live_config_for_app(app_type) { + if !Self::live_has_proxy_placeholder_for_app(app_type, &config) { + let json_str = serde_json::to_string(&config) + .map_err(|e| format!("序列化 {app_type_str} 配置失败: {e}"))?; + self.db + .save_live_backup(app_type_str, &json_str) + .await + .map_err(|e| format!("备份 {app_type_str} 配置失败: {e}"))?; + return Ok(true); + } + } + + let provider = self.require_current_provider_for_app(app_type)?; + self.update_live_backup_from_provider_inner(app_type_str, &provider) + .await?; + + let Some(backup) = self + .db + .get_live_backup(app_type_str) + .await + .map_err(|e| format!("读取重建后的 {app_type_str} 备份失败: {e}"))? + else { + return Err(format!("{app_type_str} Live 备份重建后仍不存在")); + }; + let config: Value = serde_json::from_str(&backup.original_config) + .map_err(|e| format!("解析重建后的 {app_type_str} 备份失败: {e}"))?; + if Self::live_has_proxy_placeholder_for_app(app_type, &config) { + return Err(format!("{app_type_str} Live 备份重建后仍包含代理占位符")); + } + + Ok(true) + } + + fn read_live_config_for_app(&self, app_type: &AppType) -> Result { + match app_type { + AppType::Claude => self.read_claude_live(), + AppType::Codex => self.read_codex_live(), + AppType::Gemini => self.read_gemini_live(), + AppType::GrokBuild => self.read_grok_live(), + _ => Err("该应用不支持代理功能".to_string()), + } + } + /// 构造写入 Live 的代理地址(处理 0.0.0.0 / IPv6 等特殊情况) async fn build_proxy_urls(&self) -> Result<(String, String), String> { let config = self @@ -2264,46 +2379,6 @@ impl ProxyService { Ok(()) } - async fn restore_live_config_for_app_inner(&self, app_type: &AppType) -> Result<(), String> { - match app_type { - AppType::Claude => { - if let Ok(Some(backup)) = self.db.get_live_backup("claude").await { - let config: Value = serde_json::from_str(&backup.original_config) - .map_err(|e| format!("解析 Claude 备份失败: {e}"))?; - self.write_claude_live(&config)?; - log::info!("Claude Live 配置已恢复"); - } - } - AppType::Codex => { - if let Ok(Some(backup)) = self.db.get_live_backup("codex").await { - let config: Value = serde_json::from_str(&backup.original_config) - .map_err(|e| format!("解析 Codex 备份失败: {e}"))?; - self.write_codex_live(&config)?; - log::info!("Codex Live 配置已恢复"); - } - } - AppType::Gemini => { - if let Ok(Some(backup)) = self.db.get_live_backup("gemini").await { - let config: Value = serde_json::from_str(&backup.original_config) - .map_err(|e| format!("解析 Gemini 备份失败: {e}"))?; - self.write_gemini_live(&config)?; - log::info!("Gemini Live 配置已恢复"); - } - } - AppType::GrokBuild => { - if let Ok(Some(backup)) = self.db.get_live_backup("grokbuild").await { - let config: Value = serde_json::from_str(&backup.original_config) - .map_err(|e| format!("解析 Grok Build 备份失败: {e}"))?; - self.write_grok_live(&config)?; - log::info!("Grok Build Live 配置已恢复"); - } - } - _ => {} - } - - Ok(()) - } - /// 恢复原始 Live 配置 async fn restore_live_configs(&self) -> Result<(), String> { let mut errors = Vec::new(); @@ -2400,7 +2475,6 @@ impl ProxyService { app_type_str, ); outcome.kept_fields = vec!["provider".to_string()]; - outcome.lost_fields = vec!["userTables".to_string()]; outcome.next_step = Some("reviewRecoveryResults".to_string()); crate::services::recovery_outcome::record_best_effort(outcome); log::info!("{app_type_str} Live 配置已从 SSOT 恢复(无备份兜底)"); @@ -2438,7 +2512,6 @@ impl ProxyService { app_type_str, ); outcome.kept_fields = vec!["provider".to_string()]; - outcome.lost_fields = vec!["userTables".to_string()]; outcome.next_step = Some("openLogsOrRestoreUserBackup".to_string()); crate::services::recovery_outcome::record_best_effort(outcome); log::info!("{app_type_str} Live 接管占位符已清理(无备份兜底)"); @@ -10360,4 +10433,301 @@ experimental_bearer_token = "PROXY_MANAGED" .expect("backup exists"); assert_eq!(backup.original_config, original_backup); } + + const USER_CODEX_CONFIG_WITH_USER_FIELDS: &str = r#"model_provider = "custom" +model = "gpt-5.4" +approval_policy = "untrusted" + +[model_providers.custom] +name = "Test Provider" +base_url = "https://provider.example/v1" +wire_api = "responses" +requires_openai_auth = true + +[mcp_servers.demo] +command = "demo-command" +"#; + + const TAKEOVER_CODEX_CONFIG: &str = r#"model_provider = "custom" +model = "gpt-5.4" + +[model_providers.custom] +name = "Test Provider" +base_url = "http://127.0.0.1:15721/v1" +wire_api = "responses" +requires_openai_auth = true +"#; + + #[tokio::test] + #[serial] + async fn stop_keep_state_preserves_backup_for_apps_that_keep_takeover_enabled() { + let _home = TempHome::new(); + crate::settings::reload_settings().expect("reload settings"); + + let db = Arc::new(Database::memory().expect("init db")); + let state = crate::store::AppState::new(db.clone()); + + let user_live = json!({ + "auth": { "OPENAI_API_KEY": "user-key" }, + "config": USER_CODEX_CONFIG_WITH_USER_FIELDS, + }); + state + .proxy_service + .write_codex_live_verbatim(&user_live) + .expect("seed user live config"); + let backup_text = serde_json::to_string(&user_live).expect("serialize backup"); + db.save_live_backup("codex", &backup_text) + .await + .expect("seed clean backup"); + state + .proxy_service + .write_codex_live_verbatim(&json!({ + "auth": { "OPENAI_API_KEY": PROXY_TOKEN_PLACEHOLDER }, + "config": TAKEOVER_CODEX_CONFIG, + })) + .expect("seed crashed takeover live"); + + let mut app_config = db + .get_proxy_config_for_app("codex") + .await + .expect("read codex config"); + app_config.enabled = true; + db.update_proxy_config_for_app(app_config) + .await + .expect("enable codex takeover"); + + state + .proxy_service + .stop_with_restore_keep_state() + .await + .expect("keep-state stop should restore live and keep backups"); + + let backup = db + .get_live_backup("codex") + .await + .expect("read backup") + .expect("clean backup must be kept when takeover stays enabled"); + assert_eq!(backup.original_config, backup_text); + + let restored = state + .proxy_service + .read_codex_live() + .expect("read restored live"); + assert_eq!( + restored + .get("auth") + .and_then(|auth| auth.get("OPENAI_API_KEY")) + .and_then(|value| value.as_str()), + Some("user-key"), + "restored live must carry the user auth, not the proxy placeholder" + ); + let restored_config = restored + .get("config") + .and_then(|value| value.as_str()) + .expect("restored config"); + assert!( + restored_config.contains("approval_policy = \"untrusted\""), + "user approval policy must survive exit recovery" + ); + assert!( + restored_config.contains("[mcp_servers.demo]"), + "user MCP entries must survive exit recovery" + ); + + let still_enabled = db + .get_proxy_config_for_app("codex") + .await + .expect("read codex config after exit"); + assert!( + still_enabled.enabled, + "clean exit must keep takeover enabled for the next launch" + ); + } + + #[tokio::test] + #[serial] + async fn stop_keep_state_deletes_backup_when_takeover_is_disabled() { + let _home = TempHome::new(); + crate::settings::reload_settings().expect("reload settings"); + + let db = Arc::new(Database::memory().expect("init db")); + let state = crate::store::AppState::new(db.clone()); + + let user_live = json!({ + "auth": { "OPENAI_API_KEY": "user-key" }, + "config": USER_CODEX_CONFIG_WITH_USER_FIELDS, + }); + state + .proxy_service + .write_codex_live_verbatim(&user_live) + .expect("seed live config"); + db.save_live_backup( + "codex", + &serde_json::to_string(&user_live).expect("serialize backup"), + ) + .await + .expect("seed backup"); + + state + .proxy_service + .stop_with_restore_keep_state() + .await + .expect("keep-state stop with disabled takeover"); + + assert!( + db.get_live_backup("codex") + .await + .expect("read backup") + .is_none(), + "disabled takeover must remove the backup so credentials are not retained" + ); + } + + #[tokio::test] + #[serial] + async fn ensure_clean_live_backup_rebuilds_from_provider_when_live_is_placeholder() { + let _home = TempHome::new(); + crate::settings::reload_settings().expect("reload settings"); + + let db = Arc::new(Database::memory().expect("init db")); + let state = crate::store::AppState::new(db.clone()); + + let provider = Provider::with_id( + "p1".to_string(), + "Test Provider".to_string(), + json!({ + "auth": { "OPENAI_API_KEY": "provider-key" }, + "config": USER_CODEX_CONFIG_WITH_USER_FIELDS, + }), + None, + ); + db.save_provider("codex", &provider).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"); + + state + .proxy_service + .write_codex_live_verbatim(&json!({ + "auth": { "OPENAI_API_KEY": PROXY_TOKEN_PLACEHOLDER }, + "config": TAKEOVER_CODEX_CONFIG, + })) + .expect("seed takeover live without backup"); + + let created = state + .proxy_service + .ensure_clean_live_backup_for_app_locked(&AppType::Codex) + .await + .expect("rebuild clean backup"); + assert!(created, "missing clean backup must be created"); + + let backup = db + .get_live_backup("codex") + .await + .expect("read backup") + .expect("backup exists"); + let stored: Value = serde_json::from_str(&backup.original_config).expect("parse backup"); + assert_ne!( + stored + .get("auth") + .and_then(|auth| auth.get("OPENAI_API_KEY")) + .and_then(|value| value.as_str()), + Some(PROXY_TOKEN_PLACEHOLDER), + "rebuilt backup must not contain the proxy placeholder" + ); + let stored_config = stored + .get("config") + .and_then(|value| value.as_str()) + .expect("stored config"); + assert!( + stored_config.contains("base_url = \"https://provider.example/v1\""), + "rebuilt backup must come from the current provider" + ); + assert!( + stored_config.contains("[mcp_servers.demo]"), + "provider config user fields must survive the rebuild" + ); + } + + #[tokio::test] + #[serial] + async fn simulated_crash_restore_uses_backup_and_keeps_user_fields() { + 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()); + + let user_config = json!({ + "env": { + "ANTHROPIC_API_KEY": "user-key", + "ANTHROPIC_BASE_URL": "https://user.example" + }, + "permissions": { "ask": ["Read", "Write"] }, + "projects": { "/work": { "allowedTools": ["Read"] } }, + }); + service + .write_claude_live(&user_config) + .expect("seed user live config"); + db.save_live_backup( + "claude", + &serde_json::to_string(&user_config).expect("serialize backup"), + ) + .await + .expect("seed backup"); + + service + .write_claude_live(&json!({ + "env": { + "ANTHROPIC_AUTH_TOKEN": PROXY_TOKEN_PLACEHOLDER, + "ANTHROPIC_BASE_URL": "http://127.0.0.1:15721" + } + })) + .expect("simulate crashed takeover live"); + + let provider = Provider::with_id( + "p1".to_string(), + "Provider".to_string(), + json!({ "env": { "ANTHROPIC_API_KEY": "provider-key" } }), + None, + ); + db.save_provider("claude", &provider) + .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"); + + service + .restore_live_config_for_app_with_fallback_inner(&AppType::Claude) + .await + .expect("restore from backup"); + + let restored = service.read_claude_live().expect("read restored live"); + assert_eq!( + restored + .get("env") + .and_then(|env| env.get("ANTHROPIC_API_KEY")) + .and_then(|value| value.as_str()), + Some("user-key"), + "backup must win over the SSOT provider rebuild" + ); + assert_eq!( + restored.get("permissions"), + Some(&user_config["permissions"]), + "user permissions must survive crash recovery" + ); + assert_eq!( + restored.get("projects"), + Some(&user_config["projects"]), + "user project scopes must survive crash recovery" + ); + assert!( + !serde_json::to_string(&restored) + .expect("serialize restored") + .contains("127.0.0.1:15721"), + "restored live must not keep the proxy placeholder" + ); + } }