Skip to main content

cc_switch_lib/services/
proxy.rs

1mod codex_toml;
2
3use std::{
4    collections::HashMap,
5    future::Future,
6    process::{Command, Stdio},
7    sync::{Arc, Mutex as StdMutex, OnceLock, Weak},
8    time::Duration,
9};
10
11#[cfg(unix)]
12use std::os::unix::process::CommandExt;
13
14use serde::{Deserialize, Serialize};
15use serde_json::{json, Value};
16use tokio::sync::RwLock;
17
18use crate::{
19    app_config::AppType,
20    codex_config::{get_codex_auth_path, get_codex_config_path, write_codex_live_atomic},
21    config::{get_claude_settings_path, read_json_file, write_json_file, write_text_file},
22    database::Database,
23    gemini_config::{
24        env_to_json, get_gemini_env_path, json_to_env, read_gemini_env, write_gemini_env_atomic,
25    },
26    provider::Provider,
27    proxy::{
28        switch_lock::SwitchLockManager,
29        types::{GlobalProxyConfig, ProxyTakeoverStatus},
30        ProxyConfig, ProxyServer, ProxyServerInfo, ProxyStatus,
31    },
32    AppError,
33};
34
35const PROXY_TOKEN_PLACEHOLDER: &str = "PROXY_MANAGED";
36const PROXY_RUNTIME_SESSION_KEY: &str = "proxy_runtime_session";
37const PROXY_RUNTIME_KIND_ENV_KEY: &str = "CC_SWITCH_PROXY_RUNTIME_KIND";
38const PROXY_RUNTIME_SESSION_TOKEN_ENV_KEY: &str = "CC_SWITCH_PROXY_SESSION_TOKEN";
39
40const CLAUDE_MODEL_OVERRIDE_ENV_KEYS: [&str; 6] = [
41    "ANTHROPIC_MODEL",
42    "ANTHROPIC_REASONING_MODEL",
43    "ANTHROPIC_DEFAULT_HAIKU_MODEL",
44    "ANTHROPIC_DEFAULT_SONNET_MODEL",
45    "ANTHROPIC_DEFAULT_OPUS_MODEL",
46    "ANTHROPIC_SMALL_FAST_MODEL",
47];
48
49#[derive(Clone)]
50pub struct ProxyService {
51    db: Arc<Database>,
52    runtime: Arc<ProxyRuntimeState>,
53    switch_locks: SwitchLockManager,
54}
55
56struct ProxyRuntimeState {
57    server: RwLock<Option<ProxyServer>>,
58}
59
60#[derive(Debug, Clone, Copy, Default)]
61pub struct HotSwitchOutcome {
62    pub logical_target_changed: bool,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
66#[serde(rename_all = "snake_case")]
67enum PersistedProxyRuntimeSessionKind {
68    #[serde(alias = "foreground")]
69    Foreground,
70    ManagedExternal,
71}
72
73impl Default for PersistedProxyRuntimeSessionKind {
74    fn default() -> Self {
75        Self::Foreground
76    }
77}
78
79impl PersistedProxyRuntimeSessionKind {
80    fn from_env() -> Self {
81        match std::env::var(PROXY_RUNTIME_KIND_ENV_KEY).ok().as_deref() {
82            Some("managed_external") => Self::ManagedExternal,
83            _ => Self::Foreground,
84        }
85    }
86
87    fn as_env_value(&self) -> &'static str {
88        match self {
89            Self::Foreground => "foreground",
90            Self::ManagedExternal => "managed_external",
91        }
92    }
93
94    fn is_managed_external(&self) -> bool {
95        matches!(self, Self::ManagedExternal)
96    }
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
100struct PersistedProxyRuntimeSession {
101    pid: u32,
102    address: String,
103    port: u16,
104    started_at: String,
105    #[serde(default)]
106    kind: PersistedProxyRuntimeSessionKind,
107    #[serde(default)]
108    session_token: Option<String>,
109}
110
111enum ExternalProxyStatusProbe {
112    Matched(ProxyStatus),
113    Mismatched,
114    Unreachable,
115}
116
117fn proxy_runtime_registry() -> &'static StdMutex<HashMap<String, Weak<ProxyRuntimeState>>> {
118    static REGISTRY: OnceLock<StdMutex<HashMap<String, Weak<ProxyRuntimeState>>>> = OnceLock::new();
119    REGISTRY.get_or_init(|| StdMutex::new(HashMap::new()))
120}
121
122impl ProxyService {
123    fn run_in_blocking_runtime<T, F, Fut>(&self, task: F) -> Result<T, String>
124    where
125        T: Send + 'static,
126        F: FnOnce(ProxyService) -> Fut + Send + 'static,
127        Fut: Future<Output = Result<T, String>> + Send + 'static,
128    {
129        let service = self.clone();
130        let handle = std::thread::Builder::new()
131            .name("cc-switch-proxy-blocking".to_string())
132            .spawn(move || {
133                let runtime = tokio::runtime::Builder::new_current_thread()
134                    .enable_all()
135                    .build()
136                    .map_err(|e| format!("failed to create async runtime: {e}"))?;
137                runtime.block_on(task(service))
138            })
139            .map_err(|e| format!("spawn proxy runtime helper failed: {e}"))?;
140
141        handle
142            .join()
143            .map_err(|_| "proxy runtime helper panicked".to_string())?
144    }
145
146    pub fn is_running_blocking(&self) -> Result<bool, String> {
147        self.run_in_blocking_runtime(|service| async move { Ok(service.is_running().await) })
148    }
149
150    pub fn is_app_takeover_active_blocking(&self, app_type: &AppType) -> Result<bool, String> {
151        let app_type = app_type.clone();
152        self.run_in_blocking_runtime(move |service| async move {
153            service.is_app_takeover_active(&app_type).await
154        })
155    }
156
157    pub fn recover_takeovers_on_startup_blocking(&self) -> Result<(), String> {
158        self.run_in_blocking_runtime(|service| async move {
159            service.recover_takeovers_on_startup().await
160        })
161    }
162
163    pub fn new(db: Arc<Database>) -> Self {
164        let runtime = Self::shared_runtime_state(db.runtime_key());
165        Self {
166            db,
167            runtime,
168            switch_locks: SwitchLockManager::new(),
169        }
170    }
171
172    fn shared_runtime_state(runtime_key: &str) -> Arc<ProxyRuntimeState> {
173        let mut registry = proxy_runtime_registry()
174            .lock()
175            .unwrap_or_else(|poisoned| poisoned.into_inner());
176
177        if let Some(existing) = registry.get(runtime_key).and_then(Weak::upgrade) {
178            return existing;
179        }
180
181        let runtime = Arc::new(ProxyRuntimeState {
182            server: RwLock::new(None),
183        });
184        registry.insert(runtime_key.to_string(), Arc::downgrade(&runtime));
185        runtime
186    }
187
188    pub async fn start(&self) -> Result<ProxyServerInfo, String> {
189        let _guard = crate::services::state_coordination::acquire_restore_mutation_guard().await?;
190        let config = self.get_config().await.map_err(|e| e.to_string())?;
191        self.start_with_resolved_config_unlocked(config).await
192    }
193
194    pub async fn start_with_runtime_config(
195        &self,
196        config: ProxyConfig,
197    ) -> Result<ProxyServerInfo, String> {
198        let _guard = crate::services::state_coordination::acquire_restore_mutation_guard().await?;
199        self.start_with_resolved_config_unlocked(config).await
200    }
201
202    pub async fn start_managed_session(&self, app_type: &str) -> Result<ProxyServerInfo, String> {
203        let _guard = crate::services::state_coordination::acquire_restore_mutation_guard().await?;
204        self.start_managed_session_unlocked(app_type).await
205    }
206
207    async fn start_managed_session_unlocked(
208        &self,
209        app_type: &str,
210    ) -> Result<ProxyServerInfo, String> {
211        Self::ensure_managed_sessions_supported()?;
212
213        let app_type = Self::takeover_app_from_str(app_type)?;
214        self.validate_app_proxy_activation(&app_type).await?;
215        let current_status = self.get_status().await;
216        if current_status.running {
217            return Err(
218                "proxy is already running; stop the current runtime before starting a managed session"
219                    .to_string(),
220            );
221        }
222
223        let executable = Self::resolve_managed_proxy_executable()?;
224        let session_token = uuid::Uuid::new_v4().to_string();
225        let mut child = Command::new(executable);
226        child
227            .arg("proxy")
228            .arg("serve")
229            .arg("--takeover")
230            .arg(app_type.as_str())
231            .env(
232                PROXY_RUNTIME_KIND_ENV_KEY,
233                PersistedProxyRuntimeSessionKind::ManagedExternal.as_env_value(),
234            )
235            .env(PROXY_RUNTIME_SESSION_TOKEN_ENV_KEY, &session_token)
236            .env(
237                crate::services::state_coordination::RESTORE_GUARD_BYPASS_ENV_KEY,
238                "1",
239            )
240            .stdin(Stdio::null())
241            .stdout(Stdio::null())
242            .stderr(Stdio::null());
243
244        #[cfg(unix)]
245        unsafe {
246            child.pre_exec(|| {
247                if libc::setsid() == -1 {
248                    return Err(std::io::Error::last_os_error());
249                }
250                Ok(())
251            });
252        }
253
254        let mut child = child
255            .spawn()
256            .map_err(|error| format!("spawn managed proxy session failed: {error}"))?;
257        let child_pid = child.id();
258
259        let start_deadline = tokio::time::Instant::now() + Duration::from_secs(10);
260        loop {
261            if let Some(status) = child
262                .try_wait()
263                .map_err(|error| format!("poll managed proxy process failed: {error}"))?
264            {
265                return Err(format!(
266                    "managed proxy session exited before becoming ready: {}",
267                    status
268                ));
269            }
270
271            if let Some(info) = self
272                .managed_session_ready_info(child_pid, session_token.as_str())
273                .await
274            {
275                Self::spawn_managed_child_reaper(child);
276                return Ok(info);
277            }
278
279            if tokio::time::Instant::now() >= start_deadline {
280                let _ = child.kill();
281                let _ = child.wait();
282                let _ = self.clear_persisted_runtime_session();
283                return Err("managed proxy session did not become ready in time".to_string());
284            }
285
286            tokio::time::sleep(Duration::from_millis(100)).await;
287        }
288    }
289
290    pub async fn set_managed_session_for_app(
291        &self,
292        app_type: &str,
293        enabled: bool,
294    ) -> Result<(), String> {
295        let _guard = crate::services::state_coordination::acquire_restore_mutation_guard().await?;
296        self.set_managed_session_for_app_unlocked(app_type, enabled)
297            .await
298    }
299
300    async fn set_managed_session_for_app_unlocked(
301        &self,
302        app_type: &str,
303        enabled: bool,
304    ) -> Result<(), String> {
305        let app_type = Self::takeover_app_from_str(app_type)?;
306
307        if enabled {
308            let status = self.get_status().await;
309            if !status.running {
310                self.start_managed_session_unlocked(app_type.as_str())
311                    .await?;
312                return Ok(());
313            }
314
315            if self
316                .load_persisted_runtime_session()
317                .is_some_and(|session| session.kind.is_managed_external())
318            {
319                self.enable_takeover_for_app_unlocked(&app_type).await?;
320                return Ok(());
321            }
322
323            return Err(
324                "proxy is already running in foreground mode; stop the current runtime before attaching another app to a managed session"
325                    .to_string(),
326            );
327        }
328
329        let stop_server_when_last = self
330            .load_persisted_runtime_session()
331            .map(|session| session.kind.is_managed_external())
332            .unwrap_or(false);
333        self.disable_takeover_for_app_unlocked(&app_type, stop_server_when_last)
334            .await
335    }
336
337    async fn start_with_resolved_config_unlocked(
338        &self,
339        config: ProxyConfig,
340    ) -> Result<ProxyServerInfo, String> {
341        self.sync_persisted_global_proxy_enabled(true).await?;
342
343        if let Some(server) = self.runtime.server.read().await.as_ref() {
344            let status = server.get_status().await;
345            if status.running {
346                return Ok(ProxyServerInfo {
347                    address: status.address,
348                    port: status.port,
349                    started_at: chrono::Utc::now().to_rfc3339(),
350                });
351            }
352        }
353
354        let server = ProxyServer::new(config, self.db.clone());
355        let info = server.start().await?;
356        if !Self::defer_runtime_session_publish() {
357            if let Err(error) = self.persist_runtime_session(&info) {
358                let _ = server.stop().await;
359                return Err(error);
360            }
361        }
362        *self.runtime.server.write().await = Some(server);
363        Ok(info)
364    }
365
366    pub(crate) fn publish_runtime_session_if_needed(
367        &self,
368        info: &ProxyServerInfo,
369    ) -> Result<(), String> {
370        if Self::defer_runtime_session_publish() && self.load_persisted_runtime_session().is_none()
371        {
372            self.persist_runtime_session(info)?;
373        }
374        Ok(())
375    }
376
377    pub async fn recover_takeovers_on_startup(&self) -> Result<(), String> {
378        for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] {
379            let app_key = app_type.as_str();
380            let app_proxy = self
381                .db
382                .get_proxy_config_for_app(app_key)
383                .await
384                .map_err(|error| format!("load proxy config for {app_key} failed: {error}"))?;
385            let has_backup = self
386                .db
387                .get_live_backup(app_key)
388                .await
389                .map_err(|error| format!("load live backup for {app_key} failed: {error}"))?
390                .is_some();
391            let live_taken_over = self.detect_takeover_in_live_config_for_app(&app_type);
392
393            if !app_proxy.enabled && !has_backup && !live_taken_over {
394                continue;
395            }
396
397            if has_backup {
398                self.restore_live_config_for_app(&app_type).await?;
399                self.db
400                    .delete_live_backup(app_key)
401                    .await
402                    .map_err(|error| format!("delete live backup for {app_key} failed: {error}"))?;
403            } else if live_taken_over {
404                self.restore_live_from_current_provider(&app_type).await?;
405            }
406
407            if app_proxy.enabled {
408                let mut cleared = app_proxy;
409                cleared.enabled = false;
410                self.db
411                    .update_proxy_config_for_app(cleared)
412                    .await
413                    .map_err(|error| {
414                        format!("clear takeover flag for {app_key} failed: {error}")
415                    })?;
416            }
417        }
418
419        Ok(())
420    }
421
422    pub async fn stop(&self) -> Result<(), String> {
423        let _guard = crate::services::state_coordination::acquire_restore_mutation_guard().await?;
424        self.stop_server_unlocked().await
425    }
426
427    pub async fn stop_with_restore(&self) -> Result<(), String> {
428        let _guard = crate::services::state_coordination::acquire_restore_mutation_guard().await?;
429
430        if let Err(error) = self.stop_server_unlocked().await {
431            log::warn!("stop proxy runtime before restore failed: {error}");
432        }
433
434        self.restore_active_takeovers_on_shutdown_unlocked().await
435    }
436
437    async fn stop_server_unlocked(&self) -> Result<(), String> {
438        let mut stopped_runtime = false;
439
440        if let Some(server) = self.runtime.server.read().await.as_ref() {
441            server.stop().await?;
442            self.clear_persisted_runtime_session()?;
443            self.sync_persisted_global_proxy_enabled(false).await?;
444            return Ok(());
445        }
446
447        if let Some(session) = self.load_persisted_runtime_session() {
448            if session.kind.is_managed_external() {
449                let probe = Self::probe_external_proxy_status(&session).await;
450                let should_terminate = match probe {
451                    ExternalProxyStatusProbe::Matched(_) => true,
452                    ExternalProxyStatusProbe::Mismatched => false,
453                    ExternalProxyStatusProbe::Unreachable => {
454                        Self::has_managed_external_ownership_signal(&session)
455                    }
456                };
457
458                if Self::is_process_alive(session.pid) && should_terminate {
459                    Self::terminate_external_process(session.pid).await?;
460                    stopped_runtime = true;
461                }
462            }
463        }
464
465        self.clear_persisted_runtime_session()?;
466        self.sync_persisted_global_proxy_enabled(false).await?;
467        if stopped_runtime {
468            Ok(())
469        } else {
470            Err("proxy server is not running".to_string())
471        }
472    }
473
474    pub async fn is_running(&self) -> bool {
475        self.get_status().await.running
476    }
477
478    pub async fn get_status(&self) -> ProxyStatus {
479        if let Some(server) = self.runtime.server.read().await.as_ref() {
480            return server.get_status().await;
481        }
482
483        if let Some(session) = self.load_persisted_runtime_session() {
484            if session.kind.is_managed_external() {
485                if Self::is_process_alive(session.pid) {
486                    match Self::probe_external_proxy_status(&session).await {
487                        ExternalProxyStatusProbe::Matched(status) => return status,
488                        ExternalProxyStatusProbe::Mismatched => {
489                            let _ = self.clear_persisted_runtime_session();
490                            return ProxyStatus::default();
491                        }
492                        ExternalProxyStatusProbe::Unreachable => {
493                            if Self::has_managed_external_ownership_signal(&session) {
494                                let uptime_seconds =
495                                    chrono::DateTime::parse_from_rfc3339(&session.started_at)
496                                        .ok()
497                                        .map(|started_at| {
498                                            let started_at = started_at.with_timezone(&chrono::Utc);
499                                            (chrono::Utc::now() - started_at).num_seconds().max(0)
500                                                as u64
501                                        })
502                                        .unwrap_or(0);
503
504                                return ProxyStatus {
505                                    running: true,
506                                    address: session.address.clone(),
507                                    port: session.port,
508                                    uptime_seconds,
509                                    managed_session_token: session.session_token.clone(),
510                                    ..ProxyStatus::default()
511                                };
512                            }
513                        }
514                    }
515                }
516
517                let _ = self.clear_persisted_runtime_session();
518                return ProxyStatus::default();
519            }
520
521            if Self::is_process_alive(session.pid) {
522                let uptime_seconds = chrono::DateTime::parse_from_rfc3339(&session.started_at)
523                    .ok()
524                    .map(|started_at| {
525                        let started_at = started_at.with_timezone(&chrono::Utc);
526                        (chrono::Utc::now() - started_at).num_seconds().max(0) as u64
527                    })
528                    .unwrap_or(0);
529
530                return ProxyStatus {
531                    running: true,
532                    address: session.address,
533                    port: session.port,
534                    uptime_seconds,
535                    ..ProxyStatus::default()
536                };
537            }
538
539            let _ = self.clear_persisted_runtime_session();
540        }
541
542        ProxyStatus::default()
543    }
544
545    pub async fn get_config(&self) -> Result<ProxyConfig, AppError> {
546        self.db.get_proxy_config().await
547    }
548
549    pub async fn update_config(&self, config: &ProxyConfig) -> Result<(), AppError> {
550        let _guard = crate::services::state_coordination::acquire_restore_mutation_guard()
551            .await
552            .map_err(AppError::Message)?;
553        self.db.update_proxy_config(config.clone()).await
554    }
555
556    pub async fn update_circuit_breaker_configs(
557        &self,
558        config: crate::proxy::circuit_breaker::CircuitBreakerConfig,
559    ) -> Result<(), String> {
560        if let Some(server) = self.runtime.server.read().await.as_ref() {
561            server.update_circuit_breaker_configs(config).await;
562        }
563
564        Ok(())
565    }
566
567    pub async fn reset_provider_circuit_breaker(
568        &self,
569        provider_id: &str,
570        app_type: &str,
571    ) -> Result<(), String> {
572        if let Some(server) = self.runtime.server.read().await.as_ref() {
573            server
574                .reset_provider_circuit_breaker(provider_id, app_type)
575                .await;
576        }
577
578        Ok(())
579    }
580
581    pub async fn get_global_config(&self) -> Result<GlobalProxyConfig, AppError> {
582        self.db.get_global_proxy_config().await
583    }
584
585    pub async fn set_global_enabled(&self, enabled: bool) -> Result<GlobalProxyConfig, AppError> {
586        let _guard = crate::services::state_coordination::acquire_restore_mutation_guard()
587            .await
588            .map_err(AppError::Message)?;
589        let mut config = self.get_global_config().await?;
590        config.proxy_enabled = enabled;
591        self.db.update_global_proxy_config(config.clone()).await?;
592        Ok(config)
593    }
594
595    pub async fn get_takeover_status(&self) -> Result<ProxyTakeoverStatus, String> {
596        Ok(ProxyTakeoverStatus {
597            claude: self
598                .db
599                .get_proxy_config_for_app("claude")
600                .await
601                .map_err(|error| format!("load claude proxy config failed: {error}"))?
602                .enabled,
603            codex: self
604                .db
605                .get_proxy_config_for_app("codex")
606                .await
607                .map_err(|error| format!("load codex proxy config failed: {error}"))?
608                .enabled,
609            gemini: self
610                .db
611                .get_proxy_config_for_app("gemini")
612                .await
613                .map_err(|error| format!("load gemini proxy config failed: {error}"))?
614                .enabled,
615        })
616    }
617
618    pub async fn set_takeover_for_app(&self, app_type: &str, enabled: bool) -> Result<(), String> {
619        let app_type = Self::takeover_app_from_str(app_type)?;
620        let _guard = crate::services::state_coordination::acquire_restore_mutation_guard().await?;
621
622        if enabled {
623            self.enable_takeover_for_app_unlocked(&app_type).await
624        } else {
625            self.disable_takeover_for_app_unlocked(&app_type, true)
626                .await
627        }
628    }
629
630    pub async fn is_app_takeover_active(&self, app_type: &AppType) -> Result<bool, String> {
631        let app_key = app_type.as_str();
632        let app_proxy = self
633            .db
634            .get_proxy_config_for_app(app_key)
635            .await
636            .map_err(|error| format!("load proxy config for {app_key} failed: {error}"))?;
637        if app_proxy.enabled {
638            return Ok(true);
639        }
640
641        if self
642            .db
643            .get_live_backup(app_key)
644            .await
645            .map_err(|error| format!("load live backup for {app_key} failed: {error}"))?
646            .is_some()
647        {
648            return Ok(true);
649        }
650
651        Ok(self.detect_takeover_in_live_config_for_app(app_type))
652    }
653
654    pub fn detect_takeover_in_live_config_for_app(&self, app_type: &AppType) -> bool {
655        match app_type {
656            AppType::Claude => self
657                .read_claude_live()
658                .ok()
659                .is_some_and(|live| Self::is_claude_live_taken_over(&live)),
660            AppType::Codex => self
661                .read_codex_live()
662                .ok()
663                .is_some_and(|live| Self::is_codex_live_taken_over(&live)),
664            AppType::Gemini => self
665                .read_gemini_live()
666                .ok()
667                .is_some_and(|live| Self::is_gemini_live_taken_over(&live)),
668            _ => false,
669        }
670    }
671
672    fn is_claude_live_taken_over(config: &Value) -> bool {
673        let Some(env) = config.get("env").and_then(Value::as_object) else {
674            return false;
675        };
676
677        [
678            "ANTHROPIC_AUTH_TOKEN",
679            "ANTHROPIC_API_KEY",
680            "OPENROUTER_API_KEY",
681            "OPENAI_API_KEY",
682        ]
683        .iter()
684        .any(|key| {
685            env.get(*key)
686                .and_then(Value::as_str)
687                .is_some_and(|value| value == PROXY_TOKEN_PLACEHOLDER)
688        })
689    }
690
691    fn is_codex_live_taken_over(config: &Value) -> bool {
692        config
693            .get("auth")
694            .and_then(|auth| auth.get("OPENAI_API_KEY"))
695            .and_then(Value::as_str)
696            .is_some_and(|value| value == PROXY_TOKEN_PLACEHOLDER)
697    }
698
699    fn is_gemini_live_taken_over(config: &Value) -> bool {
700        config
701            .get("env")
702            .and_then(|env| env.get("GEMINI_API_KEY"))
703            .and_then(Value::as_str)
704            .is_some_and(|value| value == PROXY_TOKEN_PLACEHOLDER)
705    }
706
707    pub async fn update_live_backup_from_provider(
708        &self,
709        app_type: &str,
710        provider: &Provider,
711    ) -> Result<(), String> {
712        let app_type = Self::takeover_app_from_str(app_type)?;
713        let mut backup_snapshot = self.build_live_snapshot_from_provider(&app_type, provider)?;
714
715        if matches!(app_type, AppType::Codex) {
716            let existing_backup_value = self
717                .db
718                .get_live_backup(app_type.as_str())
719                .await
720                .map_err(|error| {
721                    format!(
722                        "load {} existing live backup failed: {error}",
723                        app_type.as_str()
724                    )
725                })?
726                .map(|backup| {
727                    serde_json::from_str::<Value>(&backup.original_config).map_err(|error| {
728                        format!(
729                            "parse {} existing live backup failed: {error}",
730                            app_type.as_str()
731                        )
732                    })
733                })
734                .transpose()?;
735
736            if let Some(existing_value) = existing_backup_value.as_ref() {
737                Self::preserve_codex_mcp_servers_in_backup(&mut backup_snapshot, existing_value)?;
738            }
739
740            let anchor_config_text = existing_backup_value
741                .as_ref()
742                .and_then(|value| value.get("config"))
743                .and_then(Value::as_str);
744            crate::codex_config::normalize_codex_settings_config_model_provider(
745                &mut backup_snapshot,
746                anchor_config_text,
747            )
748            .map_err(|error| format!("normalize Codex live backup failed: {error}"))?;
749        }
750
751        if matches!(app_type, AppType::Gemini) {
752            backup_snapshot = json!({
753                "env": backup_snapshot
754                    .get("env")
755                    .cloned()
756                    .unwrap_or_else(|| json!({}))
757            });
758        }
759
760        self.save_live_backup_snapshot(app_type.as_str(), &backup_snapshot)
761            .await
762    }
763
764    pub async fn hot_switch_provider(
765        &self,
766        app_type: &str,
767        provider_id: &str,
768    ) -> Result<HotSwitchOutcome, String> {
769        let _guard = self.switch_locks.lock_for_app(app_type).await;
770
771        let app_type_enum = Self::takeover_app_from_str(app_type)?;
772        let provider = self
773            .db
774            .get_provider_by_id(provider_id, app_type)
775            .map_err(|e| format!("读取供应商失败: {e}"))?
776            .ok_or_else(|| format!("供应商不存在: {provider_id}"))?;
777
778        let logical_target_changed =
779            crate::settings::get_effective_current_provider(&self.db, &app_type_enum)
780                .map_err(|e| format!("读取当前供应商失败: {e}"))?
781                .as_deref()
782                != Some(provider_id);
783
784        let has_backup = self
785            .db
786            .get_live_backup(app_type_enum.as_str())
787            .await
788            .map_err(|e| format!("读取 {app_type} 备份失败: {e}"))?
789            .is_some();
790        let live_taken_over = self.detect_takeover_in_live_config_for_app(&app_type_enum);
791        let should_sync_backup = has_backup || live_taken_over;
792
793        self.db
794            .set_current_provider(app_type_enum.as_str(), provider_id)
795            .map_err(|e| format!("更新当前供应商失败: {e}"))?;
796        crate::settings::set_current_provider(&app_type_enum, Some(provider_id))
797            .map_err(|e| format!("更新本地当前供应商失败: {e}"))?;
798
799        if should_sync_backup {
800            self.update_live_backup_from_provider(app_type, &provider)
801                .await?;
802        }
803
804        if let Some(server) = self.runtime.server.read().await.as_ref() {
805            server
806                .set_active_target(app_type_enum.as_str(), &provider.id, &provider.name)
807                .await;
808        }
809
810        Ok(HotSwitchOutcome {
811            logical_target_changed,
812        })
813    }
814
815    pub async fn switch_proxy_target(
816        &self,
817        app_type: &str,
818        provider_id: &str,
819    ) -> Result<(), String> {
820        let outcome = self.hot_switch_provider(app_type, provider_id).await?;
821
822        if outcome.logical_target_changed {
823            log::info!("代理模式:已切换 {app_type} 的目标供应商为 {provider_id}");
824        } else {
825            log::debug!("代理模式:{app_type} 已对齐到目标供应商 {provider_id}");
826        }
827
828        Ok(())
829    }
830
831    fn preserve_codex_mcp_servers_in_backup(
832        target_settings: &mut Value,
833        existing_backup: &Value,
834    ) -> Result<(), String> {
835        let target_obj = target_settings
836            .as_object_mut()
837            .ok_or_else(|| "Codex live backup must be a JSON object".to_string())?;
838
839        let target_config = target_obj
840            .get("config")
841            .and_then(Value::as_str)
842            .unwrap_or("");
843        let mut target_doc = if target_config.trim().is_empty() {
844            toml_edit::DocumentMut::new()
845        } else {
846            target_config
847                .parse::<toml_edit::DocumentMut>()
848                .map_err(|error| format!("parse new Codex config.toml failed: {error}"))?
849        };
850
851        let existing_config = existing_backup
852            .get("config")
853            .and_then(Value::as_str)
854            .unwrap_or("");
855        if existing_config.trim().is_empty() {
856            target_obj.insert("config".to_string(), json!(target_doc.to_string()));
857            return Ok(());
858        }
859
860        let existing_doc = existing_config
861            .parse::<toml_edit::DocumentMut>()
862            .map_err(|error| format!("parse existing Codex backup failed: {error}"))?;
863
864        if let Some(existing_mcp_servers) = existing_doc.get("mcp_servers") {
865            match target_doc.get_mut("mcp_servers") {
866                Some(target_mcp_servers) => {
867                    if let (Some(target_table), Some(existing_table)) = (
868                        target_mcp_servers.as_table_like_mut(),
869                        existing_mcp_servers.as_table_like(),
870                    ) {
871                        for (server_id, server_item) in existing_table.iter() {
872                            if target_table.get(server_id).is_none() {
873                                target_table.insert(server_id, server_item.clone());
874                            }
875                        }
876                    }
877                }
878                None => {
879                    target_doc["mcp_servers"] = existing_mcp_servers.clone();
880                }
881            }
882        }
883
884        target_obj.insert("config".to_string(), json!(target_doc.to_string()));
885        Ok(())
886    }
887
888    async fn validate_app_proxy_activation(&self, app_type: &AppType) -> Result<(), String> {
889        let app_key = app_type.as_str();
890        let app_proxy = self
891            .db
892            .get_proxy_config_for_app(app_key)
893            .await
894            .map_err(|error| format!("load proxy config for {app_key} failed: {error}"))?;
895
896        if app_proxy.auto_failover_enabled {
897            if self
898                .db
899                .get_failover_queue(app_key)
900                .map_err(|error| format!("load failover queue for {app_key} failed: {error}"))?
901                .is_empty()
902            {
903                return Err(
904                    "cannot enable proxy because automatic failover is enabled and the failover queue is empty"
905                        .to_string(),
906                );
907            }
908            return Ok(());
909        }
910
911        let Some(provider_id) =
912            crate::settings::get_effective_current_provider(self.db.as_ref(), app_type).map_err(
913                |error| {
914                    format!(
915                        "load effective current provider for {} failed: {error}",
916                        app_type.as_str()
917                    )
918                },
919            )?
920        else {
921            return Err("cannot enable proxy because no active provider is selected".to_string());
922        };
923
924        if self
925            .db
926            .get_provider_by_id(&provider_id, app_key)
927            .map_err(|error| format!("load provider {provider_id} for {app_key} failed: {error}"))?
928            .is_none()
929        {
930            return Err("cannot enable proxy because no active provider is selected".to_string());
931        }
932
933        Ok(())
934    }
935
936    pub async fn save_live_backup_snapshot(
937        &self,
938        app_type: &str,
939        snapshot: &Value,
940    ) -> Result<(), String> {
941        let app_type = Self::takeover_app_from_str(app_type)?;
942        let backup = serde_json::to_string(snapshot).map_err(|error| {
943            format!(
944                "serialize {} live backup failed: {error}",
945                app_type.as_str()
946            )
947        })?;
948        self.db
949            .save_live_backup(app_type.as_str(), &backup)
950            .await
951            .map_err(|error| format!("save {} live backup failed: {error}", app_type.as_str()))
952    }
953
954    async fn restore_active_takeovers_on_shutdown_unlocked(&self) -> Result<(), String> {
955        for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] {
956            self.disable_takeover_for_app_unlocked(&app_type, false)
957                .await?;
958        }
959
960        Ok(())
961    }
962
963    async fn enable_takeover_for_app_unlocked(&self, app_type: &AppType) -> Result<(), String> {
964        self.validate_app_proxy_activation(app_type).await?;
965
966        if !self.is_running().await {
967            let config = self.get_config().await.map_err(|e| e.to_string())?;
968            self.start_with_resolved_config_unlocked(config).await?;
969        }
970
971        let app_key = app_type.as_str();
972        let app_proxy = self
973            .db
974            .get_proxy_config_for_app(app_key)
975            .await
976            .map_err(|error| format!("load proxy config for {app_key} failed: {error}"))?;
977        let has_backup = self
978            .db
979            .get_live_backup(app_key)
980            .await
981            .map_err(|error| format!("load live backup for {app_key} failed: {error}"))?
982            .is_some();
983        let live_taken_over = self.detect_takeover_in_live_config_for_app(app_type);
984
985        if app_proxy.enabled && has_backup && live_taken_over {
986            return Ok(());
987        }
988
989        let live = self.read_or_current_provider_live(app_type).await?;
990        if !has_backup {
991            let backup = serde_json::to_string(&live)
992                .map_err(|error| format!("serialize {app_key} live backup failed: {error}"))?;
993            self.db
994                .save_live_backup(app_key, &backup)
995                .await
996                .map_err(|error| format!("save {app_key} live backup failed: {error}"))?;
997        }
998        self.sync_live_config_to_current_provider(app_type, &live)
999            .await?;
1000
1001        let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;
1002        let mut taken_over = live;
1003        self.rewrite_live_for_proxy(app_type, &mut taken_over, &proxy_url, &proxy_codex_base_url)?;
1004        self.write_live_config_for_app(app_type, &taken_over)?;
1005
1006        if !app_proxy.enabled {
1007            let mut updated = app_proxy;
1008            updated.enabled = true;
1009            self.db
1010                .update_proxy_config_for_app(updated)
1011                .await
1012                .map_err(|error| format!("set takeover flag for {app_key} failed: {error}"))?;
1013        }
1014
1015        Ok(())
1016    }
1017
1018    async fn disable_takeover_for_app_unlocked(
1019        &self,
1020        app_type: &AppType,
1021        stop_server_when_last: bool,
1022    ) -> Result<(), String> {
1023        let app_key = app_type.as_str();
1024        let app_proxy = self
1025            .db
1026            .get_proxy_config_for_app(app_key)
1027            .await
1028            .map_err(|error| format!("load proxy config for {app_key} failed: {error}"))?;
1029        let has_backup = self
1030            .db
1031            .get_live_backup(app_key)
1032            .await
1033            .map_err(|error| format!("load live backup for {app_key} failed: {error}"))?
1034            .is_some();
1035        let live_taken_over = self.detect_takeover_in_live_config_for_app(app_type);
1036
1037        if !app_proxy.enabled && !has_backup && !live_taken_over {
1038            return Ok(());
1039        }
1040
1041        if has_backup {
1042            self.restore_live_config_for_app(app_type).await?;
1043            self.db
1044                .delete_live_backup(app_key)
1045                .await
1046                .map_err(|error| format!("delete live backup for {app_key} failed: {error}"))?;
1047        } else if live_taken_over {
1048            self.restore_live_from_current_provider(app_type).await?;
1049        }
1050
1051        if app_proxy.enabled {
1052            let mut cleared = app_proxy;
1053            cleared.enabled = false;
1054            self.db
1055                .update_proxy_config_for_app(cleared)
1056                .await
1057                .map_err(|error| format!("clear takeover flag for {app_key} failed: {error}"))?;
1058        }
1059
1060        self.db
1061            .clear_provider_health_for_app(app_key)
1062            .await
1063            .map_err(|error| format!("clear provider health for {app_key} failed: {error}"))?;
1064
1065        if stop_server_when_last
1066            && !self
1067                .db
1068                .is_live_takeover_active()
1069                .await
1070                .map_err(|error| format!("check active takeovers failed: {error}"))?
1071        {
1072            self.stop_server_unlocked().await?;
1073        }
1074
1075        Ok(())
1076    }
1077
1078    async fn restore_live_config_for_app(&self, app_type: &AppType) -> Result<(), String> {
1079        let app_key = app_type.as_str();
1080        let Some(backup) = self
1081            .db
1082            .get_live_backup(app_key)
1083            .await
1084            .map_err(|error| format!("load live backup for {app_key} failed: {error}"))?
1085        else {
1086            return Ok(());
1087        };
1088
1089        let restored: Value = serde_json::from_str(&backup.original_config)
1090            .map_err(|error| format!("parse {app_key} live backup failed: {error}"))?;
1091        self.write_live_config_for_app(app_type, &restored)
1092    }
1093
1094    async fn restore_live_from_current_provider(&self, app_type: &AppType) -> Result<(), String> {
1095        let Some(settings) = self.current_provider_settings(app_type).await? else {
1096            return self.clear_stale_takeover_from_live_config(app_type);
1097        };
1098        self.write_live_config_for_app(app_type, &settings)
1099    }
1100
1101    async fn current_provider_settings(&self, app_type: &AppType) -> Result<Option<Value>, String> {
1102        let Some(current_provider) =
1103            crate::settings::get_effective_current_provider(self.db.as_ref(), app_type).map_err(
1104                |error| {
1105                    format!(
1106                        "load effective current provider for {} failed: {error}",
1107                        app_type.as_str()
1108                    )
1109                },
1110            )?
1111        else {
1112            return Ok(None);
1113        };
1114
1115        self.db
1116            .get_provider_by_id(&current_provider, app_type.as_str())
1117            .map_err(|error| {
1118                format!(
1119                    "load provider {} for {} failed: {error}",
1120                    current_provider,
1121                    app_type.as_str()
1122                )
1123            })
1124            .and_then(|provider| {
1125                provider
1126                    .map(|provider| {
1127                        self.build_current_provider_restore_snapshot(app_type, &provider)
1128                    })
1129                    .transpose()
1130            })
1131    }
1132
1133    fn build_current_provider_restore_snapshot(
1134        &self,
1135        app_type: &AppType,
1136        provider: &Provider,
1137    ) -> Result<Value, String> {
1138        let common_config_snippet =
1139            self.db
1140                .get_config_snippet(app_type.as_str())
1141                .map_err(|error| {
1142                    format!(
1143                        "load common config snippet for {} failed: {error}",
1144                        app_type.as_str()
1145                    )
1146                })?;
1147        let apply_common_config = provider
1148            .meta
1149            .as_ref()
1150            .and_then(|meta| meta.apply_common_config)
1151            .unwrap_or(true);
1152
1153        crate::services::provider::ProviderService::build_live_backup_snapshot(
1154            app_type,
1155            provider,
1156            common_config_snippet.as_deref(),
1157            apply_common_config,
1158        )
1159        .and_then(|mut snapshot| {
1160            if matches!(app_type, AppType::Codex) {
1161                crate::codex_config::normalize_codex_settings_config_model_provider(
1162                    &mut snapshot,
1163                    None,
1164                )?;
1165            }
1166            Ok(snapshot)
1167        })
1168        .map_err(|error| {
1169            format!(
1170                "build {} current-provider restore snapshot failed: {error}",
1171                app_type.as_str()
1172            )
1173        })
1174    }
1175
1176    async fn sync_live_config_to_current_provider(
1177        &self,
1178        app_type: &AppType,
1179        live_config: &Value,
1180    ) -> Result<(), String> {
1181        enum LiveTokenSync {
1182            Claude(&'static str, String),
1183            Codex(String),
1184            Gemini(String),
1185        }
1186
1187        let Some(token_sync) = (match app_type {
1188            AppType::Claude => live_config
1189                .get("env")
1190                .and_then(Value::as_object)
1191                .and_then(|env| {
1192                    [
1193                        "ANTHROPIC_AUTH_TOKEN",
1194                        "ANTHROPIC_API_KEY",
1195                        "OPENROUTER_API_KEY",
1196                        "OPENAI_API_KEY",
1197                    ]
1198                    .into_iter()
1199                    .find_map(|key| {
1200                        env.get(key)
1201                            .and_then(Value::as_str)
1202                            .map(|value| (key, value.trim().to_string()))
1203                    })
1204                })
1205                .filter(|(_, token)| !token.is_empty() && token != PROXY_TOKEN_PLACEHOLDER)
1206                .map(|(key, token)| LiveTokenSync::Claude(key, token)),
1207            AppType::Codex => live_config
1208                .get("auth")
1209                .and_then(|auth| auth.get("OPENAI_API_KEY"))
1210                .and_then(Value::as_str)
1211                .map(str::trim)
1212                .filter(|token| !token.is_empty() && *token != PROXY_TOKEN_PLACEHOLDER)
1213                .map(|token| LiveTokenSync::Codex(token.to_string())),
1214            AppType::Gemini => live_config
1215                .get("env")
1216                .and_then(|env| env.get("GEMINI_API_KEY"))
1217                .and_then(Value::as_str)
1218                .map(str::trim)
1219                .filter(|token| !token.is_empty() && *token != PROXY_TOKEN_PLACEHOLDER)
1220                .map(|token| LiveTokenSync::Gemini(token.to_string())),
1221            _ => None,
1222        }) else {
1223            return Ok(());
1224        };
1225
1226        let Some(provider_id) =
1227            crate::settings::get_effective_current_provider(self.db.as_ref(), app_type).map_err(
1228                |error| {
1229                    format!(
1230                        "load effective current provider for {} failed: {error}",
1231                        app_type.as_str()
1232                    )
1233                },
1234            )?
1235        else {
1236            return Ok(());
1237        };
1238
1239        let Some(mut provider) = self
1240            .db
1241            .get_provider_by_id(&provider_id, app_type.as_str())
1242            .map_err(|error| {
1243                format!(
1244                    "load provider {} for {} failed: {error}",
1245                    provider_id,
1246                    app_type.as_str()
1247                )
1248            })?
1249        else {
1250            return Ok(());
1251        };
1252
1253        let Some(root) = (match &mut provider.settings_config {
1254            Value::Object(root) => Some(root),
1255            Value::Null => {
1256                provider.settings_config = json!({});
1257                provider.settings_config.as_object_mut()
1258            }
1259            _ => None,
1260        }) else {
1261            log::warn!(
1262                "skip syncing {} live token because provider {} settings root is not an object",
1263                app_type.as_str(),
1264                provider_id
1265            );
1266            return Ok(());
1267        };
1268
1269        match token_sync {
1270            LiveTokenSync::Claude(token_key, token) => {
1271                let env_value = root.entry("env".to_string()).or_insert_with(|| json!({}));
1272                if !env_value.is_object() {
1273                    *env_value = json!({});
1274                }
1275
1276                let Some(env) = env_value.as_object_mut() else {
1277                    log::warn!(
1278                        "skip syncing {} live token because provider {} env is not an object",
1279                        app_type.as_str(),
1280                        provider_id
1281                    );
1282                    return Ok(());
1283                };
1284
1285                if matches!(token_key, "ANTHROPIC_AUTH_TOKEN" | "ANTHROPIC_API_KEY") {
1286                    let mut updated = false;
1287                    if env.contains_key("ANTHROPIC_AUTH_TOKEN") {
1288                        env.insert("ANTHROPIC_AUTH_TOKEN".to_string(), json!(token));
1289                        updated = true;
1290                    }
1291                    if env.contains_key("ANTHROPIC_API_KEY") {
1292                        env.insert("ANTHROPIC_API_KEY".to_string(), json!(token));
1293                        updated = true;
1294                    }
1295                    if !updated {
1296                        env.insert(token_key.to_string(), json!(token));
1297                    }
1298                } else {
1299                    env.insert(token_key.to_string(), json!(token));
1300                }
1301            }
1302            LiveTokenSync::Codex(token) => {
1303                let auth_value = root.entry("auth".to_string()).or_insert_with(|| json!({}));
1304                if !auth_value.is_object() {
1305                    *auth_value = json!({});
1306                }
1307
1308                let Some(auth) = auth_value.as_object_mut() else {
1309                    log::warn!(
1310                        "skip syncing {} live token because provider {} auth is not an object",
1311                        app_type.as_str(),
1312                        provider_id
1313                    );
1314                    return Ok(());
1315                };
1316
1317                auth.insert("OPENAI_API_KEY".to_string(), json!(token));
1318            }
1319            LiveTokenSync::Gemini(token) => {
1320                let env_value = root.entry("env".to_string()).or_insert_with(|| json!({}));
1321                if !env_value.is_object() {
1322                    *env_value = json!({});
1323                }
1324
1325                let Some(env) = env_value.as_object_mut() else {
1326                    log::warn!(
1327                        "skip syncing {} live token because provider {} env is not an object",
1328                        app_type.as_str(),
1329                        provider_id
1330                    );
1331                    return Ok(());
1332                };
1333
1334                env.insert("GEMINI_API_KEY".to_string(), json!(token));
1335            }
1336        }
1337
1338        if let Err(error) = self.db.update_provider_settings_config(
1339            app_type.as_str(),
1340            &provider_id,
1341            &provider.settings_config,
1342        ) {
1343            log::warn!(
1344                "sync {} live token to provider {} failed: {error}",
1345                app_type.as_str(),
1346                provider_id
1347            );
1348        }
1349
1350        Ok(())
1351    }
1352
1353    async fn read_or_current_provider_live(&self, app_type: &AppType) -> Result<Value, String> {
1354        match self.read_live_config_for_app(app_type) {
1355            Ok(live) => Ok(live),
1356            Err(_) => self
1357                .current_provider_settings(app_type)
1358                .await?
1359                .ok_or_else(|| {
1360                    format!(
1361                        "missing live config and current provider for {}",
1362                        app_type.as_str()
1363                    )
1364                }),
1365        }
1366    }
1367
1368    async fn build_proxy_urls(&self) -> Result<(String, String), String> {
1369        let runtime_status = self.get_status().await;
1370        let persisted = self.get_config().await.map_err(|e| e.to_string())?;
1371        let listen_address = if runtime_status.running && !runtime_status.address.is_empty() {
1372            runtime_status.address
1373        } else {
1374            persisted.listen_address.clone()
1375        };
1376        let listen_port = if runtime_status.running && runtime_status.port != 0 {
1377            runtime_status.port
1378        } else {
1379            persisted.listen_port
1380        };
1381
1382        let connect_host = match listen_address.as_str() {
1383            "0.0.0.0" => "127.0.0.1".to_string(),
1384            "::" => "::1".to_string(),
1385            _ => listen_address,
1386        };
1387        let connect_host_for_url = if connect_host.contains(':') && !connect_host.starts_with('[') {
1388            format!("[{connect_host}]")
1389        } else {
1390            connect_host
1391        };
1392
1393        let proxy_origin = format!("http://{}:{}", connect_host_for_url, listen_port);
1394        let proxy_codex_base_url = format!("{}/v1", proxy_origin.trim_end_matches('/'));
1395        Ok((proxy_origin, proxy_codex_base_url))
1396    }
1397
1398    fn rewrite_live_for_proxy(
1399        &self,
1400        app_type: &AppType,
1401        live: &mut Value,
1402        proxy_url: &str,
1403        proxy_codex_base_url: &str,
1404    ) -> Result<(), String> {
1405        match app_type {
1406            AppType::Claude => {
1407                if !live.is_object() {
1408                    *live = json!({});
1409                }
1410
1411                let root = live
1412                    .as_object_mut()
1413                    .ok_or_else(|| "claude live config root must be an object".to_string())?;
1414                if !root.get("env").is_some_and(Value::is_object) {
1415                    root.insert("env".to_string(), json!({}));
1416                }
1417
1418                let env = root
1419                    .get_mut("env")
1420                    .and_then(Value::as_object_mut)
1421                    .ok_or_else(|| "claude env must be an object".to_string())?;
1422                env.insert("ANTHROPIC_BASE_URL".to_string(), json!(proxy_url));
1423                for key in CLAUDE_MODEL_OVERRIDE_ENV_KEYS {
1424                    env.remove(key);
1425                }
1426
1427                let token_keys = [
1428                    "ANTHROPIC_AUTH_TOKEN",
1429                    "ANTHROPIC_API_KEY",
1430                    "OPENROUTER_API_KEY",
1431                    "OPENAI_API_KEY",
1432                ];
1433                let mut replaced_any = false;
1434                for key in token_keys {
1435                    if env.contains_key(key) {
1436                        env.insert(key.to_string(), json!(PROXY_TOKEN_PLACEHOLDER));
1437                        replaced_any = true;
1438                    }
1439                }
1440
1441                if !replaced_any {
1442                    env.insert(
1443                        "ANTHROPIC_AUTH_TOKEN".to_string(),
1444                        json!(PROXY_TOKEN_PLACEHOLDER),
1445                    );
1446                }
1447            }
1448            AppType::Codex => {
1449                if !live.is_object() {
1450                    *live = json!({});
1451                }
1452
1453                let root = live
1454                    .as_object_mut()
1455                    .ok_or_else(|| "codex live config root must be an object".to_string())?;
1456                if !root.get("auth").is_some_and(Value::is_object) {
1457                    root.insert("auth".to_string(), json!({}));
1458                }
1459
1460                let auth = root
1461                    .get_mut("auth")
1462                    .and_then(Value::as_object_mut)
1463                    .ok_or_else(|| "codex auth must be an object".to_string())?;
1464                auth.insert("OPENAI_API_KEY".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));
1465
1466                let config_text = root
1467                    .get("config")
1468                    .and_then(Value::as_str)
1469                    .unwrap_or_default()
1470                    .to_string();
1471                root.insert(
1472                    "config".to_string(),
1473                    json!(codex_toml::update_toml_base_url(
1474                        &config_text,
1475                        proxy_codex_base_url
1476                    )),
1477                );
1478            }
1479            AppType::Gemini => {
1480                if !live.is_object() {
1481                    *live = json!({});
1482                }
1483
1484                let root = live
1485                    .as_object_mut()
1486                    .ok_or_else(|| "gemini live config root must be an object".to_string())?;
1487                if !root.get("env").is_some_and(Value::is_object) {
1488                    root.insert("env".to_string(), json!({}));
1489                }
1490
1491                let env = root
1492                    .get_mut("env")
1493                    .and_then(Value::as_object_mut)
1494                    .ok_or_else(|| "gemini env must be an object".to_string())?;
1495                env.insert("GOOGLE_GEMINI_BASE_URL".to_string(), json!(proxy_url));
1496                env.insert("GEMINI_API_KEY".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));
1497            }
1498            _ => {
1499                return Err(format!(
1500                    "proxy takeover not supported for {}",
1501                    app_type.as_str()
1502                ));
1503            }
1504        }
1505
1506        Ok(())
1507    }
1508
1509    fn read_live_config_for_app(&self, app_type: &AppType) -> Result<Value, String> {
1510        match app_type {
1511            AppType::Claude => self.read_claude_live(),
1512            AppType::Codex => self.read_codex_live(),
1513            AppType::Gemini => self.read_gemini_live(),
1514            _ => Err(format!(
1515                "proxy takeover not supported for {}",
1516                app_type.as_str()
1517            )),
1518        }
1519    }
1520
1521    fn write_live_config_for_app(&self, app_type: &AppType, config: &Value) -> Result<(), String> {
1522        match app_type {
1523            AppType::Claude => self.write_claude_live(config),
1524            AppType::Codex => self.write_codex_live(config),
1525            AppType::Gemini => self.write_gemini_live(config),
1526            _ => Err(format!(
1527                "proxy takeover not supported for {}",
1528                app_type.as_str()
1529            )),
1530        }
1531    }
1532
1533    fn clear_stale_takeover_from_live_config(&self, app_type: &AppType) -> Result<(), String> {
1534        let mut live = match self.read_live_config_for_app(app_type) {
1535            Ok(live) => live,
1536            Err(_) => return Ok(()),
1537        };
1538
1539        match app_type {
1540            AppType::Claude => {
1541                if let Some(env) = live.get_mut("env").and_then(Value::as_object_mut) {
1542                    if env
1543                        .get("ANTHROPIC_BASE_URL")
1544                        .and_then(Value::as_str)
1545                        .is_some_and(codex_toml::is_loopback_proxy_url)
1546                    {
1547                        env.remove("ANTHROPIC_BASE_URL");
1548                    }
1549
1550                    for key in [
1551                        "ANTHROPIC_AUTH_TOKEN",
1552                        "ANTHROPIC_API_KEY",
1553                        "OPENROUTER_API_KEY",
1554                        "OPENAI_API_KEY",
1555                    ] {
1556                        if env
1557                            .get(key)
1558                            .and_then(Value::as_str)
1559                            .is_some_and(|value| value == PROXY_TOKEN_PLACEHOLDER)
1560                        {
1561                            env.remove(key);
1562                        }
1563                    }
1564                }
1565            }
1566            AppType::Codex => {
1567                if let Some(auth) = live.get_mut("auth").and_then(Value::as_object_mut) {
1568                    if auth
1569                        .get("OPENAI_API_KEY")
1570                        .and_then(Value::as_str)
1571                        .is_some_and(|value| value == PROXY_TOKEN_PLACEHOLDER)
1572                    {
1573                        auth.remove("OPENAI_API_KEY");
1574                    }
1575                }
1576
1577                if let Some(config_text) = live.get("config").and_then(Value::as_str) {
1578                    live["config"] =
1579                        json!(codex_toml::remove_loopback_base_url_from_toml(config_text));
1580                }
1581            }
1582            AppType::Gemini => {
1583                if let Some(env) = live.get_mut("env").and_then(Value::as_object_mut) {
1584                    if env
1585                        .get("GOOGLE_GEMINI_BASE_URL")
1586                        .and_then(Value::as_str)
1587                        .is_some_and(codex_toml::is_loopback_proxy_url)
1588                    {
1589                        env.remove("GOOGLE_GEMINI_BASE_URL");
1590                    }
1591                    if env
1592                        .get("GEMINI_API_KEY")
1593                        .and_then(Value::as_str)
1594                        .is_some_and(|value| value == PROXY_TOKEN_PLACEHOLDER)
1595                    {
1596                        env.remove("GEMINI_API_KEY");
1597                    }
1598                }
1599            }
1600            _ => {
1601                return Err(format!(
1602                    "proxy takeover not supported for {}",
1603                    app_type.as_str()
1604                ));
1605            }
1606        }
1607
1608        self.write_live_config_for_app(app_type, &live)
1609    }
1610
1611    fn read_claude_live(&self) -> Result<Value, String> {
1612        let path = get_claude_settings_path();
1613        if !path.exists() {
1614            return Err("Claude settings.json does not exist".to_string());
1615        }
1616
1617        let value: Value = read_json_file(&path)
1618            .map_err(|error| format!("read Claude settings.json failed: {error}"))?;
1619        if value.is_object() {
1620            Ok(value)
1621        } else {
1622            Err("Claude settings.json root must be an object".to_string())
1623        }
1624    }
1625
1626    fn write_claude_live(&self, config: &Value) -> Result<(), String> {
1627        write_json_file(&get_claude_settings_path(), config)
1628            .map_err(|error| format!("write Claude settings.json failed: {error}"))
1629    }
1630
1631    fn read_codex_live(&self) -> Result<Value, String> {
1632        let auth_path = get_codex_auth_path();
1633        let config_path = get_codex_config_path();
1634        if !auth_path.exists() && !config_path.exists() {
1635            return Err("Codex live config does not exist".to_string());
1636        }
1637
1638        let auth = if auth_path.exists() {
1639            read_json_file(&auth_path)
1640                .map_err(|error| format!("read Codex auth.json failed: {error}"))?
1641        } else {
1642            json!({})
1643        };
1644        let config = if config_path.exists() {
1645            std::fs::read_to_string(&config_path)
1646                .map_err(|error| format!("read Codex config.toml failed: {error}"))?
1647        } else {
1648            String::new()
1649        };
1650
1651        Ok(json!({
1652            "auth": auth,
1653            "config": config,
1654        }))
1655    }
1656
1657    fn write_codex_live(&self, config: &Value) -> Result<(), String> {
1658        let auth = config
1659            .get("auth")
1660            .filter(|value| !value.as_object().is_some_and(|object| object.is_empty()));
1661        let config_text = config.get("config").and_then(Value::as_str);
1662        let auth_path = get_codex_auth_path();
1663
1664        // Proxy restore applies the saved backup config without another stable-provider rewrite.
1665        match (auth, config_text) {
1666            (Some(auth), Some(config_text)) => write_codex_live_atomic(auth, Some(config_text))
1667                .map_err(|error| format!("write Codex live config failed: {error}")),
1668            (Some(auth), None) => write_json_file(&get_codex_auth_path(), auth)
1669                .map_err(|error| format!("write Codex auth.json failed: {error}")),
1670            (None, Some(config_text)) => {
1671                if auth_path.exists() {
1672                    std::fs::remove_file(&auth_path)
1673                        .map_err(|error| format!("remove Codex auth.json failed: {error}"))?;
1674                }
1675                write_text_file(&get_codex_config_path(), config_text)
1676                    .map_err(|error| format!("write Codex config.toml failed: {error}"))
1677            }
1678            (None, None) => {
1679                if auth_path.exists() {
1680                    std::fs::remove_file(&auth_path)
1681                        .map_err(|error| format!("remove Codex auth.json failed: {error}"))?;
1682                }
1683                Ok(())
1684            }
1685        }
1686    }
1687
1688    fn build_live_snapshot_from_provider(
1689        &self,
1690        app_type: &AppType,
1691        provider: &Provider,
1692    ) -> Result<Value, String> {
1693        let common_config_snippet =
1694            self.db
1695                .get_config_snippet(app_type.as_str())
1696                .map_err(|error| {
1697                    format!(
1698                        "load common config snippet for {} failed: {error}",
1699                        app_type.as_str()
1700                    )
1701                })?;
1702        let apply_common_config = provider
1703            .meta
1704            .as_ref()
1705            .and_then(|meta| meta.apply_common_config)
1706            .unwrap_or(true);
1707
1708        crate::services::provider::ProviderService::build_live_backup_snapshot(
1709            app_type,
1710            provider,
1711            common_config_snippet.as_deref(),
1712            apply_common_config,
1713        )
1714        .map_err(|error| {
1715            format!(
1716                "build {} live snapshot from provider failed: {error}",
1717                app_type.as_str()
1718            )
1719        })
1720    }
1721
1722    fn persist_runtime_session(&self, info: &ProxyServerInfo) -> Result<(), String> {
1723        let session = PersistedProxyRuntimeSession {
1724            pid: std::process::id(),
1725            address: info.address.clone(),
1726            port: info.port,
1727            started_at: info.started_at.clone(),
1728            kind: PersistedProxyRuntimeSessionKind::from_env(),
1729            session_token: std::env::var(PROXY_RUNTIME_SESSION_TOKEN_ENV_KEY)
1730                .ok()
1731                .filter(|value| !value.trim().is_empty()),
1732        };
1733        let serialized = serde_json::to_string(&session)
1734            .map_err(|error| format!("serialize proxy runtime session failed: {error}"))?;
1735        self.db
1736            .set_setting(PROXY_RUNTIME_SESSION_KEY, &serialized)
1737            .map_err(|error| format!("persist proxy runtime session failed: {error}"))
1738    }
1739
1740    fn defer_runtime_session_publish() -> bool {
1741        PersistedProxyRuntimeSessionKind::from_env().is_managed_external()
1742    }
1743
1744    fn clear_persisted_runtime_session(&self) -> Result<(), String> {
1745        self.db
1746            .delete_setting(PROXY_RUNTIME_SESSION_KEY)
1747            .map_err(|error| format!("clear proxy runtime session failed: {error}"))
1748    }
1749
1750    fn load_persisted_runtime_session(&self) -> Option<PersistedProxyRuntimeSession> {
1751        let raw = self
1752            .db
1753            .get_setting(PROXY_RUNTIME_SESSION_KEY)
1754            .ok()
1755            .flatten()?;
1756        let raw = raw.trim();
1757        if raw.is_empty() {
1758            return None;
1759        }
1760
1761        match serde_json::from_str(raw) {
1762            Ok(session) => Some(session),
1763            Err(_) => {
1764                let _ = self.clear_persisted_runtime_session();
1765                None
1766            }
1767        }
1768    }
1769
1770    fn is_process_alive(pid: u32) -> bool {
1771        if pid == 0 {
1772            return false;
1773        }
1774
1775        #[cfg(unix)]
1776        {
1777            let rc = unsafe { libc::kill(pid as i32, 0) };
1778            return rc == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM);
1779        }
1780
1781        #[cfg(not(unix))]
1782        {
1783            pid == std::process::id()
1784        }
1785    }
1786
1787    fn has_managed_external_ownership_signal(session: &PersistedProxyRuntimeSession) -> bool {
1788        session.session_token.is_some() && Self::is_detached_session_leader(session.pid)
1789    }
1790
1791    #[cfg(unix)]
1792    fn is_detached_session_leader(pid: u32) -> bool {
1793        if pid == 0 {
1794            return false;
1795        }
1796
1797        let sid = unsafe { libc::getsid(pid as i32) };
1798        let pgid = unsafe { libc::getpgid(pid as i32) };
1799        sid == pid as i32 && pgid == pid as i32
1800    }
1801
1802    #[cfg(not(unix))]
1803    fn is_detached_session_leader(_pid: u32) -> bool {
1804        false
1805    }
1806
1807    async fn managed_session_ready_info(
1808        &self,
1809        child_pid: u32,
1810        session_token: &str,
1811    ) -> Option<ProxyServerInfo> {
1812        let session = self
1813            .load_persisted_runtime_session()
1814            .filter(|session| session.pid == child_pid)
1815            .filter(|session| session.kind.is_managed_external())
1816            .filter(|session| session.session_token.as_deref() == Some(session_token))?;
1817
1818        match Self::probe_external_proxy_status(&session).await {
1819            ExternalProxyStatusProbe::Matched(status) => Some(ProxyServerInfo {
1820                address: status.address,
1821                port: status.port,
1822                started_at: session.started_at,
1823            }),
1824            ExternalProxyStatusProbe::Unreachable => Some(ProxyServerInfo {
1825                address: session.address,
1826                port: session.port,
1827                started_at: session.started_at,
1828            }),
1829            ExternalProxyStatusProbe::Mismatched => None,
1830        }
1831    }
1832
1833    async fn probe_external_proxy_status(
1834        session: &PersistedProxyRuntimeSession,
1835    ) -> ExternalProxyStatusProbe {
1836        let Some(expected_session_token) = session.session_token.as_deref() else {
1837            return ExternalProxyStatusProbe::Unreachable;
1838        };
1839        let client = reqwest::Client::builder()
1840            .timeout(Duration::from_millis(500))
1841            .build();
1842        let Ok(client) = client else {
1843            return ExternalProxyStatusProbe::Unreachable;
1844        };
1845
1846        let response = client
1847            .get(Self::build_session_status_url(session))
1848            .send()
1849            .await;
1850        let Ok(response) = response else {
1851            return ExternalProxyStatusProbe::Unreachable;
1852        };
1853        if !response.status().is_success() {
1854            return ExternalProxyStatusProbe::Unreachable;
1855        }
1856
1857        let mut status = match response.json::<ProxyStatus>().await {
1858            Ok(status) => status,
1859            Err(_) => return ExternalProxyStatusProbe::Unreachable,
1860        };
1861        if status.managed_session_token.as_deref() != Some(expected_session_token) {
1862            return ExternalProxyStatusProbe::Mismatched;
1863        }
1864        status.running = true;
1865        if status.address.trim().is_empty() {
1866            status.address = session.address.clone();
1867        }
1868        if status.port == 0 {
1869            status.port = session.port;
1870        }
1871        ExternalProxyStatusProbe::Matched(status)
1872    }
1873
1874    fn build_session_status_url(session: &PersistedProxyRuntimeSession) -> String {
1875        let connect_host = match session.address.as_str() {
1876            "0.0.0.0" => "127.0.0.1".to_string(),
1877            "::" => "::1".to_string(),
1878            value => value.to_string(),
1879        };
1880        let connect_host = if connect_host.contains(':') && !connect_host.starts_with('[') {
1881            format!("[{connect_host}]")
1882        } else {
1883            connect_host
1884        };
1885
1886        format!("http://{}:{}/status", connect_host, session.port)
1887    }
1888
1889    async fn terminate_external_process(pid: u32) -> Result<(), String> {
1890        if pid == 0 || pid == std::process::id() || !Self::is_process_alive(pid) {
1891            return Ok(());
1892        }
1893
1894        #[cfg(unix)]
1895        {
1896            let rc = unsafe { libc::kill(pid as i32, libc::SIGTERM) };
1897            if rc != 0 {
1898                let error = std::io::Error::last_os_error();
1899                if error.raw_os_error() != Some(libc::ESRCH) {
1900                    return Err(format!("stop managed proxy session failed: {error}"));
1901                }
1902            }
1903
1904            let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
1905            while tokio::time::Instant::now() < deadline {
1906                if !Self::is_process_alive(pid) {
1907                    return Ok(());
1908                }
1909                tokio::time::sleep(Duration::from_millis(100)).await;
1910            }
1911
1912            let rc = unsafe { libc::kill(pid as i32, libc::SIGKILL) };
1913            if rc != 0 {
1914                let error = std::io::Error::last_os_error();
1915                if error.raw_os_error() != Some(libc::ESRCH) {
1916                    return Err(format!("force stop managed proxy session failed: {error}"));
1917                }
1918            }
1919
1920            let deadline = tokio::time::Instant::now() + Duration::from_secs(1);
1921            while tokio::time::Instant::now() < deadline {
1922                if !Self::is_process_alive(pid) {
1923                    return Ok(());
1924                }
1925                tokio::time::sleep(Duration::from_millis(50)).await;
1926            }
1927
1928            return Err(format!(
1929                "managed proxy session did not exit after termination signal: pid {}",
1930                pid
1931            ));
1932        }
1933
1934        #[cfg(not(unix))]
1935        {
1936            let _ = pid;
1937            Err("managed proxy session stop is only supported on unix in this build".to_string())
1938        }
1939    }
1940
1941    fn spawn_managed_child_reaper(mut child: std::process::Child) {
1942        tokio::task::spawn_blocking(move || {
1943            let _ = child.wait();
1944        });
1945    }
1946
1947    fn ensure_managed_sessions_supported() -> Result<(), String> {
1948        #[cfg(unix)]
1949        {
1950            Ok(())
1951        }
1952
1953        #[cfg(not(unix))]
1954        {
1955            Err("managed proxy sessions are unsupported on non-unix platforms".to_string())
1956        }
1957    }
1958
1959    fn resolve_managed_proxy_executable() -> Result<std::path::PathBuf, String> {
1960        if let Some(path) = std::env::var_os("CARGO_BIN_EXE_cc-switch-tui") {
1961            return Ok(path.into());
1962        }
1963
1964        let current_exe = std::env::current_exe()
1965            .map_err(|error| format!("resolve managed proxy executable failed: {error}"))?;
1966
1967        if current_exe
1968            .file_stem()
1969            .and_then(|value| value.to_str())
1970            .is_some_and(|value| value.starts_with("cc-switch-tui"))
1971        {
1972            return Ok(current_exe);
1973        }
1974
1975        if let Some(debug_dir) = current_exe.parent().and_then(|parent| parent.parent()) {
1976            let candidate = debug_dir.join(format!("cc-switch{}", std::env::consts::EXE_SUFFIX));
1977            if candidate.exists() {
1978                return Ok(candidate);
1979            }
1980        }
1981
1982        Ok(current_exe)
1983    }
1984
1985    async fn sync_persisted_global_proxy_enabled(&self, enabled: bool) -> Result<(), String> {
1986        let mut config = self
1987            .db
1988            .get_global_proxy_config()
1989            .await
1990            .map_err(|error| format!("load global proxy config failed: {error}"))?;
1991        if config.proxy_enabled == enabled {
1992            return Ok(());
1993        }
1994
1995        config.proxy_enabled = enabled;
1996        self.db
1997            .update_global_proxy_config(config)
1998            .await
1999            .map_err(|error| format!("update global proxy switch failed: {error}"))
2000    }
2001
2002    fn read_gemini_live(&self) -> Result<Value, String> {
2003        let env_path = get_gemini_env_path();
2004        if !env_path.exists() {
2005            return Err("Gemini .env does not exist".to_string());
2006        }
2007
2008        let env = read_gemini_env().map_err(|error| format!("read Gemini .env failed: {error}"))?;
2009        Ok(env_to_json(&env))
2010    }
2011
2012    fn write_gemini_live(&self, config: &Value) -> Result<(), String> {
2013        let env =
2014            json_to_env(config).map_err(|error| format!("build Gemini .env failed: {error}"))?;
2015        write_gemini_env_atomic(&env).map_err(|error| format!("write Gemini .env failed: {error}"))
2016    }
2017
2018    fn takeover_app_from_str(app_type: &str) -> Result<AppType, String> {
2019        match app_type {
2020            "claude" => Ok(AppType::Claude),
2021            "codex" => Ok(AppType::Codex),
2022            "gemini" => Ok(AppType::Gemini),
2023            _ => Err(format!("proxy takeover not supported for app: {app_type}")),
2024        }
2025    }
2026}
2027
2028#[cfg(test)]
2029mod tests {
2030    use super::*;
2031    use crate::provider::ProviderMeta;
2032    use crate::proxy::circuit_breaker::CircuitBreakerConfig;
2033    use crate::test_support::{lock_test_home_and_settings, set_test_home_override};
2034    use serial_test::serial;
2035    use std::ffi::OsString;
2036    use std::path::Path;
2037    use std::sync::atomic::{AtomicBool, Ordering};
2038    use tempfile::TempDir;
2039
2040    struct ManagedRuntimeEnvGuard {
2041        old_kind: Option<OsString>,
2042        old_token: Option<OsString>,
2043    }
2044
2045    impl ManagedRuntimeEnvGuard {
2046        fn set(token: &str) -> Self {
2047            let old_kind = std::env::var_os(PROXY_RUNTIME_KIND_ENV_KEY);
2048            let old_token = std::env::var_os(PROXY_RUNTIME_SESSION_TOKEN_ENV_KEY);
2049            unsafe {
2050                std::env::set_var(
2051                    PROXY_RUNTIME_KIND_ENV_KEY,
2052                    PersistedProxyRuntimeSessionKind::ManagedExternal.as_env_value(),
2053                );
2054                std::env::set_var(PROXY_RUNTIME_SESSION_TOKEN_ENV_KEY, token);
2055            }
2056            Self {
2057                old_kind,
2058                old_token,
2059            }
2060        }
2061    }
2062
2063    impl Drop for ManagedRuntimeEnvGuard {
2064        fn drop(&mut self) {
2065            match &self.old_kind {
2066                Some(value) => unsafe { std::env::set_var(PROXY_RUNTIME_KIND_ENV_KEY, value) },
2067                None => unsafe { std::env::remove_var(PROXY_RUNTIME_KIND_ENV_KEY) },
2068            }
2069            match &self.old_token {
2070                Some(value) => unsafe {
2071                    std::env::set_var(PROXY_RUNTIME_SESSION_TOKEN_ENV_KEY, value)
2072                },
2073                None => unsafe { std::env::remove_var(PROXY_RUNTIME_SESSION_TOKEN_ENV_KEY) },
2074            }
2075        }
2076    }
2077
2078    struct TestHomeEnvGuard {
2079        _lock: crate::test_support::TestHomeSettingsLock,
2080        old_home: Option<OsString>,
2081        old_userprofile: Option<OsString>,
2082        old_config_dir: Option<OsString>,
2083    }
2084
2085    impl TestHomeEnvGuard {
2086        fn set(home: &Path) -> Self {
2087            let lock = lock_test_home_and_settings();
2088            let old_home = std::env::var_os("HOME");
2089            let old_userprofile = std::env::var_os("USERPROFILE");
2090            let old_config_dir = std::env::var_os("CC_SWITCH_CONFIG_DIR");
2091            unsafe {
2092                std::env::set_var("HOME", home);
2093                std::env::set_var("USERPROFILE", home);
2094                std::env::set_var("CC_SWITCH_CONFIG_DIR", home.join(".cc-switch"));
2095            }
2096            set_test_home_override(Some(home));
2097            crate::settings::reload_test_settings();
2098            Self {
2099                _lock: lock,
2100                old_home,
2101                old_userprofile,
2102                old_config_dir,
2103            }
2104        }
2105    }
2106
2107    impl Drop for TestHomeEnvGuard {
2108        fn drop(&mut self) {
2109            match &self.old_home {
2110                Some(value) => unsafe { std::env::set_var("HOME", value) },
2111                None => unsafe { std::env::remove_var("HOME") },
2112            }
2113            match &self.old_userprofile {
2114                Some(value) => unsafe { std::env::set_var("USERPROFILE", value) },
2115                None => unsafe { std::env::remove_var("USERPROFILE") },
2116            }
2117            match &self.old_config_dir {
2118                Some(value) => unsafe { std::env::set_var("CC_SWITCH_CONFIG_DIR", value) },
2119                None => unsafe { std::env::remove_var("CC_SWITCH_CONFIG_DIR") },
2120            }
2121            set_test_home_override(self.old_home.as_deref().map(Path::new));
2122            crate::settings::reload_test_settings();
2123        }
2124    }
2125
2126    #[tokio::test]
2127    #[serial]
2128    async fn takeover_activation_rejects_missing_current_provider_when_failover_disabled() {
2129        let temp_home = TempDir::new().expect("create temp home");
2130        let _env = TestHomeEnvGuard::set(temp_home.path());
2131
2132        let db = Arc::new(Database::memory().expect("create database"));
2133        let service = ProxyService::new(db.clone());
2134
2135        let error = service
2136            .set_takeover_for_app("claude", true)
2137            .await
2138            .expect_err("takeover should require an active provider when failover is disabled");
2139
2140        assert!(
2141            error.contains("cannot enable proxy because no active provider is selected"),
2142            "{error}"
2143        );
2144        assert!(
2145            !db.get_proxy_config_for_app("claude")
2146                .await
2147                .expect("load claude proxy config")
2148                .enabled,
2149            "failed validation should not enable takeover state"
2150        );
2151    }
2152
2153    #[tokio::test]
2154    #[serial]
2155    async fn takeover_activation_allows_current_provider_when_failover_disabled() {
2156        let temp_home = TempDir::new().expect("create temp home");
2157        let _env = TestHomeEnvGuard::set(temp_home.path());
2158        std::fs::create_dir_all(
2159            get_claude_settings_path()
2160                .parent()
2161                .expect("claude settings parent dir"),
2162        )
2163        .expect("create ~/.claude");
2164        write_json_file(
2165            &get_claude_settings_path(),
2166            &json!({
2167                "env": {
2168                    "ANTHROPIC_BASE_URL": "https://api.anthropic.com",
2169                    "ANTHROPIC_AUTH_TOKEN": "fresh-live-token"
2170                }
2171            }),
2172        )
2173        .expect("seed claude live config");
2174
2175        let db = Arc::new(Database::memory().expect("create database"));
2176        let service = ProxyService::new(db.clone());
2177        let provider = Provider::with_id(
2178            "claude-provider".to_string(),
2179            "Claude Provider".to_string(),
2180            json!({
2181                "env": {
2182                    "ANTHROPIC_BASE_URL": "https://api.anthropic.com",
2183                    "ANTHROPIC_AUTH_TOKEN": "stale-provider-token"
2184                }
2185            }),
2186            None,
2187        );
2188        db.save_provider("claude", &provider)
2189            .expect("save claude provider");
2190        db.set_current_provider("claude", &provider.id)
2191            .expect("set current claude provider");
2192        let mut runtime_config = service.get_config().await.expect("get proxy config");
2193        runtime_config.listen_port = 0;
2194        service
2195            .update_config(&runtime_config)
2196            .await
2197            .expect("persist runtime config");
2198
2199        service
2200            .set_takeover_for_app("claude", true)
2201            .await
2202            .expect("takeover should allow an active provider when failover is disabled");
2203
2204        assert!(
2205            db.get_proxy_config_for_app("claude")
2206                .await
2207                .expect("load claude proxy config")
2208                .enabled
2209        );
2210        service.stop().await.expect("stop proxy runtime");
2211    }
2212
2213    #[tokio::test]
2214    #[serial]
2215    async fn takeover_activation_rejects_empty_queue_when_failover_enabled() {
2216        let temp_home = TempDir::new().expect("create temp home");
2217        let _env = TestHomeEnvGuard::set(temp_home.path());
2218
2219        let db = Arc::new(Database::memory().expect("create database"));
2220        let service = ProxyService::new(db.clone());
2221        let provider = Provider::with_id(
2222            "claude-provider".to_string(),
2223            "Claude Provider".to_string(),
2224            json!({
2225                "env": {
2226                    "ANTHROPIC_BASE_URL": "https://api.anthropic.com",
2227                    "ANTHROPIC_AUTH_TOKEN": "token"
2228                }
2229            }),
2230            None,
2231        );
2232        db.save_provider("claude", &provider)
2233            .expect("save claude provider");
2234        db.set_current_provider("claude", &provider.id)
2235            .expect("set current claude provider");
2236        let mut app_proxy = db
2237            .get_proxy_config_for_app("claude")
2238            .await
2239            .expect("load claude proxy config");
2240        app_proxy.auto_failover_enabled = true;
2241        db.update_proxy_config_for_app(app_proxy)
2242            .await
2243            .expect("enable auto failover");
2244
2245        let error = service
2246            .set_takeover_for_app("claude", true)
2247            .await
2248            .expect_err("takeover should require a non-empty failover queue");
2249
2250        assert!(
2251            error.contains(
2252                "cannot enable proxy because automatic failover is enabled and the failover queue is empty"
2253            ),
2254            "{error}"
2255        );
2256        assert!(
2257            !db.get_proxy_config_for_app("claude")
2258                .await
2259                .expect("load claude proxy config")
2260                .enabled,
2261            "failed validation should not enable takeover state"
2262        );
2263    }
2264
2265    #[tokio::test]
2266    #[serial]
2267    async fn takeover_activation_allows_non_empty_queue_when_failover_enabled() {
2268        let temp_home = TempDir::new().expect("create temp home");
2269        let _env = TestHomeEnvGuard::set(temp_home.path());
2270        std::fs::create_dir_all(
2271            get_claude_settings_path()
2272                .parent()
2273                .expect("claude settings parent dir"),
2274        )
2275        .expect("create ~/.claude");
2276        write_json_file(
2277            &get_claude_settings_path(),
2278            &json!({
2279                "env": {
2280                    "ANTHROPIC_BASE_URL": "https://api.anthropic.com",
2281                    "ANTHROPIC_AUTH_TOKEN": "fresh-live-token"
2282                }
2283            }),
2284        )
2285        .expect("seed claude live config");
2286
2287        let db = Arc::new(Database::memory().expect("create database"));
2288        let service = ProxyService::new(db.clone());
2289        let provider = Provider::with_id(
2290            "claude-provider".to_string(),
2291            "Claude Provider".to_string(),
2292            json!({
2293                "env": {
2294                    "ANTHROPIC_BASE_URL": "https://api.anthropic.com",
2295                    "ANTHROPIC_AUTH_TOKEN": "stale-provider-token"
2296                }
2297            }),
2298            None,
2299        );
2300        db.save_provider("claude", &provider)
2301            .expect("save claude provider");
2302        db.add_to_failover_queue("claude", &provider.id)
2303            .expect("queue claude provider");
2304        let mut app_proxy = db
2305            .get_proxy_config_for_app("claude")
2306            .await
2307            .expect("load claude proxy config");
2308        app_proxy.auto_failover_enabled = true;
2309        db.update_proxy_config_for_app(app_proxy)
2310            .await
2311            .expect("enable auto failover");
2312        let mut runtime_config = service.get_config().await.expect("get proxy config");
2313        runtime_config.listen_port = 0;
2314        service
2315            .update_config(&runtime_config)
2316            .await
2317            .expect("persist runtime config");
2318
2319        service
2320            .set_takeover_for_app("claude", true)
2321            .await
2322            .expect("takeover should allow a non-empty failover queue");
2323
2324        assert!(
2325            db.get_proxy_config_for_app("claude")
2326                .await
2327                .expect("load claude proxy config")
2328                .enabled
2329        );
2330        service.stop().await.expect("stop proxy runtime");
2331    }
2332
2333    #[tokio::test]
2334    #[serial]
2335    async fn recover_takeovers_on_startup_cleans_claude_placeholder_only_residue_without_backup() {
2336        let temp_home = TempDir::new().expect("create temp home");
2337        let _env = TestHomeEnvGuard::set(temp_home.path());
2338        std::fs::create_dir_all(
2339            get_claude_settings_path()
2340                .parent()
2341                .expect("claude settings parent dir"),
2342        )
2343        .expect("create ~/.claude");
2344
2345        write_json_file(
2346            &get_claude_settings_path(),
2347            &json!({
2348                "env": {
2349                    "ANTHROPIC_AUTH_TOKEN": PROXY_TOKEN_PLACEHOLDER,
2350                    "ANTHROPIC_BASE_URL": "https://api.anthropic.com"
2351                }
2352            }),
2353        )
2354        .expect("seed placeholder-only claude live config");
2355
2356        let db = Arc::new(Database::memory().expect("create database"));
2357        let service = ProxyService::new(db);
2358
2359        service
2360            .recover_takeovers_on_startup()
2361            .await
2362            .expect("startup recovery should clean placeholder-only residue");
2363
2364        let live: Value =
2365            read_json_file(&get_claude_settings_path()).expect("read claude live config");
2366        let env = live
2367            .get("env")
2368            .and_then(Value::as_object)
2369            .expect("claude env should be object");
2370        assert!(
2371            !env.contains_key("ANTHROPIC_AUTH_TOKEN"),
2372            "startup recovery should remove stale proxy placeholder tokens even without loopback base_url"
2373        );
2374        assert_eq!(
2375            env.get("ANTHROPIC_BASE_URL").and_then(Value::as_str),
2376            Some("https://api.anthropic.com"),
2377            "startup recovery should keep a non-proxy base_url when only clearing stale placeholder residue"
2378        );
2379    }
2380
2381    #[tokio::test]
2382    #[serial]
2383    async fn enabling_claude_takeover_syncs_live_token_back_to_current_provider() {
2384        let temp_home = TempDir::new().expect("create temp home");
2385        let _env = TestHomeEnvGuard::set(temp_home.path());
2386        std::fs::create_dir_all(
2387            get_claude_settings_path()
2388                .parent()
2389                .expect("claude settings parent dir"),
2390        )
2391        .expect("create ~/.claude");
2392
2393        write_json_file(
2394            &get_claude_settings_path(),
2395            &json!({
2396                "env": {
2397                    "ANTHROPIC_BASE_URL": "https://api.anthropic.com",
2398                    "ANTHROPIC_AUTH_TOKEN": "fresh-live-token"
2399                }
2400            }),
2401        )
2402        .expect("seed claude live config");
2403
2404        let db = Arc::new(Database::memory().expect("create database"));
2405        let service = ProxyService::new(db.clone());
2406
2407        let provider = Provider::with_id(
2408            "claude-provider".to_string(),
2409            "Claude Provider".to_string(),
2410            json!({
2411                "env": {
2412                    "ANTHROPIC_BASE_URL": "https://api.anthropic.com",
2413                    "ANTHROPIC_AUTH_TOKEN": "stale-provider-token"
2414                }
2415            }),
2416            None,
2417        );
2418        db.save_provider("claude", &provider)
2419            .expect("save claude provider");
2420        db.set_current_provider("claude", &provider.id)
2421            .expect("set current claude provider");
2422
2423        let mut runtime_config = service.get_config().await.expect("get proxy config");
2424        runtime_config.listen_port = 0;
2425        service
2426            .update_config(&runtime_config)
2427            .await
2428            .expect("persist runtime config");
2429
2430        service
2431            .set_takeover_for_app("claude", true)
2432            .await
2433            .expect("enable claude takeover");
2434        service.stop().await.expect("stop proxy runtime");
2435
2436        let updated = db
2437            .get_provider_by_id("claude-provider", "claude")
2438            .expect("read claude provider")
2439            .expect("claude provider exists");
2440        let env = updated
2441            .settings_config
2442            .get("env")
2443            .and_then(Value::as_object)
2444            .expect("provider env should exist");
2445
2446        assert_eq!(
2447            env.get("ANTHROPIC_AUTH_TOKEN").and_then(Value::as_str),
2448            Some("fresh-live-token"),
2449            "enabling takeover should sync the live Claude token back to the current provider before takeover rewrites it"
2450        );
2451        assert!(
2452            !env.contains_key("ANTHROPIC_API_KEY"),
2453            "sync should not introduce ANTHROPIC_API_KEY when the provider only used ANTHROPIC_AUTH_TOKEN"
2454        );
2455    }
2456
2457    #[tokio::test]
2458    #[serial]
2459    async fn enabling_codex_takeover_syncs_live_token_back_to_current_provider() {
2460        let temp_home = TempDir::new().expect("create temp home");
2461        let _env = TestHomeEnvGuard::set(temp_home.path());
2462        std::fs::create_dir_all(
2463            get_codex_auth_path()
2464                .parent()
2465                .expect("codex auth parent dir"),
2466        )
2467        .expect("create ~/.codex");
2468
2469        write_json_file(
2470            &get_codex_auth_path(),
2471            &json!({
2472                "OPENAI_API_KEY": "fresh-live-token"
2473            }),
2474        )
2475        .expect("seed codex auth.json");
2476        write_text_file(
2477            &get_codex_config_path(),
2478            r#"model_provider = "default"
2479
2480[model_providers.default]
2481base_url = "https://api.openai.com/v1"
2482"#,
2483        )
2484        .expect("seed codex config.toml");
2485
2486        let db = Arc::new(Database::memory().expect("create database"));
2487        let service = ProxyService::new(db.clone());
2488
2489        let provider = Provider::with_id(
2490            "codex-provider".to_string(),
2491            "Codex Provider".to_string(),
2492            json!({
2493                "auth": {
2494                    "OPENAI_API_KEY": "stale-provider-token"
2495                },
2496                "config": r#"model_provider = "default"
2497
2498[model_providers.default]
2499base_url = "https://api.openai.com/v1"
2500"#
2501            }),
2502            None,
2503        );
2504        db.save_provider("codex", &provider)
2505            .expect("save codex provider");
2506        db.set_current_provider("codex", &provider.id)
2507            .expect("set current codex provider");
2508
2509        let mut runtime_config = service.get_config().await.expect("get proxy config");
2510        runtime_config.listen_port = 0;
2511        service
2512            .update_config(&runtime_config)
2513            .await
2514            .expect("persist runtime config");
2515
2516        service
2517            .set_takeover_for_app("codex", true)
2518            .await
2519            .expect("enable codex takeover");
2520        service.stop().await.expect("stop proxy runtime");
2521
2522        let updated = db
2523            .get_provider_by_id("codex-provider", "codex")
2524            .expect("read codex provider")
2525            .expect("codex provider exists");
2526        let auth = updated
2527            .settings_config
2528            .get("auth")
2529            .and_then(Value::as_object)
2530            .expect("provider auth should exist");
2531
2532        assert_eq!(
2533            auth.get("OPENAI_API_KEY").and_then(Value::as_str),
2534            Some("fresh-live-token"),
2535            "enabling takeover should sync the live Codex token back to the current provider before takeover rewrites it"
2536        );
2537    }
2538
2539    #[tokio::test]
2540    #[serial]
2541    async fn enabling_codex_takeover_syncs_live_token_to_effective_current_provider() {
2542        let temp_home = TempDir::new().expect("create temp home");
2543        let _env = TestHomeEnvGuard::set(temp_home.path());
2544        std::fs::create_dir_all(
2545            get_codex_auth_path()
2546                .parent()
2547                .expect("codex auth parent dir"),
2548        )
2549        .expect("create ~/.codex");
2550
2551        write_json_file(
2552            &get_codex_auth_path(),
2553            &json!({
2554                "OPENAI_API_KEY": "fresh-live-token"
2555            }),
2556        )
2557        .expect("seed codex auth.json");
2558        write_text_file(
2559            &get_codex_config_path(),
2560            r#"model_provider = "default"
2561
2562[model_providers.default]
2563base_url = "https://api.openai.com/v1"
2564"#,
2565        )
2566        .expect("seed codex config.toml");
2567
2568        let db = Arc::new(Database::memory().expect("create database"));
2569        let service = ProxyService::new(db.clone());
2570
2571        let db_current = Provider::with_id(
2572            "codex-db-current".to_string(),
2573            "Codex DB Current".to_string(),
2574            json!({
2575                "auth": {
2576                    "OPENAI_API_KEY": "db-current-token"
2577                },
2578                "config": r#"model_provider = "default"
2579
2580[model_providers.default]
2581base_url = "https://api.openai.com/v1"
2582"#
2583            }),
2584            None,
2585        );
2586        let local_current = Provider::with_id(
2587            "codex-local-current".to_string(),
2588            "Codex Local Current".to_string(),
2589            json!({
2590                "auth": {
2591                    "OPENAI_API_KEY": "local-current-token"
2592                },
2593                "config": r#"model_provider = "default"
2594
2595[model_providers.default]
2596base_url = "https://api.openai.com/v1"
2597"#
2598            }),
2599            None,
2600        );
2601        db.save_provider("codex", &db_current)
2602            .expect("save db-current codex provider");
2603        db.save_provider("codex", &local_current)
2604            .expect("save local-current codex provider");
2605        db.set_current_provider("codex", &db_current.id)
2606            .expect("set db current codex provider");
2607        crate::settings::set_current_provider(&AppType::Codex, Some(&local_current.id))
2608            .expect("set local current codex provider");
2609
2610        let mut runtime_config = service.get_config().await.expect("get proxy config");
2611        runtime_config.listen_port = 0;
2612        service
2613            .update_config(&runtime_config)
2614            .await
2615            .expect("persist runtime config");
2616
2617        service
2618            .set_takeover_for_app("codex", true)
2619            .await
2620            .expect("enable codex takeover");
2621        service.stop().await.expect("stop proxy runtime");
2622
2623        let updated_local = db
2624            .get_provider_by_id("codex-local-current", "codex")
2625            .expect("read local-current codex provider")
2626            .expect("local-current codex provider exists");
2627        let local_auth = updated_local
2628            .settings_config
2629            .get("auth")
2630            .and_then(Value::as_object)
2631            .expect("local-current provider auth should exist");
2632        assert_eq!(
2633            local_auth.get("OPENAI_API_KEY").and_then(Value::as_str),
2634            Some("fresh-live-token"),
2635            "takeover sync should follow the effective current provider from local settings"
2636        );
2637
2638        let unchanged_db = db
2639            .get_provider_by_id("codex-db-current", "codex")
2640            .expect("read db-current codex provider")
2641            .expect("db-current codex provider exists");
2642        let db_auth = unchanged_db
2643            .settings_config
2644            .get("auth")
2645            .and_then(Value::as_object)
2646            .expect("db-current provider auth should exist");
2647        assert_eq!(
2648            db_auth.get("OPENAI_API_KEY").and_then(Value::as_str),
2649            Some("db-current-token"),
2650            "DB current provider should remain unchanged when local settings override it"
2651        );
2652    }
2653
2654    #[tokio::test]
2655    #[serial]
2656    async fn enabling_gemini_takeover_syncs_live_token_to_effective_current_provider() {
2657        let temp_home = TempDir::new().expect("create temp home");
2658        let _env = TestHomeEnvGuard::set(temp_home.path());
2659
2660        let db = Arc::new(Database::memory().expect("create database"));
2661        let service = ProxyService::new(db.clone());
2662
2663        service
2664            .write_gemini_live(&json!({
2665                "env": {
2666                    "GEMINI_API_KEY": "fresh-live-token"
2667                }
2668            }))
2669            .expect("seed gemini env");
2670
2671        let db_current = Provider::with_id(
2672            "gemini-db-current".to_string(),
2673            "Gemini DB Current".to_string(),
2674            json!({
2675                "env": {
2676                    "GEMINI_API_KEY": "db-current-token"
2677                }
2678            }),
2679            None,
2680        );
2681        let local_current = Provider::with_id(
2682            "gemini-local-current".to_string(),
2683            "Gemini Local Current".to_string(),
2684            json!({
2685                "env": {
2686                    "GEMINI_API_KEY": "local-current-token"
2687                }
2688            }),
2689            None,
2690        );
2691        db.save_provider("gemini", &db_current)
2692            .expect("save db-current gemini provider");
2693        db.save_provider("gemini", &local_current)
2694            .expect("save local-current gemini provider");
2695        db.set_current_provider("gemini", &db_current.id)
2696            .expect("set db current gemini provider");
2697        crate::settings::set_current_provider(&AppType::Gemini, Some(&local_current.id))
2698            .expect("set local current gemini provider");
2699
2700        let mut runtime_config = service.get_config().await.expect("get proxy config");
2701        runtime_config.listen_port = 0;
2702        service
2703            .update_config(&runtime_config)
2704            .await
2705            .expect("persist runtime config");
2706
2707        service
2708            .set_takeover_for_app("gemini", true)
2709            .await
2710            .expect("enable gemini takeover");
2711        service.stop().await.expect("stop proxy runtime");
2712
2713        let updated_local = db
2714            .get_provider_by_id("gemini-local-current", "gemini")
2715            .expect("read local-current gemini provider")
2716            .expect("local-current gemini provider exists");
2717        let local_env = updated_local
2718            .settings_config
2719            .get("env")
2720            .and_then(Value::as_object)
2721            .expect("local-current provider env should exist");
2722        assert_eq!(
2723            local_env.get("GEMINI_API_KEY").and_then(Value::as_str),
2724            Some("fresh-live-token"),
2725            "takeover sync should follow the effective current provider from local settings"
2726        );
2727
2728        let unchanged_db = db
2729            .get_provider_by_id("gemini-db-current", "gemini")
2730            .expect("read db-current gemini provider")
2731            .expect("db-current gemini provider exists");
2732        let db_env = unchanged_db
2733            .settings_config
2734            .get("env")
2735            .and_then(Value::as_object)
2736            .expect("db-current provider env should exist");
2737        assert_eq!(
2738            db_env.get("GEMINI_API_KEY").and_then(Value::as_str),
2739            Some("db-current-token"),
2740            "DB current provider should remain unchanged when local settings override it"
2741        );
2742    }
2743
2744    #[tokio::test]
2745    #[serial]
2746    async fn foreground_runtime_start_and_stop_syncs_global_proxy_switch() {
2747        let db = Arc::new(Database::memory().expect("create database"));
2748        let service = ProxyService::new(db);
2749
2750        assert!(
2751            !service
2752                .get_global_config()
2753                .await
2754                .expect("read initial global proxy config")
2755                .proxy_enabled,
2756            "precondition: global proxy switch should start disabled"
2757        );
2758
2759        let mut runtime_config = service.get_config().await.expect("get proxy config");
2760        runtime_config.listen_port = 0;
2761        service
2762            .start_with_runtime_config(runtime_config)
2763            .await
2764            .expect("start foreground proxy runtime");
2765
2766        assert!(
2767            service
2768                .get_global_config()
2769                .await
2770                .expect("read global proxy config after start")
2771                .proxy_enabled,
2772            "starting the foreground proxy runtime should enable the persisted global proxy switch"
2773        );
2774
2775        service.stop().await.expect("stop foreground proxy runtime");
2776
2777        assert!(
2778            !service
2779                .get_global_config()
2780                .await
2781                .expect("read global proxy config after stop")
2782                .proxy_enabled,
2783            "stopping the foreground proxy runtime should disable the persisted global proxy switch"
2784        );
2785    }
2786
2787    #[tokio::test]
2788    #[serial]
2789    async fn managed_external_runtime_does_not_publish_session_before_ready_signal() {
2790        let _env = ManagedRuntimeEnvGuard::set("test-managed-session-token");
2791        let db = Arc::new(Database::memory().expect("create database"));
2792        let service = ProxyService::new(db.clone());
2793
2794        let mut runtime_config = service.get_config().await.expect("get proxy config");
2795        runtime_config.listen_port = 0;
2796        service
2797            .start_with_runtime_config(runtime_config)
2798            .await
2799            .expect("start proxy runtime");
2800
2801        assert!(
2802            db.get_setting(PROXY_RUNTIME_SESSION_KEY)
2803                .expect("read runtime session")
2804                .is_none(),
2805            "managed external runtime should wait to publish its session until takeover is finished"
2806        );
2807
2808        service.stop().await.expect("stop proxy runtime");
2809    }
2810
2811    #[tokio::test]
2812    #[serial]
2813    async fn managed_external_runtime_publishes_session_when_ready_signal_is_sent() {
2814        let _env = ManagedRuntimeEnvGuard::set("test-managed-session-token");
2815        let db = Arc::new(Database::memory().expect("create database"));
2816        let service = ProxyService::new(db.clone());
2817
2818        let mut runtime_config = service.get_config().await.expect("get proxy config");
2819        runtime_config.listen_port = 0;
2820        let info = service
2821            .start_with_runtime_config(runtime_config)
2822            .await
2823            .expect("start proxy runtime");
2824
2825        service
2826            .publish_runtime_session_if_needed(&info)
2827            .expect("publish managed runtime session");
2828
2829        let session: PersistedProxyRuntimeSession = serde_json::from_str(
2830            &db.get_setting(PROXY_RUNTIME_SESSION_KEY)
2831                .expect("read runtime session")
2832                .expect("persisted runtime session"),
2833        )
2834        .expect("parse runtime session");
2835        assert!(session.kind.is_managed_external());
2836        assert_eq!(
2837            session.session_token.as_deref(),
2838            Some("test-managed-session-token")
2839        );
2840
2841        service.stop().await.expect("stop proxy runtime");
2842    }
2843
2844    #[tokio::test]
2845    #[serial]
2846    async fn takeover_mutation_waits_for_shared_restore_guard() {
2847        let temp_home = TempDir::new().expect("create temp home");
2848        let _env = TestHomeEnvGuard::set(temp_home.path());
2849        std::fs::create_dir_all(
2850            get_claude_settings_path()
2851                .parent()
2852                .expect("claude settings parent dir"),
2853        )
2854        .expect("create claude settings dir");
2855        write_json_file(
2856            &get_claude_settings_path(),
2857            &json!({
2858                "env": {
2859                    "ANTHROPIC_API_KEY": "live-key"
2860                }
2861            }),
2862        )
2863        .expect("seed claude live config");
2864
2865        let db = Arc::new(Database::init().expect("create database"));
2866        let provider = Provider::with_id(
2867            "claude-provider".to_string(),
2868            "Claude Provider".to_string(),
2869            json!({
2870                "env": {
2871                    "ANTHROPIC_API_KEY": "db-key"
2872                }
2873            }),
2874            Some("claude".to_string()),
2875        );
2876        db.save_provider("claude", &provider)
2877            .expect("save claude provider");
2878        db.set_current_provider("claude", &provider.id)
2879            .expect("set current claude provider");
2880
2881        let service = ProxyService::new(db.clone());
2882        let mut config = service.get_config().await.expect("read proxy config");
2883        config.listen_port = 0;
2884        service
2885            .update_config(&config)
2886            .await
2887            .expect("update proxy config");
2888
2889        let guard = crate::services::state_coordination::acquire_restore_mutation_guard()
2890            .await
2891            .expect("acquire shared restore guard");
2892        let completed = Arc::new(AtomicBool::new(false));
2893        let completed_bg = Arc::clone(&completed);
2894        let service_bg = service.clone();
2895        let handle = tokio::spawn(async move {
2896            service_bg
2897                .set_takeover_for_app("claude", true)
2898                .await
2899                .expect("enable takeover after guard release");
2900            completed_bg.store(true, Ordering::SeqCst);
2901        });
2902
2903        tokio::time::sleep(Duration::from_millis(150)).await;
2904        assert!(
2905            !completed.load(Ordering::SeqCst),
2906            "takeover mutation should wait behind the shared restore guard"
2907        );
2908        assert!(
2909            !handle.is_finished(),
2910            "spawned takeover task should still be blocked on the shared guard"
2911        );
2912
2913        drop(guard);
2914
2915        tokio::time::timeout(Duration::from_secs(5), handle)
2916            .await
2917            .expect("takeover task should complete after guard release")
2918            .expect("takeover task should succeed");
2919        assert!(
2920            completed.load(Ordering::SeqCst),
2921            "takeover mutation should complete once the shared guard is released"
2922        );
2923
2924        service
2925            .set_takeover_for_app("claude", false)
2926            .await
2927            .expect("disable takeover for cleanup");
2928    }
2929
2930    #[tokio::test]
2931    #[serial]
2932    async fn proxy_config_update_waits_for_shared_restore_guard() {
2933        let db = Arc::new(Database::memory().expect("create database"));
2934        let service = ProxyService::new(db.clone());
2935
2936        let initial = service.get_config().await.expect("read initial config");
2937        let mut updated = initial.clone();
2938        updated.listen_port = if initial.listen_port == 15721 {
2939            15722
2940        } else {
2941            initial.listen_port.saturating_add(1)
2942        };
2943        let expected_port = updated.listen_port;
2944
2945        let guard = crate::services::state_coordination::acquire_restore_mutation_guard()
2946            .await
2947            .expect("acquire shared restore guard");
2948        let completed = Arc::new(AtomicBool::new(false));
2949        let completed_bg = Arc::clone(&completed);
2950        let service_bg = service.clone();
2951        let handle = tokio::spawn(async move {
2952            service_bg
2953                .update_config(&updated)
2954                .await
2955                .expect("update proxy config after guard release");
2956            completed_bg.store(true, Ordering::SeqCst);
2957        });
2958
2959        tokio::time::sleep(Duration::from_millis(150)).await;
2960        assert!(
2961            !completed.load(Ordering::SeqCst),
2962            "proxy config update should wait behind the shared restore guard"
2963        );
2964        assert!(
2965            !handle.is_finished(),
2966            "spawned config-update task should still be blocked on the shared guard"
2967        );
2968
2969        drop(guard);
2970
2971        tokio::time::timeout(Duration::from_secs(5), handle)
2972            .await
2973            .expect("config update task should complete after guard release")
2974            .expect("config update task should succeed");
2975        assert!(
2976            completed.load(Ordering::SeqCst),
2977            "proxy config update should complete once the shared guard is released"
2978        );
2979
2980        assert_eq!(
2981            service
2982                .get_config()
2983                .await
2984                .expect("read config after guard release")
2985                .listen_port,
2986            expected_port
2987        );
2988    }
2989
2990    #[tokio::test]
2991    #[serial]
2992    async fn managed_session_ready_info_accepts_persisted_session_without_status_probe() {
2993        let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0))
2994            .await
2995            .expect("bind unused proxy status port");
2996        let port = listener
2997            .local_addr()
2998            .expect("read unused proxy status port")
2999            .port();
3000        drop(listener);
3001
3002        let db = Arc::new(Database::memory().expect("create database"));
3003        let service = ProxyService::new(db.clone());
3004
3005        db.set_setting(
3006            PROXY_RUNTIME_SESSION_KEY,
3007            &serde_json::to_string(&PersistedProxyRuntimeSession {
3008                pid: 4242,
3009                address: "127.0.0.1".to_string(),
3010                port,
3011                started_at: "2026-03-10T00:00:00Z".to_string(),
3012                kind: PersistedProxyRuntimeSessionKind::ManagedExternal,
3013                session_token: Some("expected-session-token".to_string()),
3014            })
3015            .expect("serialize runtime session"),
3016        )
3017        .expect("persist runtime session");
3018
3019        let info = service
3020            .managed_session_ready_info(4242, "expected-session-token")
3021            .await
3022            .expect("persisted managed runtime marker should be treated as ready");
3023
3024        assert_eq!(info.address, "127.0.0.1");
3025        assert_eq!(info.port, port);
3026        assert_eq!(info.started_at, "2026-03-10T00:00:00Z");
3027    }
3028
3029    #[tokio::test]
3030    async fn managed_session_ready_info_prefers_matching_status_snapshot_when_available() {
3031        let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0))
3032            .await
3033            .expect("bind fake proxy status listener");
3034        let port = listener
3035            .local_addr()
3036            .expect("read fake proxy listener addr")
3037            .port();
3038        let status = serde_json::json!({
3039            "running": true,
3040            "address": "127.0.0.1",
3041            "port": port,
3042            "active_connections": 0,
3043            "total_requests": 0,
3044            "success_requests": 0,
3045            "failed_requests": 0,
3046            "success_rate": 0.0,
3047            "uptime_seconds": 12,
3048            "current_provider": null,
3049            "current_provider_id": null,
3050            "last_request_at": null,
3051            "last_error": null,
3052            "failover_count": 0,
3053            "managed_session_token": "expected-session-token"
3054        });
3055
3056        let server = tokio::spawn(async move {
3057            let (mut socket, _) = listener.accept().await.expect("accept status request");
3058            let response = format!(
3059                "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
3060                status.to_string().len(),
3061                status
3062            );
3063            use tokio::io::AsyncWriteExt;
3064            socket
3065                .write_all(response.as_bytes())
3066                .await
3067                .expect("write fake status response");
3068        });
3069
3070        let db = Arc::new(Database::memory().expect("create database"));
3071        let service = ProxyService::new(db.clone());
3072        db.set_setting(
3073            PROXY_RUNTIME_SESSION_KEY,
3074            &serde_json::to_string(&PersistedProxyRuntimeSession {
3075                pid: 4242,
3076                address: "127.0.0.1".to_string(),
3077                port,
3078                started_at: "2026-03-10T00:00:00Z".to_string(),
3079                kind: PersistedProxyRuntimeSessionKind::ManagedExternal,
3080                session_token: Some("expected-session-token".to_string()),
3081            })
3082            .expect("serialize runtime session"),
3083        )
3084        .expect("persist runtime session");
3085
3086        let info = service
3087            .managed_session_ready_info(4242, "expected-session-token")
3088            .await
3089            .expect("matching external status should make the session ready");
3090
3091        assert_eq!(info.address, "127.0.0.1");
3092        assert_eq!(info.port, port);
3093
3094        server.await.expect("fake status server should finish");
3095    }
3096
3097    #[tokio::test]
3098    async fn managed_session_ready_info_rejects_mismatched_status_snapshot() {
3099        let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0))
3100            .await
3101            .expect("bind fake proxy status listener");
3102        let port = listener
3103            .local_addr()
3104            .expect("read fake proxy listener addr")
3105            .port();
3106        let status = serde_json::json!({
3107            "running": true,
3108            "address": "127.0.0.1",
3109            "port": port,
3110            "active_connections": 0,
3111            "total_requests": 0,
3112            "success_requests": 0,
3113            "failed_requests": 0,
3114            "success_rate": 0.0,
3115            "uptime_seconds": 12,
3116            "current_provider": null,
3117            "current_provider_id": null,
3118            "last_request_at": null,
3119            "last_error": null,
3120            "failover_count": 0,
3121            "managed_session_token": "other-session-token"
3122        });
3123
3124        let server = tokio::spawn(async move {
3125            let (mut socket, _) = listener.accept().await.expect("accept status request");
3126            let response = format!(
3127                "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
3128                status.to_string().len(),
3129                status
3130            );
3131            use tokio::io::AsyncWriteExt;
3132            socket
3133                .write_all(response.as_bytes())
3134                .await
3135                .expect("write fake status response");
3136        });
3137
3138        let db = Arc::new(Database::memory().expect("create database"));
3139        let service = ProxyService::new(db.clone());
3140        db.set_setting(
3141            PROXY_RUNTIME_SESSION_KEY,
3142            &serde_json::to_string(&PersistedProxyRuntimeSession {
3143                pid: 4242,
3144                address: "127.0.0.1".to_string(),
3145                port,
3146                started_at: "2026-03-10T00:00:00Z".to_string(),
3147                kind: PersistedProxyRuntimeSessionKind::ManagedExternal,
3148                session_token: Some("expected-session-token".to_string()),
3149            })
3150            .expect("serialize runtime session"),
3151        )
3152        .expect("persist runtime session");
3153
3154        assert!(
3155            service
3156                .managed_session_ready_info(4242, "expected-session-token")
3157                .await
3158                .is_none(),
3159            "startup should keep waiting when /status reports a different managed session token"
3160        );
3161
3162        server.await.expect("fake status server should finish");
3163    }
3164
3165    #[tokio::test]
3166    #[serial]
3167    async fn hot_updating_running_breaker_configs_refreshes_existing_breakers() {
3168        let db = Arc::new(Database::memory().expect("create database"));
3169        let service = ProxyService::new(db.clone());
3170
3171        let provider = Provider::with_id(
3172            "p1".to_string(),
3173            "Provider One".to_string(),
3174            json!({}),
3175            None,
3176        );
3177        db.save_provider("claude", &provider)
3178            .expect("save provider");
3179
3180        let mut app_proxy = db
3181            .get_proxy_config_for_app("claude")
3182            .await
3183            .expect("load app proxy config");
3184        app_proxy.circuit_failure_threshold = 1;
3185        app_proxy.circuit_timeout_seconds = 3600;
3186        db.update_proxy_config_for_app(app_proxy)
3187            .await
3188            .expect("persist initial breaker config");
3189
3190        let mut runtime_config = service.get_config().await.expect("get proxy config");
3191        runtime_config.listen_port = 0;
3192        service
3193            .start_with_runtime_config(runtime_config)
3194            .await
3195            .expect("start proxy");
3196
3197        let router = {
3198            let server_guard = service.runtime.server.read().await;
3199            server_guard
3200                .as_ref()
3201                .expect("running server")
3202                .provider_router()
3203        };
3204
3205        router
3206            .record_result("p1", "claude", false, false, Some("fail".to_string()))
3207            .await
3208            .expect("open breaker");
3209        assert!(!router.allow_provider_request("p1", "claude").await.allowed);
3210
3211        let updated = CircuitBreakerConfig {
3212            failure_threshold: 1,
3213            success_threshold: 2,
3214            timeout_seconds: 0,
3215            error_rate_threshold: 1.0,
3216            min_requests: u32::MAX,
3217        };
3218        db.update_circuit_breaker_config(&updated)
3219            .await
3220            .expect("persist updated breaker config");
3221        service
3222            .update_circuit_breaker_configs(updated)
3223            .await
3224            .expect("hot update running breaker config");
3225
3226        let permit = router.allow_provider_request("p1", "claude").await;
3227        assert!(permit.allowed);
3228        assert!(permit.used_half_open_permit);
3229
3230        service.stop().await.expect("stop proxy");
3231    }
3232
3233    #[tokio::test]
3234    #[serial]
3235    async fn resetting_running_provider_breaker_clears_existing_breaker_state() {
3236        let db = Arc::new(Database::memory().expect("create database"));
3237        let service = ProxyService::new(db.clone());
3238
3239        let provider = Provider::with_id(
3240            "p1".to_string(),
3241            "Provider One".to_string(),
3242            json!({}),
3243            None,
3244        );
3245        db.save_provider("claude", &provider)
3246            .expect("save provider");
3247
3248        let mut app_proxy = db
3249            .get_proxy_config_for_app("claude")
3250            .await
3251            .expect("load app proxy config");
3252        app_proxy.circuit_failure_threshold = 1;
3253        app_proxy.circuit_timeout_seconds = 3600;
3254        db.update_proxy_config_for_app(app_proxy)
3255            .await
3256            .expect("persist breaker config");
3257
3258        let mut runtime_config = service.get_config().await.expect("get proxy config");
3259        runtime_config.listen_port = 0;
3260        service
3261            .start_with_runtime_config(runtime_config)
3262            .await
3263            .expect("start proxy");
3264
3265        let router = {
3266            let server_guard = service.runtime.server.read().await;
3267            server_guard
3268                .as_ref()
3269                .expect("running server")
3270                .provider_router()
3271        };
3272
3273        router
3274            .record_result("p1", "claude", false, false, Some("fail".to_string()))
3275            .await
3276            .expect("open breaker");
3277        assert!(!router.allow_provider_request("p1", "claude").await.allowed);
3278
3279        service
3280            .reset_provider_circuit_breaker("p1", "claude")
3281            .await
3282            .expect("reset running breaker");
3283
3284        let permit = router.allow_provider_request("p1", "claude").await;
3285        assert!(permit.allowed);
3286        assert!(!permit.used_half_open_permit);
3287
3288        service.stop().await.expect("stop proxy");
3289    }
3290
3291    #[tokio::test]
3292    #[serial]
3293    async fn update_live_backup_from_provider_preserves_codex_mcp_servers() {
3294        let db = Arc::new(Database::memory().expect("init db"));
3295        let service = ProxyService::new(db.clone());
3296
3297        db.save_live_backup(
3298            "codex",
3299            &serde_json::to_string(&json!({
3300                "auth": {
3301                    "OPENAI_API_KEY": "old-token"
3302                },
3303                "config": r#"model_provider = "any"
3304model = "gpt-4"
3305
3306[model_providers.any]
3307base_url = "https://old.example/v1"
3308
3309[mcp_servers.echo]
3310command = "npx"
3311args = ["echo-server"]
3312"#
3313            }))
3314            .expect("serialize seed backup"),
3315        )
3316        .await
3317        .expect("seed live backup");
3318
3319        let provider = Provider::with_id(
3320            "p2".to_string(),
3321            "P2".to_string(),
3322            json!({
3323                "auth": {
3324                    "OPENAI_API_KEY": "new-token"
3325                },
3326                "config": r#"model_provider = "any"
3327model = "gpt-5"
3328
3329[model_providers.any]
3330base_url = "https://new.example/v1"
3331"#
3332            }),
3333            None,
3334        );
3335
3336        service
3337            .update_live_backup_from_provider("codex", &provider)
3338            .await
3339            .expect("update live backup");
3340
3341        let backup = db
3342            .get_live_backup("codex")
3343            .await
3344            .expect("get live backup")
3345            .expect("backup exists");
3346        let stored: Value =
3347            serde_json::from_str(&backup.original_config).expect("parse backup json");
3348        let config = stored
3349            .get("config")
3350            .and_then(|v| v.as_str())
3351            .expect("config string");
3352
3353        assert!(
3354            config.contains("[mcp_servers.echo]"),
3355            "existing Codex MCP section should survive proxy hot-switch backup update"
3356        );
3357        assert!(
3358            config.contains("https://new.example/v1"),
3359            "provider-specific base_url should still update to the new provider"
3360        );
3361    }
3362
3363    #[tokio::test]
3364    #[serial]
3365    async fn hot_switch_codex_provider_keeps_model_provider_stable_in_backup_and_restore() {
3366        let temp_home = TempDir::new().expect("create temp home");
3367        let _env = TestHomeEnvGuard::set(temp_home.path());
3368
3369        let db = Arc::new(Database::memory().expect("init db"));
3370        let service = ProxyService::new(db.clone());
3371
3372        let provider_a = Provider::with_id(
3373            "a".to_string(),
3374            "RightCode".to_string(),
3375            json!({
3376                "auth": {
3377                    "OPENAI_API_KEY": "rightcode-key"
3378                },
3379                "config": r#"model_provider = "rightcode"
3380model = "gpt-5.4"
3381
3382[model_providers.rightcode]
3383name = "RightCode"
3384base_url = "https://rightcode.example/v1"
3385wire_api = "responses"
3386requires_openai_auth = true
3387"#
3388            }),
3389            None,
3390        );
3391        let provider_b = Provider::with_id(
3392            "b".to_string(),
3393            "AiHubMix".to_string(),
3394            json!({
3395                "auth": {
3396                    "OPENAI_API_KEY": "aihubmix-key"
3397                },
3398                "config": r#"model_provider = "aihubmix"
3399model = "gpt-5.4"
3400
3401[model_providers.aihubmix]
3402name = "AiHubMix"
3403base_url = "https://aihubmix.example/v1"
3404wire_api = "responses"
3405requires_openai_auth = true
3406"#
3407            }),
3408            None,
3409        );
3410
3411        db.save_provider("codex", &provider_a)
3412            .expect("save provider a");
3413        db.save_provider("codex", &provider_b)
3414            .expect("save provider b");
3415        db.set_current_provider("codex", "a")
3416            .expect("set current provider");
3417        crate::settings::set_current_provider(&AppType::Codex, Some("a"))
3418            .expect("set local current provider");
3419        db.save_live_backup(
3420            "codex",
3421            &serde_json::to_string(&provider_a.settings_config).expect("serialize provider a"),
3422        )
3423        .await
3424        .expect("seed live backup");
3425        service
3426            .write_codex_live(&json!({
3427                "auth": {
3428                    "OPENAI_API_KEY": PROXY_TOKEN_PLACEHOLDER
3429                },
3430                "config": r#"model_provider = "rightcode"
3431model = "gpt-5.4"
3432
3433[model_providers.rightcode]
3434name = "RightCode"
3435base_url = "http://127.0.0.1:15721/v1"
3436wire_api = "responses"
3437requires_openai_auth = true
3438"#
3439            }))
3440            .expect("seed taken-over Codex live config");
3441
3442        service
3443            .hot_switch_provider("codex", "b")
3444            .await
3445            .expect("hot switch Codex provider");
3446
3447        let backup = db
3448            .get_live_backup("codex")
3449            .await
3450            .expect("get live backup")
3451            .expect("backup exists");
3452        let stored: Value =
3453            serde_json::from_str(&backup.original_config).expect("parse backup json");
3454        let backup_config = stored
3455            .get("config")
3456            .and_then(Value::as_str)
3457            .expect("backup config string");
3458        let parsed_backup: toml::Value =
3459            toml::from_str(backup_config).expect("parse backup config");
3460        assert_eq!(
3461            parsed_backup.get("model_provider").and_then(|v| v.as_str()),
3462            Some("rightcode"),
3463            "provider-derived restore backup should retain stable Codex model_provider"
3464        );
3465        let backup_model_providers = parsed_backup
3466            .get("model_providers")
3467            .and_then(|v| v.as_table())
3468            .expect("backup model_providers");
3469        assert!(backup_model_providers.get("aihubmix").is_none());
3470        assert_eq!(
3471            backup_model_providers
3472                .get("rightcode")
3473                .and_then(|v| v.get("base_url"))
3474                .and_then(|v| v.as_str()),
3475            Some("https://aihubmix.example/v1"),
3476            "stable provider id should point at the hot-switched provider endpoint"
3477        );
3478
3479        service
3480            .restore_live_config_for_app(&AppType::Codex)
3481            .await
3482            .expect("restore Codex live config");
3483
3484        let live = service.read_codex_live().expect("read Codex live config");
3485        let live_config = live
3486            .get("config")
3487            .and_then(Value::as_str)
3488            .expect("live config string");
3489        let parsed_live: toml::Value = toml::from_str(live_config).expect("parse live config");
3490        assert_eq!(
3491            parsed_live.get("model_provider").and_then(|v| v.as_str()),
3492            Some("rightcode"),
3493            "restored Codex live config should not switch history buckets"
3494        );
3495        assert_eq!(
3496            live.get("auth")
3497                .and_then(|auth| auth.get("OPENAI_API_KEY"))
3498                .and_then(Value::as_str),
3499            Some("aihubmix-key"),
3500            "restore should still use the hot-switched provider auth"
3501        );
3502    }
3503
3504    #[tokio::test]
3505    #[serial]
3506    async fn update_live_backup_from_provider_for_gemini_keeps_only_env_snapshot() {
3507        let temp_home = TempDir::new().expect("create temp home");
3508        let _env = TestHomeEnvGuard::set(temp_home.path());
3509
3510        let settings_path = crate::gemini_config::get_gemini_settings_path();
3511        std::fs::create_dir_all(
3512            settings_path
3513                .parent()
3514                .expect("gemini settings parent directory"),
3515        )
3516        .expect("create gemini settings directory");
3517        write_json_file(
3518            &settings_path,
3519            &json!({
3520                "mcpServers": {
3521                    "echo": {
3522                        "command": "npx",
3523                        "args": ["echo-server"]
3524                    }
3525                }
3526            }),
3527        )
3528        .expect("seed gemini settings.json");
3529
3530        let db = Arc::new(Database::memory().expect("init db"));
3531        let service = ProxyService::new(db.clone());
3532        db.save_live_backup("gemini", r#"{"env":{"GEMINI_API_KEY":"stale-token"}}"#)
3533            .await
3534            .expect("seed active gemini backup");
3535        let provider = Provider::with_id(
3536            "gemini-provider".to_string(),
3537            "Gemini Provider".to_string(),
3538            json!({
3539                "env": {
3540                    "GEMINI_API_KEY": "gemini-token"
3541                },
3542                "config": {
3543                    "theme": "solarized"
3544                }
3545            }),
3546            None,
3547        );
3548
3549        service
3550            .update_live_backup_from_provider("gemini", &provider)
3551            .await
3552            .expect("update gemini live backup");
3553
3554        let backup = db
3555            .get_live_backup("gemini")
3556            .await
3557            .expect("get gemini live backup")
3558            .expect("gemini backup exists");
3559        let stored: Value =
3560            serde_json::from_str(&backup.original_config).expect("parse gemini backup json");
3561
3562        assert_eq!(
3563            stored.get("env"),
3564            Some(&json!({
3565                "GEMINI_API_KEY": "gemini-token"
3566            })),
3567            "Gemini live backup should keep the provider env for takeover restore"
3568        );
3569        assert!(
3570            stored.get("config").is_none(),
3571            "Gemini live backup should not snapshot settings.json/config; upstream keeps only env"
3572        );
3573    }
3574
3575    #[tokio::test]
3576    #[serial]
3577    async fn update_live_backup_from_provider_applies_claude_common_config_without_takeover() {
3578        let temp_home = TempDir::new().expect("create temp home");
3579        let _env = TestHomeEnvGuard::set(temp_home.path());
3580
3581        let db = Arc::new(Database::memory().expect("init db"));
3582        db.set_config_snippet(
3583            "claude",
3584            Some(
3585                serde_json::json!({
3586                    "includeCoAuthoredBy": false
3587                })
3588                .to_string(),
3589            ),
3590        )
3591        .expect("set common config snippet");
3592
3593        let service = ProxyService::new(db.clone());
3594
3595        let mut provider = Provider::with_id(
3596            "claude-provider".to_string(),
3597            "Claude Provider".to_string(),
3598            json!({
3599                "env": {
3600                    "ANTHROPIC_AUTH_TOKEN": "token",
3601                    "ANTHROPIC_BASE_URL": "https://claude.example"
3602                }
3603            }),
3604            None,
3605        );
3606        provider.meta = Some(ProviderMeta {
3607            apply_common_config: Some(true),
3608            ..Default::default()
3609        });
3610
3611        service
3612            .update_live_backup_from_provider("claude", &provider)
3613            .await
3614            .expect("update claude live backup");
3615
3616        let backup = db
3617            .get_live_backup("claude")
3618            .await
3619            .expect("get claude live backup")
3620            .expect("claude backup exists");
3621        let stored: Value =
3622            serde_json::from_str(&backup.original_config).expect("parse claude backup json");
3623
3624        assert_eq!(
3625            stored.get("includeCoAuthoredBy").and_then(Value::as_bool),
3626            Some(false),
3627            "common config should be applied into Claude restore backup even before takeover is active"
3628        );
3629    }
3630
3631    #[tokio::test]
3632    #[serial]
3633    async fn switch_proxy_target_updates_live_backup_when_taken_over() {
3634        let temp_home = TempDir::new().expect("create temp home");
3635        let _env = TestHomeEnvGuard::set(temp_home.path());
3636
3637        let db = Arc::new(Database::memory().expect("init db"));
3638        let service = ProxyService::new(db.clone());
3639
3640        let provider_a = Provider::with_id(
3641            "a".to_string(),
3642            "A".to_string(),
3643            json!({
3644                "env": {
3645                    "ANTHROPIC_API_KEY": "a-key"
3646                }
3647            }),
3648            None,
3649        );
3650        let provider_b = Provider::with_id(
3651            "b".to_string(),
3652            "B".to_string(),
3653            json!({
3654                "env": {
3655                    "ANTHROPIC_API_KEY": "b-key"
3656                }
3657            }),
3658            None,
3659        );
3660        db.save_provider("claude", &provider_a)
3661            .expect("save provider a");
3662        db.save_provider("claude", &provider_b)
3663            .expect("save provider b");
3664        db.set_current_provider("claude", "a")
3665            .expect("set current provider");
3666
3667        db.save_live_backup("claude", "{\"env\":{}}")
3668            .await
3669            .expect("seed live backup");
3670
3671        service
3672            .switch_proxy_target("claude", "b")
3673            .await
3674            .expect("switch proxy target");
3675
3676        assert_eq!(
3677            crate::settings::get_current_provider(&AppType::Claude).as_deref(),
3678            Some("b")
3679        );
3680
3681        let backup = db
3682            .get_live_backup("claude")
3683            .await
3684            .expect("get live backup")
3685            .expect("backup exists");
3686        let expected = serde_json::to_string(&provider_b.settings_config).expect("serialize");
3687        assert_eq!(backup.original_config, expected);
3688    }
3689
3690    #[tokio::test]
3691    #[serial]
3692    async fn restore_live_from_current_provider_applies_codex_common_config() {
3693        let temp_home = TempDir::new().expect("create temp home");
3694        let _env = TestHomeEnvGuard::set(temp_home.path());
3695
3696        let db = Arc::new(Database::memory().expect("init db"));
3697        db.set_config_snippet(
3698            "codex",
3699            Some("disable_response_storage = true\n".to_string()),
3700        )
3701        .expect("set codex common config snippet");
3702
3703        let service = ProxyService::new(db.clone());
3704        let mut provider = Provider::with_id(
3705            "codex-provider".to_string(),
3706            "Codex Provider".to_string(),
3707            json!({
3708                "auth": {
3709                    "OPENAI_API_KEY": "codex-token"
3710                },
3711                "config": r#"model_provider = "default"
3712model = "gpt-5.2-codex"
3713
3714[model_providers.default]
3715base_url = "https://api.example/v1"
3716"#
3717            }),
3718            None,
3719        );
3720        provider.meta = Some(ProviderMeta {
3721            apply_common_config: Some(true),
3722            ..Default::default()
3723        });
3724        db.save_provider("codex", &provider)
3725            .expect("save codex provider");
3726        db.set_current_provider("codex", &provider.id)
3727            .expect("set current codex provider");
3728
3729        service
3730            .restore_live_from_current_provider(&AppType::Codex)
3731            .await
3732            .expect("restore codex live from current provider");
3733
3734        let auth: Value = read_json_file(&get_codex_auth_path()).expect("read codex auth.json");
3735        assert_eq!(
3736            auth.get("OPENAI_API_KEY").and_then(Value::as_str),
3737            Some("codex-token"),
3738            "Codex fallback restore should write auth.json from the current provider"
3739        );
3740
3741        let config_text =
3742            std::fs::read_to_string(get_codex_config_path()).expect("read codex config.toml");
3743        assert!(
3744            config_text.contains("disable_response_storage = true"),
3745            "Codex fallback restore should apply the common config snippet like upstream"
3746        );
3747    }
3748}