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(¤t_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 let has_codex_config = live
1578 .get(crate::codex_config::CODEX_STRUCTURED_CONFIG_KEY)
1579 .is_some()
1580 || live
1581 .get(crate::codex_config::CODEX_LEGACY_CONFIG_KEY)
1582 .is_some();
1583 if has_codex_config {
1584 let config_text =
1585 crate::codex_config::codex_config_text_from_settings(&live)
1586 .map_err(|error| format!("render Codex config failed: {error}"))?;
1587 let prefer_structured = live
1588 .get(crate::codex_config::CODEX_STRUCTURED_CONFIG_KEY)
1589 .is_some()
1590 && live
1591 .get(crate::codex_config::CODEX_LEGACY_CONFIG_KEY)
1592 .is_none();
1593 crate::codex_config::set_codex_config_text_in_settings(
1594 &mut live,
1595 &codex_toml::remove_loopback_base_url_from_toml(&config_text),
1596 prefer_structured,
1597 )
1598 .map_err(|error| format!("update Codex config failed: {error}"))?;
1599 }
1600 }
1601 AppType::Gemini => {
1602 if let Some(env) = live.get_mut("env").and_then(Value::as_object_mut) {
1603 if env
1604 .get("GOOGLE_GEMINI_BASE_URL")
1605 .and_then(Value::as_str)
1606 .is_some_and(codex_toml::is_loopback_proxy_url)
1607 {
1608 env.remove("GOOGLE_GEMINI_BASE_URL");
1609 }
1610 if env
1611 .get("GEMINI_API_KEY")
1612 .and_then(Value::as_str)
1613 .is_some_and(|value| value == PROXY_TOKEN_PLACEHOLDER)
1614 {
1615 env.remove("GEMINI_API_KEY");
1616 }
1617 }
1618 }
1619 _ => {
1620 return Err(format!(
1621 "proxy takeover not supported for {}",
1622 app_type.as_str()
1623 ));
1624 }
1625 }
1626
1627 self.write_live_config_for_app(app_type, &live)
1628 }
1629
1630 fn read_claude_live(&self) -> Result<Value, String> {
1631 let path = get_claude_settings_path();
1632 if !path.exists() {
1633 return Err("Claude settings.json does not exist".to_string());
1634 }
1635
1636 let value: Value = read_json_file(&path)
1637 .map_err(|error| format!("read Claude settings.json failed: {error}"))?;
1638 if value.is_object() {
1639 Ok(value)
1640 } else {
1641 Err("Claude settings.json root must be an object".to_string())
1642 }
1643 }
1644
1645 fn write_claude_live(&self, config: &Value) -> Result<(), String> {
1646 write_json_file(&get_claude_settings_path(), config)
1647 .map_err(|error| format!("write Claude settings.json failed: {error}"))
1648 }
1649
1650 fn read_codex_live(&self) -> Result<Value, String> {
1651 let auth_path = get_codex_auth_path();
1652 let config_path = get_codex_config_path();
1653 if !auth_path.exists() && !config_path.exists() {
1654 return Err("Codex live config does not exist".to_string());
1655 }
1656
1657 let auth = if auth_path.exists() {
1658 read_json_file(&auth_path)
1659 .map_err(|error| format!("read Codex auth.json failed: {error}"))?
1660 } else {
1661 json!({})
1662 };
1663 let config = if config_path.exists() {
1664 std::fs::read_to_string(&config_path)
1665 .map_err(|error| format!("read Codex config.toml failed: {error}"))?
1666 } else {
1667 String::new()
1668 };
1669
1670 Ok(json!({
1671 "auth": auth,
1672 "config": config,
1673 }))
1674 }
1675
1676 fn write_codex_live(&self, config: &Value) -> Result<(), String> {
1677 let auth = config
1678 .get("auth")
1679 .filter(|value| !value.as_object().is_some_and(|object| object.is_empty()));
1680 let has_config = config
1681 .get(crate::codex_config::CODEX_STRUCTURED_CONFIG_KEY)
1682 .is_some()
1683 || config
1684 .get(crate::codex_config::CODEX_LEGACY_CONFIG_KEY)
1685 .is_some();
1686 let config_text = if has_config {
1687 Some(
1688 crate::codex_config::codex_config_text_from_settings(config)
1689 .map_err(|error| format!("render Codex config failed: {error}"))?,
1690 )
1691 } else {
1692 None
1693 };
1694 let auth_path = get_codex_auth_path();
1695
1696 match (auth, config_text.as_deref()) {
1698 (Some(auth), Some(config_text)) => write_codex_live_atomic(auth, Some(config_text))
1699 .map_err(|error| format!("write Codex live config failed: {error}")),
1700 (Some(auth), None) => write_json_file(&get_codex_auth_path(), auth)
1701 .map_err(|error| format!("write Codex auth.json failed: {error}")),
1702 (None, Some(config_text)) => {
1703 if auth_path.exists() {
1704 std::fs::remove_file(&auth_path)
1705 .map_err(|error| format!("remove Codex auth.json failed: {error}"))?;
1706 }
1707 write_text_file(&get_codex_config_path(), config_text)
1708 .map_err(|error| format!("write Codex config.toml failed: {error}"))
1709 }
1710 (None, None) => {
1711 if auth_path.exists() {
1712 std::fs::remove_file(&auth_path)
1713 .map_err(|error| format!("remove Codex auth.json failed: {error}"))?;
1714 }
1715 Ok(())
1716 }
1717 }
1718 }
1719
1720 fn build_live_snapshot_from_provider(
1721 &self,
1722 app_type: &AppType,
1723 provider: &Provider,
1724 ) -> Result<Value, String> {
1725 let common_config_snippet =
1726 self.db
1727 .get_config_snippet(app_type.as_str())
1728 .map_err(|error| {
1729 format!(
1730 "load common config snippet for {} failed: {error}",
1731 app_type.as_str()
1732 )
1733 })?;
1734 let apply_common_config = provider
1735 .meta
1736 .as_ref()
1737 .and_then(|meta| meta.apply_common_config)
1738 .unwrap_or(true);
1739
1740 crate::services::provider::ProviderService::build_live_backup_snapshot(
1741 app_type,
1742 provider,
1743 common_config_snippet.as_deref(),
1744 apply_common_config,
1745 )
1746 .map_err(|error| {
1747 format!(
1748 "build {} live snapshot from provider failed: {error}",
1749 app_type.as_str()
1750 )
1751 })
1752 }
1753
1754 fn persist_runtime_session(&self, info: &ProxyServerInfo) -> Result<(), String> {
1755 let session = PersistedProxyRuntimeSession {
1756 pid: std::process::id(),
1757 address: info.address.clone(),
1758 port: info.port,
1759 started_at: info.started_at.clone(),
1760 kind: PersistedProxyRuntimeSessionKind::from_env(),
1761 session_token: std::env::var(PROXY_RUNTIME_SESSION_TOKEN_ENV_KEY)
1762 .ok()
1763 .filter(|value| !value.trim().is_empty()),
1764 };
1765 let serialized = serde_json::to_string(&session)
1766 .map_err(|error| format!("serialize proxy runtime session failed: {error}"))?;
1767 self.db
1768 .set_setting(PROXY_RUNTIME_SESSION_KEY, &serialized)
1769 .map_err(|error| format!("persist proxy runtime session failed: {error}"))
1770 }
1771
1772 fn defer_runtime_session_publish() -> bool {
1773 PersistedProxyRuntimeSessionKind::from_env().is_managed_external()
1774 }
1775
1776 fn clear_persisted_runtime_session(&self) -> Result<(), String> {
1777 self.db
1778 .delete_setting(PROXY_RUNTIME_SESSION_KEY)
1779 .map_err(|error| format!("clear proxy runtime session failed: {error}"))
1780 }
1781
1782 fn load_persisted_runtime_session(&self) -> Option<PersistedProxyRuntimeSession> {
1783 let raw = self
1784 .db
1785 .get_setting(PROXY_RUNTIME_SESSION_KEY)
1786 .ok()
1787 .flatten()?;
1788 let raw = raw.trim();
1789 if raw.is_empty() {
1790 return None;
1791 }
1792
1793 match serde_json::from_str(raw) {
1794 Ok(session) => Some(session),
1795 Err(_) => {
1796 let _ = self.clear_persisted_runtime_session();
1797 None
1798 }
1799 }
1800 }
1801
1802 fn is_process_alive(pid: u32) -> bool {
1803 if pid == 0 {
1804 return false;
1805 }
1806
1807 #[cfg(unix)]
1808 {
1809 let rc = unsafe { libc::kill(pid as i32, 0) };
1810 return rc == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM);
1811 }
1812
1813 #[cfg(not(unix))]
1814 {
1815 pid == std::process::id()
1816 }
1817 }
1818
1819 fn has_managed_external_ownership_signal(session: &PersistedProxyRuntimeSession) -> bool {
1820 session.session_token.is_some() && Self::is_detached_session_leader(session.pid)
1821 }
1822
1823 #[cfg(unix)]
1824 fn is_detached_session_leader(pid: u32) -> bool {
1825 if pid == 0 {
1826 return false;
1827 }
1828
1829 let sid = unsafe { libc::getsid(pid as i32) };
1830 let pgid = unsafe { libc::getpgid(pid as i32) };
1831 sid == pid as i32 && pgid == pid as i32
1832 }
1833
1834 #[cfg(not(unix))]
1835 fn is_detached_session_leader(_pid: u32) -> bool {
1836 false
1837 }
1838
1839 async fn managed_session_ready_info(
1840 &self,
1841 child_pid: u32,
1842 session_token: &str,
1843 ) -> Option<ProxyServerInfo> {
1844 let session = self
1845 .load_persisted_runtime_session()
1846 .filter(|session| session.pid == child_pid)
1847 .filter(|session| session.kind.is_managed_external())
1848 .filter(|session| session.session_token.as_deref() == Some(session_token))?;
1849
1850 match Self::probe_external_proxy_status(&session).await {
1851 ExternalProxyStatusProbe::Matched(status) => Some(ProxyServerInfo {
1852 address: status.address,
1853 port: status.port,
1854 started_at: session.started_at,
1855 }),
1856 ExternalProxyStatusProbe::Unreachable => Some(ProxyServerInfo {
1857 address: session.address,
1858 port: session.port,
1859 started_at: session.started_at,
1860 }),
1861 ExternalProxyStatusProbe::Mismatched => None,
1862 }
1863 }
1864
1865 async fn probe_external_proxy_status(
1866 session: &PersistedProxyRuntimeSession,
1867 ) -> ExternalProxyStatusProbe {
1868 let Some(expected_session_token) = session.session_token.as_deref() else {
1869 return ExternalProxyStatusProbe::Unreachable;
1870 };
1871 let client = reqwest::Client::builder()
1872 .timeout(Duration::from_millis(500))
1873 .build();
1874 let Ok(client) = client else {
1875 return ExternalProxyStatusProbe::Unreachable;
1876 };
1877
1878 let response = client
1879 .get(Self::build_session_status_url(session))
1880 .send()
1881 .await;
1882 let Ok(response) = response else {
1883 return ExternalProxyStatusProbe::Unreachable;
1884 };
1885 if !response.status().is_success() {
1886 return ExternalProxyStatusProbe::Unreachable;
1887 }
1888
1889 let mut status = match response.json::<ProxyStatus>().await {
1890 Ok(status) => status,
1891 Err(_) => return ExternalProxyStatusProbe::Unreachable,
1892 };
1893 if status.managed_session_token.as_deref() != Some(expected_session_token) {
1894 return ExternalProxyStatusProbe::Mismatched;
1895 }
1896 status.running = true;
1897 if status.address.trim().is_empty() {
1898 status.address = session.address.clone();
1899 }
1900 if status.port == 0 {
1901 status.port = session.port;
1902 }
1903 ExternalProxyStatusProbe::Matched(status)
1904 }
1905
1906 fn build_session_status_url(session: &PersistedProxyRuntimeSession) -> String {
1907 let connect_host = match session.address.as_str() {
1908 "0.0.0.0" => "127.0.0.1".to_string(),
1909 "::" => "::1".to_string(),
1910 value => value.to_string(),
1911 };
1912 let connect_host = if connect_host.contains(':') && !connect_host.starts_with('[') {
1913 format!("[{connect_host}]")
1914 } else {
1915 connect_host
1916 };
1917
1918 format!("http://{}:{}/status", connect_host, session.port)
1919 }
1920
1921 async fn terminate_external_process(pid: u32) -> Result<(), String> {
1922 if pid == 0 || pid == std::process::id() || !Self::is_process_alive(pid) {
1923 return Ok(());
1924 }
1925
1926 #[cfg(unix)]
1927 {
1928 let rc = unsafe { libc::kill(pid as i32, libc::SIGTERM) };
1929 if rc != 0 {
1930 let error = std::io::Error::last_os_error();
1931 if error.raw_os_error() != Some(libc::ESRCH) {
1932 return Err(format!("stop managed proxy session failed: {error}"));
1933 }
1934 }
1935
1936 let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
1937 while tokio::time::Instant::now() < deadline {
1938 if !Self::is_process_alive(pid) {
1939 return Ok(());
1940 }
1941 tokio::time::sleep(Duration::from_millis(100)).await;
1942 }
1943
1944 let rc = unsafe { libc::kill(pid as i32, libc::SIGKILL) };
1945 if rc != 0 {
1946 let error = std::io::Error::last_os_error();
1947 if error.raw_os_error() != Some(libc::ESRCH) {
1948 return Err(format!("force stop managed proxy session failed: {error}"));
1949 }
1950 }
1951
1952 let deadline = tokio::time::Instant::now() + Duration::from_secs(1);
1953 while tokio::time::Instant::now() < deadline {
1954 if !Self::is_process_alive(pid) {
1955 return Ok(());
1956 }
1957 tokio::time::sleep(Duration::from_millis(50)).await;
1958 }
1959
1960 return Err(format!(
1961 "managed proxy session did not exit after termination signal: pid {}",
1962 pid
1963 ));
1964 }
1965
1966 #[cfg(not(unix))]
1967 {
1968 let _ = pid;
1969 Err("managed proxy session stop is only supported on unix in this build".to_string())
1970 }
1971 }
1972
1973 fn spawn_managed_child_reaper(mut child: std::process::Child) {
1974 tokio::task::spawn_blocking(move || {
1975 let _ = child.wait();
1976 });
1977 }
1978
1979 fn ensure_managed_sessions_supported() -> Result<(), String> {
1980 #[cfg(unix)]
1981 {
1982 Ok(())
1983 }
1984
1985 #[cfg(not(unix))]
1986 {
1987 Err("managed proxy sessions are unsupported on non-unix platforms".to_string())
1988 }
1989 }
1990
1991 fn resolve_managed_proxy_executable() -> Result<std::path::PathBuf, String> {
1992 if let Some(path) = std::env::var_os("CARGO_BIN_EXE_cc-switch-tui") {
1993 return Ok(path.into());
1994 }
1995
1996 let current_exe = std::env::current_exe()
1997 .map_err(|error| format!("resolve managed proxy executable failed: {error}"))?;
1998
1999 if current_exe
2000 .file_stem()
2001 .and_then(|value| value.to_str())
2002 .is_some_and(|value| value.starts_with("cc-switch-tui"))
2003 {
2004 return Ok(current_exe);
2005 }
2006
2007 if let Some(debug_dir) = current_exe.parent().and_then(|parent| parent.parent()) {
2008 let candidate = debug_dir.join(format!("cc-switch{}", std::env::consts::EXE_SUFFIX));
2009 if candidate.exists() {
2010 return Ok(candidate);
2011 }
2012 }
2013
2014 Ok(current_exe)
2015 }
2016
2017 async fn sync_persisted_global_proxy_enabled(&self, enabled: bool) -> Result<(), String> {
2018 let mut config = self
2019 .db
2020 .get_global_proxy_config()
2021 .await
2022 .map_err(|error| format!("load global proxy config failed: {error}"))?;
2023 if config.proxy_enabled == enabled {
2024 return Ok(());
2025 }
2026
2027 config.proxy_enabled = enabled;
2028 self.db
2029 .update_global_proxy_config(config)
2030 .await
2031 .map_err(|error| format!("update global proxy switch failed: {error}"))
2032 }
2033
2034 fn read_gemini_live(&self) -> Result<Value, String> {
2035 let env_path = get_gemini_env_path();
2036 if !env_path.exists() {
2037 return Err("Gemini .env does not exist".to_string());
2038 }
2039
2040 let env = read_gemini_env().map_err(|error| format!("read Gemini .env failed: {error}"))?;
2041 Ok(env_to_json(&env))
2042 }
2043
2044 fn write_gemini_live(&self, config: &Value) -> Result<(), String> {
2045 let env =
2046 json_to_env(config).map_err(|error| format!("build Gemini .env failed: {error}"))?;
2047 write_gemini_env_atomic(&env).map_err(|error| format!("write Gemini .env failed: {error}"))
2048 }
2049
2050 fn takeover_app_from_str(app_type: &str) -> Result<AppType, String> {
2051 match app_type {
2052 "claude" => Ok(AppType::Claude),
2053 "codex" => Ok(AppType::Codex),
2054 "gemini" => Ok(AppType::Gemini),
2055 _ => Err(format!("proxy takeover not supported for app: {app_type}")),
2056 }
2057 }
2058}
2059
2060#[cfg(test)]
2061mod tests {
2062 use super::*;
2063 use crate::provider::ProviderMeta;
2064 use crate::proxy::circuit_breaker::CircuitBreakerConfig;
2065 use crate::test_support::{lock_test_home_and_settings, set_test_home_override};
2066 use serial_test::serial;
2067 use std::ffi::OsString;
2068 use std::path::Path;
2069 use std::sync::atomic::{AtomicBool, Ordering};
2070 use tempfile::TempDir;
2071
2072 struct ManagedRuntimeEnvGuard {
2073 old_kind: Option<OsString>,
2074 old_token: Option<OsString>,
2075 }
2076
2077 impl ManagedRuntimeEnvGuard {
2078 fn set(token: &str) -> Self {
2079 let old_kind = std::env::var_os(PROXY_RUNTIME_KIND_ENV_KEY);
2080 let old_token = std::env::var_os(PROXY_RUNTIME_SESSION_TOKEN_ENV_KEY);
2081 unsafe {
2082 std::env::set_var(
2083 PROXY_RUNTIME_KIND_ENV_KEY,
2084 PersistedProxyRuntimeSessionKind::ManagedExternal.as_env_value(),
2085 );
2086 std::env::set_var(PROXY_RUNTIME_SESSION_TOKEN_ENV_KEY, token);
2087 }
2088 Self {
2089 old_kind,
2090 old_token,
2091 }
2092 }
2093 }
2094
2095 impl Drop for ManagedRuntimeEnvGuard {
2096 fn drop(&mut self) {
2097 match &self.old_kind {
2098 Some(value) => unsafe { std::env::set_var(PROXY_RUNTIME_KIND_ENV_KEY, value) },
2099 None => unsafe { std::env::remove_var(PROXY_RUNTIME_KIND_ENV_KEY) },
2100 }
2101 match &self.old_token {
2102 Some(value) => unsafe {
2103 std::env::set_var(PROXY_RUNTIME_SESSION_TOKEN_ENV_KEY, value)
2104 },
2105 None => unsafe { std::env::remove_var(PROXY_RUNTIME_SESSION_TOKEN_ENV_KEY) },
2106 }
2107 }
2108 }
2109
2110 struct TestHomeEnvGuard {
2111 _lock: crate::test_support::TestHomeSettingsLock,
2112 old_home: Option<OsString>,
2113 old_userprofile: Option<OsString>,
2114 old_config_dir: Option<OsString>,
2115 }
2116
2117 impl TestHomeEnvGuard {
2118 fn set(home: &Path) -> Self {
2119 let lock = lock_test_home_and_settings();
2120 let old_home = std::env::var_os("HOME");
2121 let old_userprofile = std::env::var_os("USERPROFILE");
2122 let old_config_dir = std::env::var_os("CC_SWITCH_CONFIG_DIR");
2123 unsafe {
2124 std::env::set_var("HOME", home);
2125 std::env::set_var("USERPROFILE", home);
2126 std::env::set_var("CC_SWITCH_CONFIG_DIR", home.join(".cc-switch"));
2127 }
2128 set_test_home_override(Some(home));
2129 crate::settings::reload_test_settings();
2130 Self {
2131 _lock: lock,
2132 old_home,
2133 old_userprofile,
2134 old_config_dir,
2135 }
2136 }
2137 }
2138
2139 impl Drop for TestHomeEnvGuard {
2140 fn drop(&mut self) {
2141 match &self.old_home {
2142 Some(value) => unsafe { std::env::set_var("HOME", value) },
2143 None => unsafe { std::env::remove_var("HOME") },
2144 }
2145 match &self.old_userprofile {
2146 Some(value) => unsafe { std::env::set_var("USERPROFILE", value) },
2147 None => unsafe { std::env::remove_var("USERPROFILE") },
2148 }
2149 match &self.old_config_dir {
2150 Some(value) => unsafe { std::env::set_var("CC_SWITCH_CONFIG_DIR", value) },
2151 None => unsafe { std::env::remove_var("CC_SWITCH_CONFIG_DIR") },
2152 }
2153 set_test_home_override(self.old_home.as_deref().map(Path::new));
2154 crate::settings::reload_test_settings();
2155 }
2156 }
2157
2158 #[tokio::test]
2159 #[serial]
2160 async fn takeover_activation_rejects_missing_current_provider_when_failover_disabled() {
2161 let temp_home = TempDir::new().expect("create temp home");
2162 let _env = TestHomeEnvGuard::set(temp_home.path());
2163
2164 let db = Arc::new(Database::memory().expect("create database"));
2165 let service = ProxyService::new(db.clone());
2166
2167 let error = service
2168 .set_takeover_for_app("claude", true)
2169 .await
2170 .expect_err("takeover should require an active provider when failover is disabled");
2171
2172 assert!(
2173 error.contains("cannot enable proxy because no active provider is selected"),
2174 "{error}"
2175 );
2176 assert!(
2177 !db.get_proxy_config_for_app("claude")
2178 .await
2179 .expect("load claude proxy config")
2180 .enabled,
2181 "failed validation should not enable takeover state"
2182 );
2183 }
2184
2185 #[tokio::test]
2186 #[serial]
2187 async fn takeover_activation_allows_current_provider_when_failover_disabled() {
2188 let temp_home = TempDir::new().expect("create temp home");
2189 let _env = TestHomeEnvGuard::set(temp_home.path());
2190 std::fs::create_dir_all(
2191 get_claude_settings_path()
2192 .parent()
2193 .expect("claude settings parent dir"),
2194 )
2195 .expect("create ~/.claude");
2196 write_json_file(
2197 &get_claude_settings_path(),
2198 &json!({
2199 "env": {
2200 "ANTHROPIC_BASE_URL": "https://api.anthropic.com",
2201 "ANTHROPIC_AUTH_TOKEN": "fresh-live-token"
2202 }
2203 }),
2204 )
2205 .expect("seed claude live config");
2206
2207 let db = Arc::new(Database::memory().expect("create database"));
2208 let service = ProxyService::new(db.clone());
2209 let provider = Provider::with_id(
2210 "claude-provider".to_string(),
2211 "Claude Provider".to_string(),
2212 json!({
2213 "env": {
2214 "ANTHROPIC_BASE_URL": "https://api.anthropic.com",
2215 "ANTHROPIC_AUTH_TOKEN": "stale-provider-token"
2216 }
2217 }),
2218 None,
2219 );
2220 db.save_provider("claude", &provider)
2221 .expect("save claude provider");
2222 db.set_current_provider("claude", &provider.id)
2223 .expect("set current claude provider");
2224 let mut runtime_config = service.get_config().await.expect("get proxy config");
2225 runtime_config.listen_port = 0;
2226 service
2227 .update_config(&runtime_config)
2228 .await
2229 .expect("persist runtime config");
2230
2231 service
2232 .set_takeover_for_app("claude", true)
2233 .await
2234 .expect("takeover should allow an active provider when failover is disabled");
2235
2236 assert!(
2237 db.get_proxy_config_for_app("claude")
2238 .await
2239 .expect("load claude proxy config")
2240 .enabled
2241 );
2242 service.stop().await.expect("stop proxy runtime");
2243 }
2244
2245 #[tokio::test]
2246 #[serial]
2247 async fn takeover_activation_rejects_empty_queue_when_failover_enabled() {
2248 let temp_home = TempDir::new().expect("create temp home");
2249 let _env = TestHomeEnvGuard::set(temp_home.path());
2250
2251 let db = Arc::new(Database::memory().expect("create database"));
2252 let service = ProxyService::new(db.clone());
2253 let provider = Provider::with_id(
2254 "claude-provider".to_string(),
2255 "Claude Provider".to_string(),
2256 json!({
2257 "env": {
2258 "ANTHROPIC_BASE_URL": "https://api.anthropic.com",
2259 "ANTHROPIC_AUTH_TOKEN": "token"
2260 }
2261 }),
2262 None,
2263 );
2264 db.save_provider("claude", &provider)
2265 .expect("save claude provider");
2266 db.set_current_provider("claude", &provider.id)
2267 .expect("set current claude provider");
2268 let mut app_proxy = db
2269 .get_proxy_config_for_app("claude")
2270 .await
2271 .expect("load claude proxy config");
2272 app_proxy.auto_failover_enabled = true;
2273 db.update_proxy_config_for_app(app_proxy)
2274 .await
2275 .expect("enable auto failover");
2276
2277 let error = service
2278 .set_takeover_for_app("claude", true)
2279 .await
2280 .expect_err("takeover should require a non-empty failover queue");
2281
2282 assert!(
2283 error.contains(
2284 "cannot enable proxy because automatic failover is enabled and the failover queue is empty"
2285 ),
2286 "{error}"
2287 );
2288 assert!(
2289 !db.get_proxy_config_for_app("claude")
2290 .await
2291 .expect("load claude proxy config")
2292 .enabled,
2293 "failed validation should not enable takeover state"
2294 );
2295 }
2296
2297 #[tokio::test]
2298 #[serial]
2299 async fn takeover_activation_allows_non_empty_queue_when_failover_enabled() {
2300 let temp_home = TempDir::new().expect("create temp home");
2301 let _env = TestHomeEnvGuard::set(temp_home.path());
2302 std::fs::create_dir_all(
2303 get_claude_settings_path()
2304 .parent()
2305 .expect("claude settings parent dir"),
2306 )
2307 .expect("create ~/.claude");
2308 write_json_file(
2309 &get_claude_settings_path(),
2310 &json!({
2311 "env": {
2312 "ANTHROPIC_BASE_URL": "https://api.anthropic.com",
2313 "ANTHROPIC_AUTH_TOKEN": "fresh-live-token"
2314 }
2315 }),
2316 )
2317 .expect("seed claude live config");
2318
2319 let db = Arc::new(Database::memory().expect("create database"));
2320 let service = ProxyService::new(db.clone());
2321 let provider = Provider::with_id(
2322 "claude-provider".to_string(),
2323 "Claude Provider".to_string(),
2324 json!({
2325 "env": {
2326 "ANTHROPIC_BASE_URL": "https://api.anthropic.com",
2327 "ANTHROPIC_AUTH_TOKEN": "stale-provider-token"
2328 }
2329 }),
2330 None,
2331 );
2332 db.save_provider("claude", &provider)
2333 .expect("save claude provider");
2334 db.add_to_failover_queue("claude", &provider.id)
2335 .expect("queue claude provider");
2336 let mut app_proxy = db
2337 .get_proxy_config_for_app("claude")
2338 .await
2339 .expect("load claude proxy config");
2340 app_proxy.auto_failover_enabled = true;
2341 db.update_proxy_config_for_app(app_proxy)
2342 .await
2343 .expect("enable auto failover");
2344 let mut runtime_config = service.get_config().await.expect("get proxy config");
2345 runtime_config.listen_port = 0;
2346 service
2347 .update_config(&runtime_config)
2348 .await
2349 .expect("persist runtime config");
2350
2351 service
2352 .set_takeover_for_app("claude", true)
2353 .await
2354 .expect("takeover should allow a non-empty failover queue");
2355
2356 assert!(
2357 db.get_proxy_config_for_app("claude")
2358 .await
2359 .expect("load claude proxy config")
2360 .enabled
2361 );
2362 service.stop().await.expect("stop proxy runtime");
2363 }
2364
2365 #[tokio::test]
2366 #[serial]
2367 async fn recover_takeovers_on_startup_cleans_claude_placeholder_only_residue_without_backup() {
2368 let temp_home = TempDir::new().expect("create temp home");
2369 let _env = TestHomeEnvGuard::set(temp_home.path());
2370 std::fs::create_dir_all(
2371 get_claude_settings_path()
2372 .parent()
2373 .expect("claude settings parent dir"),
2374 )
2375 .expect("create ~/.claude");
2376
2377 write_json_file(
2378 &get_claude_settings_path(),
2379 &json!({
2380 "env": {
2381 "ANTHROPIC_AUTH_TOKEN": PROXY_TOKEN_PLACEHOLDER,
2382 "ANTHROPIC_BASE_URL": "https://api.anthropic.com"
2383 }
2384 }),
2385 )
2386 .expect("seed placeholder-only claude live config");
2387
2388 let db = Arc::new(Database::memory().expect("create database"));
2389 let service = ProxyService::new(db);
2390
2391 service
2392 .recover_takeovers_on_startup()
2393 .await
2394 .expect("startup recovery should clean placeholder-only residue");
2395
2396 let live: Value =
2397 read_json_file(&get_claude_settings_path()).expect("read claude live config");
2398 let env = live
2399 .get("env")
2400 .and_then(Value::as_object)
2401 .expect("claude env should be object");
2402 assert!(
2403 !env.contains_key("ANTHROPIC_AUTH_TOKEN"),
2404 "startup recovery should remove stale proxy placeholder tokens even without loopback base_url"
2405 );
2406 assert_eq!(
2407 env.get("ANTHROPIC_BASE_URL").and_then(Value::as_str),
2408 Some("https://api.anthropic.com"),
2409 "startup recovery should keep a non-proxy base_url when only clearing stale placeholder residue"
2410 );
2411 }
2412
2413 #[tokio::test]
2414 #[serial]
2415 async fn enabling_claude_takeover_syncs_live_token_back_to_current_provider() {
2416 let temp_home = TempDir::new().expect("create temp home");
2417 let _env = TestHomeEnvGuard::set(temp_home.path());
2418 std::fs::create_dir_all(
2419 get_claude_settings_path()
2420 .parent()
2421 .expect("claude settings parent dir"),
2422 )
2423 .expect("create ~/.claude");
2424
2425 write_json_file(
2426 &get_claude_settings_path(),
2427 &json!({
2428 "env": {
2429 "ANTHROPIC_BASE_URL": "https://api.anthropic.com",
2430 "ANTHROPIC_AUTH_TOKEN": "fresh-live-token"
2431 }
2432 }),
2433 )
2434 .expect("seed claude live config");
2435
2436 let db = Arc::new(Database::memory().expect("create database"));
2437 let service = ProxyService::new(db.clone());
2438
2439 let provider = Provider::with_id(
2440 "claude-provider".to_string(),
2441 "Claude Provider".to_string(),
2442 json!({
2443 "env": {
2444 "ANTHROPIC_BASE_URL": "https://api.anthropic.com",
2445 "ANTHROPIC_AUTH_TOKEN": "stale-provider-token"
2446 }
2447 }),
2448 None,
2449 );
2450 db.save_provider("claude", &provider)
2451 .expect("save claude provider");
2452 db.set_current_provider("claude", &provider.id)
2453 .expect("set current claude provider");
2454
2455 let mut runtime_config = service.get_config().await.expect("get proxy config");
2456 runtime_config.listen_port = 0;
2457 service
2458 .update_config(&runtime_config)
2459 .await
2460 .expect("persist runtime config");
2461
2462 service
2463 .set_takeover_for_app("claude", true)
2464 .await
2465 .expect("enable claude takeover");
2466 service.stop().await.expect("stop proxy runtime");
2467
2468 let updated = db
2469 .get_provider_by_id("claude-provider", "claude")
2470 .expect("read claude provider")
2471 .expect("claude provider exists");
2472 let env = updated
2473 .settings_config
2474 .get("env")
2475 .and_then(Value::as_object)
2476 .expect("provider env should exist");
2477
2478 assert_eq!(
2479 env.get("ANTHROPIC_AUTH_TOKEN").and_then(Value::as_str),
2480 Some("fresh-live-token"),
2481 "enabling takeover should sync the live Claude token back to the current provider before takeover rewrites it"
2482 );
2483 assert!(
2484 !env.contains_key("ANTHROPIC_API_KEY"),
2485 "sync should not introduce ANTHROPIC_API_KEY when the provider only used ANTHROPIC_AUTH_TOKEN"
2486 );
2487 }
2488
2489 #[tokio::test]
2490 #[serial]
2491 async fn enabling_codex_takeover_syncs_live_token_back_to_current_provider() {
2492 let temp_home = TempDir::new().expect("create temp home");
2493 let _env = TestHomeEnvGuard::set(temp_home.path());
2494 std::fs::create_dir_all(
2495 get_codex_auth_path()
2496 .parent()
2497 .expect("codex auth parent dir"),
2498 )
2499 .expect("create ~/.codex");
2500
2501 write_json_file(
2502 &get_codex_auth_path(),
2503 &json!({
2504 "OPENAI_API_KEY": "fresh-live-token"
2505 }),
2506 )
2507 .expect("seed codex auth.json");
2508 write_text_file(
2509 &get_codex_config_path(),
2510 r#"model_provider = "default"
2511
2512[model_providers.default]
2513base_url = "https://api.openai.com/v1"
2514"#,
2515 )
2516 .expect("seed codex config.toml");
2517
2518 let db = Arc::new(Database::memory().expect("create database"));
2519 let service = ProxyService::new(db.clone());
2520
2521 let provider = Provider::with_id(
2522 "codex-provider".to_string(),
2523 "Codex Provider".to_string(),
2524 json!({
2525 "auth": {
2526 "OPENAI_API_KEY": "stale-provider-token"
2527 },
2528 "config": r#"model_provider = "default"
2529
2530[model_providers.default]
2531base_url = "https://api.openai.com/v1"
2532"#
2533 }),
2534 None,
2535 );
2536 db.save_provider("codex", &provider)
2537 .expect("save codex provider");
2538 db.set_current_provider("codex", &provider.id)
2539 .expect("set current codex provider");
2540
2541 let mut runtime_config = service.get_config().await.expect("get proxy config");
2542 runtime_config.listen_port = 0;
2543 service
2544 .update_config(&runtime_config)
2545 .await
2546 .expect("persist runtime config");
2547
2548 service
2549 .set_takeover_for_app("codex", true)
2550 .await
2551 .expect("enable codex takeover");
2552 service.stop().await.expect("stop proxy runtime");
2553
2554 let updated = db
2555 .get_provider_by_id("codex-provider", "codex")
2556 .expect("read codex provider")
2557 .expect("codex provider exists");
2558 let auth = updated
2559 .settings_config
2560 .get("auth")
2561 .and_then(Value::as_object)
2562 .expect("provider auth should exist");
2563
2564 assert_eq!(
2565 auth.get("OPENAI_API_KEY").and_then(Value::as_str),
2566 Some("fresh-live-token"),
2567 "enabling takeover should sync the live Codex token back to the current provider before takeover rewrites it"
2568 );
2569 }
2570
2571 #[tokio::test]
2572 #[serial]
2573 async fn enabling_codex_takeover_syncs_live_token_to_effective_current_provider() {
2574 let temp_home = TempDir::new().expect("create temp home");
2575 let _env = TestHomeEnvGuard::set(temp_home.path());
2576 std::fs::create_dir_all(
2577 get_codex_auth_path()
2578 .parent()
2579 .expect("codex auth parent dir"),
2580 )
2581 .expect("create ~/.codex");
2582
2583 write_json_file(
2584 &get_codex_auth_path(),
2585 &json!({
2586 "OPENAI_API_KEY": "fresh-live-token"
2587 }),
2588 )
2589 .expect("seed codex auth.json");
2590 write_text_file(
2591 &get_codex_config_path(),
2592 r#"model_provider = "default"
2593
2594[model_providers.default]
2595base_url = "https://api.openai.com/v1"
2596"#,
2597 )
2598 .expect("seed codex config.toml");
2599
2600 let db = Arc::new(Database::memory().expect("create database"));
2601 let service = ProxyService::new(db.clone());
2602
2603 let db_current = Provider::with_id(
2604 "codex-db-current".to_string(),
2605 "Codex DB Current".to_string(),
2606 json!({
2607 "auth": {
2608 "OPENAI_API_KEY": "db-current-token"
2609 },
2610 "config": r#"model_provider = "default"
2611
2612[model_providers.default]
2613base_url = "https://api.openai.com/v1"
2614"#
2615 }),
2616 None,
2617 );
2618 let local_current = Provider::with_id(
2619 "codex-local-current".to_string(),
2620 "Codex Local Current".to_string(),
2621 json!({
2622 "auth": {
2623 "OPENAI_API_KEY": "local-current-token"
2624 },
2625 "config": r#"model_provider = "default"
2626
2627[model_providers.default]
2628base_url = "https://api.openai.com/v1"
2629"#
2630 }),
2631 None,
2632 );
2633 db.save_provider("codex", &db_current)
2634 .expect("save db-current codex provider");
2635 db.save_provider("codex", &local_current)
2636 .expect("save local-current codex provider");
2637 db.set_current_provider("codex", &db_current.id)
2638 .expect("set db current codex provider");
2639 crate::settings::set_current_provider(&AppType::Codex, Some(&local_current.id))
2640 .expect("set local current codex provider");
2641
2642 let mut runtime_config = service.get_config().await.expect("get proxy config");
2643 runtime_config.listen_port = 0;
2644 service
2645 .update_config(&runtime_config)
2646 .await
2647 .expect("persist runtime config");
2648
2649 service
2650 .set_takeover_for_app("codex", true)
2651 .await
2652 .expect("enable codex takeover");
2653 service.stop().await.expect("stop proxy runtime");
2654
2655 let updated_local = db
2656 .get_provider_by_id("codex-local-current", "codex")
2657 .expect("read local-current codex provider")
2658 .expect("local-current codex provider exists");
2659 let local_auth = updated_local
2660 .settings_config
2661 .get("auth")
2662 .and_then(Value::as_object)
2663 .expect("local-current provider auth should exist");
2664 assert_eq!(
2665 local_auth.get("OPENAI_API_KEY").and_then(Value::as_str),
2666 Some("fresh-live-token"),
2667 "takeover sync should follow the effective current provider from local settings"
2668 );
2669
2670 let unchanged_db = db
2671 .get_provider_by_id("codex-db-current", "codex")
2672 .expect("read db-current codex provider")
2673 .expect("db-current codex provider exists");
2674 let db_auth = unchanged_db
2675 .settings_config
2676 .get("auth")
2677 .and_then(Value::as_object)
2678 .expect("db-current provider auth should exist");
2679 assert_eq!(
2680 db_auth.get("OPENAI_API_KEY").and_then(Value::as_str),
2681 Some("db-current-token"),
2682 "DB current provider should remain unchanged when local settings override it"
2683 );
2684 }
2685
2686 #[tokio::test]
2687 #[serial]
2688 async fn enabling_gemini_takeover_syncs_live_token_to_effective_current_provider() {
2689 let temp_home = TempDir::new().expect("create temp home");
2690 let _env = TestHomeEnvGuard::set(temp_home.path());
2691
2692 let db = Arc::new(Database::memory().expect("create database"));
2693 let service = ProxyService::new(db.clone());
2694
2695 service
2696 .write_gemini_live(&json!({
2697 "env": {
2698 "GEMINI_API_KEY": "fresh-live-token"
2699 }
2700 }))
2701 .expect("seed gemini env");
2702
2703 let db_current = Provider::with_id(
2704 "gemini-db-current".to_string(),
2705 "Gemini DB Current".to_string(),
2706 json!({
2707 "env": {
2708 "GEMINI_API_KEY": "db-current-token"
2709 }
2710 }),
2711 None,
2712 );
2713 let local_current = Provider::with_id(
2714 "gemini-local-current".to_string(),
2715 "Gemini Local Current".to_string(),
2716 json!({
2717 "env": {
2718 "GEMINI_API_KEY": "local-current-token"
2719 }
2720 }),
2721 None,
2722 );
2723 db.save_provider("gemini", &db_current)
2724 .expect("save db-current gemini provider");
2725 db.save_provider("gemini", &local_current)
2726 .expect("save local-current gemini provider");
2727 db.set_current_provider("gemini", &db_current.id)
2728 .expect("set db current gemini provider");
2729 crate::settings::set_current_provider(&AppType::Gemini, Some(&local_current.id))
2730 .expect("set local current gemini provider");
2731
2732 let mut runtime_config = service.get_config().await.expect("get proxy config");
2733 runtime_config.listen_port = 0;
2734 service
2735 .update_config(&runtime_config)
2736 .await
2737 .expect("persist runtime config");
2738
2739 service
2740 .set_takeover_for_app("gemini", true)
2741 .await
2742 .expect("enable gemini takeover");
2743 service.stop().await.expect("stop proxy runtime");
2744
2745 let updated_local = db
2746 .get_provider_by_id("gemini-local-current", "gemini")
2747 .expect("read local-current gemini provider")
2748 .expect("local-current gemini provider exists");
2749 let local_env = updated_local
2750 .settings_config
2751 .get("env")
2752 .and_then(Value::as_object)
2753 .expect("local-current provider env should exist");
2754 assert_eq!(
2755 local_env.get("GEMINI_API_KEY").and_then(Value::as_str),
2756 Some("fresh-live-token"),
2757 "takeover sync should follow the effective current provider from local settings"
2758 );
2759
2760 let unchanged_db = db
2761 .get_provider_by_id("gemini-db-current", "gemini")
2762 .expect("read db-current gemini provider")
2763 .expect("db-current gemini provider exists");
2764 let db_env = unchanged_db
2765 .settings_config
2766 .get("env")
2767 .and_then(Value::as_object)
2768 .expect("db-current provider env should exist");
2769 assert_eq!(
2770 db_env.get("GEMINI_API_KEY").and_then(Value::as_str),
2771 Some("db-current-token"),
2772 "DB current provider should remain unchanged when local settings override it"
2773 );
2774 }
2775
2776 #[tokio::test]
2777 #[serial]
2778 async fn foreground_runtime_start_and_stop_syncs_global_proxy_switch() {
2779 let db = Arc::new(Database::memory().expect("create database"));
2780 let service = ProxyService::new(db);
2781
2782 assert!(
2783 !service
2784 .get_global_config()
2785 .await
2786 .expect("read initial global proxy config")
2787 .proxy_enabled,
2788 "precondition: global proxy switch should start disabled"
2789 );
2790
2791 let mut runtime_config = service.get_config().await.expect("get proxy config");
2792 runtime_config.listen_port = 0;
2793 service
2794 .start_with_runtime_config(runtime_config)
2795 .await
2796 .expect("start foreground proxy runtime");
2797
2798 assert!(
2799 service
2800 .get_global_config()
2801 .await
2802 .expect("read global proxy config after start")
2803 .proxy_enabled,
2804 "starting the foreground proxy runtime should enable the persisted global proxy switch"
2805 );
2806
2807 service.stop().await.expect("stop foreground proxy runtime");
2808
2809 assert!(
2810 !service
2811 .get_global_config()
2812 .await
2813 .expect("read global proxy config after stop")
2814 .proxy_enabled,
2815 "stopping the foreground proxy runtime should disable the persisted global proxy switch"
2816 );
2817 }
2818
2819 #[tokio::test]
2820 #[serial]
2821 async fn managed_external_runtime_does_not_publish_session_before_ready_signal() {
2822 let _env = ManagedRuntimeEnvGuard::set("test-managed-session-token");
2823 let db = Arc::new(Database::memory().expect("create database"));
2824 let service = ProxyService::new(db.clone());
2825
2826 let mut runtime_config = service.get_config().await.expect("get proxy config");
2827 runtime_config.listen_port = 0;
2828 service
2829 .start_with_runtime_config(runtime_config)
2830 .await
2831 .expect("start proxy runtime");
2832
2833 assert!(
2834 db.get_setting(PROXY_RUNTIME_SESSION_KEY)
2835 .expect("read runtime session")
2836 .is_none(),
2837 "managed external runtime should wait to publish its session until takeover is finished"
2838 );
2839
2840 service.stop().await.expect("stop proxy runtime");
2841 }
2842
2843 #[tokio::test]
2844 #[serial]
2845 async fn managed_external_runtime_publishes_session_when_ready_signal_is_sent() {
2846 let _env = ManagedRuntimeEnvGuard::set("test-managed-session-token");
2847 let db = Arc::new(Database::memory().expect("create database"));
2848 let service = ProxyService::new(db.clone());
2849
2850 let mut runtime_config = service.get_config().await.expect("get proxy config");
2851 runtime_config.listen_port = 0;
2852 let info = service
2853 .start_with_runtime_config(runtime_config)
2854 .await
2855 .expect("start proxy runtime");
2856
2857 service
2858 .publish_runtime_session_if_needed(&info)
2859 .expect("publish managed runtime session");
2860
2861 let session: PersistedProxyRuntimeSession = serde_json::from_str(
2862 &db.get_setting(PROXY_RUNTIME_SESSION_KEY)
2863 .expect("read runtime session")
2864 .expect("persisted runtime session"),
2865 )
2866 .expect("parse runtime session");
2867 assert!(session.kind.is_managed_external());
2868 assert_eq!(
2869 session.session_token.as_deref(),
2870 Some("test-managed-session-token")
2871 );
2872
2873 service.stop().await.expect("stop proxy runtime");
2874 }
2875
2876 #[tokio::test]
2877 #[serial]
2878 async fn takeover_mutation_waits_for_shared_restore_guard() {
2879 let temp_home = TempDir::new().expect("create temp home");
2880 let _env = TestHomeEnvGuard::set(temp_home.path());
2881 std::fs::create_dir_all(
2882 get_claude_settings_path()
2883 .parent()
2884 .expect("claude settings parent dir"),
2885 )
2886 .expect("create claude settings dir");
2887 write_json_file(
2888 &get_claude_settings_path(),
2889 &json!({
2890 "env": {
2891 "ANTHROPIC_API_KEY": "live-key"
2892 }
2893 }),
2894 )
2895 .expect("seed claude live config");
2896
2897 let db = Arc::new(Database::init().expect("create database"));
2898 let provider = Provider::with_id(
2899 "claude-provider".to_string(),
2900 "Claude Provider".to_string(),
2901 json!({
2902 "env": {
2903 "ANTHROPIC_API_KEY": "db-key"
2904 }
2905 }),
2906 Some("claude".to_string()),
2907 );
2908 db.save_provider("claude", &provider)
2909 .expect("save claude provider");
2910 db.set_current_provider("claude", &provider.id)
2911 .expect("set current claude provider");
2912
2913 let service = ProxyService::new(db.clone());
2914 let mut config = service.get_config().await.expect("read proxy config");
2915 config.listen_port = 0;
2916 service
2917 .update_config(&config)
2918 .await
2919 .expect("update proxy config");
2920
2921 let guard = crate::services::state_coordination::acquire_restore_mutation_guard()
2922 .await
2923 .expect("acquire shared restore guard");
2924 let completed = Arc::new(AtomicBool::new(false));
2925 let completed_bg = Arc::clone(&completed);
2926 let service_bg = service.clone();
2927 let handle = tokio::spawn(async move {
2928 service_bg
2929 .set_takeover_for_app("claude", true)
2930 .await
2931 .expect("enable takeover after guard release");
2932 completed_bg.store(true, Ordering::SeqCst);
2933 });
2934
2935 tokio::time::sleep(Duration::from_millis(150)).await;
2936 assert!(
2937 !completed.load(Ordering::SeqCst),
2938 "takeover mutation should wait behind the shared restore guard"
2939 );
2940 assert!(
2941 !handle.is_finished(),
2942 "spawned takeover task should still be blocked on the shared guard"
2943 );
2944
2945 drop(guard);
2946
2947 tokio::time::timeout(Duration::from_secs(5), handle)
2948 .await
2949 .expect("takeover task should complete after guard release")
2950 .expect("takeover task should succeed");
2951 assert!(
2952 completed.load(Ordering::SeqCst),
2953 "takeover mutation should complete once the shared guard is released"
2954 );
2955
2956 service
2957 .set_takeover_for_app("claude", false)
2958 .await
2959 .expect("disable takeover for cleanup");
2960 }
2961
2962 #[tokio::test]
2963 #[serial]
2964 async fn proxy_config_update_waits_for_shared_restore_guard() {
2965 let db = Arc::new(Database::memory().expect("create database"));
2966 let service = ProxyService::new(db.clone());
2967
2968 let initial = service.get_config().await.expect("read initial config");
2969 let mut updated = initial.clone();
2970 updated.listen_port = if initial.listen_port == 15721 {
2971 15722
2972 } else {
2973 initial.listen_port.saturating_add(1)
2974 };
2975 let expected_port = updated.listen_port;
2976
2977 let guard = crate::services::state_coordination::acquire_restore_mutation_guard()
2978 .await
2979 .expect("acquire shared restore guard");
2980 let completed = Arc::new(AtomicBool::new(false));
2981 let completed_bg = Arc::clone(&completed);
2982 let service_bg = service.clone();
2983 let handle = tokio::spawn(async move {
2984 service_bg
2985 .update_config(&updated)
2986 .await
2987 .expect("update proxy config after guard release");
2988 completed_bg.store(true, Ordering::SeqCst);
2989 });
2990
2991 tokio::time::sleep(Duration::from_millis(150)).await;
2992 assert!(
2993 !completed.load(Ordering::SeqCst),
2994 "proxy config update should wait behind the shared restore guard"
2995 );
2996 assert!(
2997 !handle.is_finished(),
2998 "spawned config-update task should still be blocked on the shared guard"
2999 );
3000
3001 drop(guard);
3002
3003 tokio::time::timeout(Duration::from_secs(5), handle)
3004 .await
3005 .expect("config update task should complete after guard release")
3006 .expect("config update task should succeed");
3007 assert!(
3008 completed.load(Ordering::SeqCst),
3009 "proxy config update should complete once the shared guard is released"
3010 );
3011
3012 assert_eq!(
3013 service
3014 .get_config()
3015 .await
3016 .expect("read config after guard release")
3017 .listen_port,
3018 expected_port
3019 );
3020 }
3021
3022 #[tokio::test]
3023 #[serial]
3024 async fn managed_session_ready_info_accepts_persisted_session_without_status_probe() {
3025 let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0))
3026 .await
3027 .expect("bind unused proxy status port");
3028 let port = listener
3029 .local_addr()
3030 .expect("read unused proxy status port")
3031 .port();
3032 drop(listener);
3033
3034 let db = Arc::new(Database::memory().expect("create database"));
3035 let service = ProxyService::new(db.clone());
3036
3037 db.set_setting(
3038 PROXY_RUNTIME_SESSION_KEY,
3039 &serde_json::to_string(&PersistedProxyRuntimeSession {
3040 pid: 4242,
3041 address: "127.0.0.1".to_string(),
3042 port,
3043 started_at: "2026-03-10T00:00:00Z".to_string(),
3044 kind: PersistedProxyRuntimeSessionKind::ManagedExternal,
3045 session_token: Some("expected-session-token".to_string()),
3046 })
3047 .expect("serialize runtime session"),
3048 )
3049 .expect("persist runtime session");
3050
3051 let info = service
3052 .managed_session_ready_info(4242, "expected-session-token")
3053 .await
3054 .expect("persisted managed runtime marker should be treated as ready");
3055
3056 assert_eq!(info.address, "127.0.0.1");
3057 assert_eq!(info.port, port);
3058 assert_eq!(info.started_at, "2026-03-10T00:00:00Z");
3059 }
3060
3061 #[tokio::test]
3062 async fn managed_session_ready_info_prefers_matching_status_snapshot_when_available() {
3063 let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0))
3064 .await
3065 .expect("bind fake proxy status listener");
3066 let port = listener
3067 .local_addr()
3068 .expect("read fake proxy listener addr")
3069 .port();
3070 let status = serde_json::json!({
3071 "running": true,
3072 "address": "127.0.0.1",
3073 "port": port,
3074 "active_connections": 0,
3075 "total_requests": 0,
3076 "success_requests": 0,
3077 "failed_requests": 0,
3078 "success_rate": 0.0,
3079 "uptime_seconds": 12,
3080 "current_provider": null,
3081 "current_provider_id": null,
3082 "last_request_at": null,
3083 "last_error": null,
3084 "failover_count": 0,
3085 "managed_session_token": "expected-session-token"
3086 });
3087
3088 let server = tokio::spawn(async move {
3089 let (mut socket, _) = listener.accept().await.expect("accept status request");
3090 let response = format!(
3091 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
3092 status.to_string().len(),
3093 status
3094 );
3095 use tokio::io::AsyncWriteExt;
3096 socket
3097 .write_all(response.as_bytes())
3098 .await
3099 .expect("write fake status response");
3100 });
3101
3102 let db = Arc::new(Database::memory().expect("create database"));
3103 let service = ProxyService::new(db.clone());
3104 db.set_setting(
3105 PROXY_RUNTIME_SESSION_KEY,
3106 &serde_json::to_string(&PersistedProxyRuntimeSession {
3107 pid: 4242,
3108 address: "127.0.0.1".to_string(),
3109 port,
3110 started_at: "2026-03-10T00:00:00Z".to_string(),
3111 kind: PersistedProxyRuntimeSessionKind::ManagedExternal,
3112 session_token: Some("expected-session-token".to_string()),
3113 })
3114 .expect("serialize runtime session"),
3115 )
3116 .expect("persist runtime session");
3117
3118 let info = service
3119 .managed_session_ready_info(4242, "expected-session-token")
3120 .await
3121 .expect("matching external status should make the session ready");
3122
3123 assert_eq!(info.address, "127.0.0.1");
3124 assert_eq!(info.port, port);
3125
3126 server.await.expect("fake status server should finish");
3127 }
3128
3129 #[tokio::test]
3130 async fn managed_session_ready_info_rejects_mismatched_status_snapshot() {
3131 let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0))
3132 .await
3133 .expect("bind fake proxy status listener");
3134 let port = listener
3135 .local_addr()
3136 .expect("read fake proxy listener addr")
3137 .port();
3138 let status = serde_json::json!({
3139 "running": true,
3140 "address": "127.0.0.1",
3141 "port": port,
3142 "active_connections": 0,
3143 "total_requests": 0,
3144 "success_requests": 0,
3145 "failed_requests": 0,
3146 "success_rate": 0.0,
3147 "uptime_seconds": 12,
3148 "current_provider": null,
3149 "current_provider_id": null,
3150 "last_request_at": null,
3151 "last_error": null,
3152 "failover_count": 0,
3153 "managed_session_token": "other-session-token"
3154 });
3155
3156 let server = tokio::spawn(async move {
3157 let (mut socket, _) = listener.accept().await.expect("accept status request");
3158 let response = format!(
3159 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
3160 status.to_string().len(),
3161 status
3162 );
3163 use tokio::io::AsyncWriteExt;
3164 socket
3165 .write_all(response.as_bytes())
3166 .await
3167 .expect("write fake status response");
3168 });
3169
3170 let db = Arc::new(Database::memory().expect("create database"));
3171 let service = ProxyService::new(db.clone());
3172 db.set_setting(
3173 PROXY_RUNTIME_SESSION_KEY,
3174 &serde_json::to_string(&PersistedProxyRuntimeSession {
3175 pid: 4242,
3176 address: "127.0.0.1".to_string(),
3177 port,
3178 started_at: "2026-03-10T00:00:00Z".to_string(),
3179 kind: PersistedProxyRuntimeSessionKind::ManagedExternal,
3180 session_token: Some("expected-session-token".to_string()),
3181 })
3182 .expect("serialize runtime session"),
3183 )
3184 .expect("persist runtime session");
3185
3186 assert!(
3187 service
3188 .managed_session_ready_info(4242, "expected-session-token")
3189 .await
3190 .is_none(),
3191 "startup should keep waiting when /status reports a different managed session token"
3192 );
3193
3194 server.await.expect("fake status server should finish");
3195 }
3196
3197 #[tokio::test]
3198 #[serial]
3199 async fn hot_updating_running_breaker_configs_refreshes_existing_breakers() {
3200 let db = Arc::new(Database::memory().expect("create database"));
3201 let service = ProxyService::new(db.clone());
3202
3203 let provider = Provider::with_id(
3204 "p1".to_string(),
3205 "Provider One".to_string(),
3206 json!({}),
3207 None,
3208 );
3209 db.save_provider("claude", &provider)
3210 .expect("save provider");
3211
3212 let mut app_proxy = db
3213 .get_proxy_config_for_app("claude")
3214 .await
3215 .expect("load app proxy config");
3216 app_proxy.circuit_failure_threshold = 1;
3217 app_proxy.circuit_timeout_seconds = 3600;
3218 db.update_proxy_config_for_app(app_proxy)
3219 .await
3220 .expect("persist initial breaker config");
3221
3222 let mut runtime_config = service.get_config().await.expect("get proxy config");
3223 runtime_config.listen_port = 0;
3224 service
3225 .start_with_runtime_config(runtime_config)
3226 .await
3227 .expect("start proxy");
3228
3229 let router = {
3230 let server_guard = service.runtime.server.read().await;
3231 server_guard
3232 .as_ref()
3233 .expect("running server")
3234 .provider_router()
3235 };
3236
3237 router
3238 .record_result("p1", "claude", false, false, Some("fail".to_string()))
3239 .await
3240 .expect("open breaker");
3241 assert!(!router.allow_provider_request("p1", "claude").await.allowed);
3242
3243 let updated = CircuitBreakerConfig {
3244 failure_threshold: 1,
3245 success_threshold: 2,
3246 timeout_seconds: 0,
3247 error_rate_threshold: 1.0,
3248 min_requests: u32::MAX,
3249 };
3250 db.update_circuit_breaker_config(&updated)
3251 .await
3252 .expect("persist updated breaker config");
3253 service
3254 .update_circuit_breaker_configs(updated)
3255 .await
3256 .expect("hot update running breaker config");
3257
3258 let permit = router.allow_provider_request("p1", "claude").await;
3259 assert!(permit.allowed);
3260 assert!(permit.used_half_open_permit);
3261
3262 service.stop().await.expect("stop proxy");
3263 }
3264
3265 #[tokio::test]
3266 #[serial]
3267 async fn resetting_running_provider_breaker_clears_existing_breaker_state() {
3268 let db = Arc::new(Database::memory().expect("create database"));
3269 let service = ProxyService::new(db.clone());
3270
3271 let provider = Provider::with_id(
3272 "p1".to_string(),
3273 "Provider One".to_string(),
3274 json!({}),
3275 None,
3276 );
3277 db.save_provider("claude", &provider)
3278 .expect("save provider");
3279
3280 let mut app_proxy = db
3281 .get_proxy_config_for_app("claude")
3282 .await
3283 .expect("load app proxy config");
3284 app_proxy.circuit_failure_threshold = 1;
3285 app_proxy.circuit_timeout_seconds = 3600;
3286 db.update_proxy_config_for_app(app_proxy)
3287 .await
3288 .expect("persist breaker config");
3289
3290 let mut runtime_config = service.get_config().await.expect("get proxy config");
3291 runtime_config.listen_port = 0;
3292 service
3293 .start_with_runtime_config(runtime_config)
3294 .await
3295 .expect("start proxy");
3296
3297 let router = {
3298 let server_guard = service.runtime.server.read().await;
3299 server_guard
3300 .as_ref()
3301 .expect("running server")
3302 .provider_router()
3303 };
3304
3305 router
3306 .record_result("p1", "claude", false, false, Some("fail".to_string()))
3307 .await
3308 .expect("open breaker");
3309 assert!(!router.allow_provider_request("p1", "claude").await.allowed);
3310
3311 service
3312 .reset_provider_circuit_breaker("p1", "claude")
3313 .await
3314 .expect("reset running breaker");
3315
3316 let permit = router.allow_provider_request("p1", "claude").await;
3317 assert!(permit.allowed);
3318 assert!(!permit.used_half_open_permit);
3319
3320 service.stop().await.expect("stop proxy");
3321 }
3322
3323 #[tokio::test]
3324 #[serial]
3325 async fn update_live_backup_from_provider_preserves_codex_mcp_servers() {
3326 let db = Arc::new(Database::memory().expect("init db"));
3327 let service = ProxyService::new(db.clone());
3328
3329 db.save_live_backup(
3330 "codex",
3331 &serde_json::to_string(&json!({
3332 "auth": {
3333 "OPENAI_API_KEY": "old-token"
3334 },
3335 "config": r#"model_provider = "any"
3336model = "gpt-4"
3337
3338[model_providers.any]
3339base_url = "https://old.example/v1"
3340
3341[mcp_servers.echo]
3342command = "npx"
3343args = ["echo-server"]
3344"#
3345 }))
3346 .expect("serialize seed backup"),
3347 )
3348 .await
3349 .expect("seed live backup");
3350
3351 let provider = Provider::with_id(
3352 "p2".to_string(),
3353 "P2".to_string(),
3354 json!({
3355 "auth": {
3356 "OPENAI_API_KEY": "new-token"
3357 },
3358 "config": r#"model_provider = "any"
3359model = "gpt-5"
3360
3361[model_providers.any]
3362base_url = "https://new.example/v1"
3363"#
3364 }),
3365 None,
3366 );
3367
3368 service
3369 .update_live_backup_from_provider("codex", &provider)
3370 .await
3371 .expect("update live backup");
3372
3373 let backup = db
3374 .get_live_backup("codex")
3375 .await
3376 .expect("get live backup")
3377 .expect("backup exists");
3378 let stored: Value =
3379 serde_json::from_str(&backup.original_config).expect("parse backup json");
3380 let config = stored
3381 .get("config")
3382 .and_then(|v| v.as_str())
3383 .expect("config string");
3384
3385 assert!(
3386 config.contains("[mcp_servers.echo]"),
3387 "existing Codex MCP section should survive proxy hot-switch backup update"
3388 );
3389 assert!(
3390 config.contains("https://new.example/v1"),
3391 "provider-specific base_url should still update to the new provider"
3392 );
3393 }
3394
3395 #[tokio::test]
3396 #[serial]
3397 async fn hot_switch_codex_provider_keeps_model_provider_stable_in_backup_and_restore() {
3398 let temp_home = TempDir::new().expect("create temp home");
3399 let _env = TestHomeEnvGuard::set(temp_home.path());
3400
3401 let db = Arc::new(Database::memory().expect("init db"));
3402 let service = ProxyService::new(db.clone());
3403
3404 let provider_a = Provider::with_id(
3405 "a".to_string(),
3406 "RightCode".to_string(),
3407 json!({
3408 "auth": {
3409 "OPENAI_API_KEY": "rightcode-key"
3410 },
3411 "config": r#"model_provider = "rightcode"
3412model = "gpt-5.4"
3413
3414[model_providers.rightcode]
3415name = "RightCode"
3416base_url = "https://rightcode.example/v1"
3417wire_api = "responses"
3418requires_openai_auth = true
3419"#
3420 }),
3421 None,
3422 );
3423 let provider_b = Provider::with_id(
3424 "b".to_string(),
3425 "AiHubMix".to_string(),
3426 json!({
3427 "auth": {
3428 "OPENAI_API_KEY": "aihubmix-key"
3429 },
3430 "config": r#"model_provider = "aihubmix"
3431model = "gpt-5.4"
3432
3433[model_providers.aihubmix]
3434name = "AiHubMix"
3435base_url = "https://aihubmix.example/v1"
3436wire_api = "responses"
3437requires_openai_auth = true
3438"#
3439 }),
3440 None,
3441 );
3442
3443 db.save_provider("codex", &provider_a)
3444 .expect("save provider a");
3445 db.save_provider("codex", &provider_b)
3446 .expect("save provider b");
3447 db.set_current_provider("codex", "a")
3448 .expect("set current provider");
3449 crate::settings::set_current_provider(&AppType::Codex, Some("a"))
3450 .expect("set local current provider");
3451 db.save_live_backup(
3452 "codex",
3453 &serde_json::to_string(&provider_a.settings_config).expect("serialize provider a"),
3454 )
3455 .await
3456 .expect("seed live backup");
3457 service
3458 .write_codex_live(&json!({
3459 "auth": {
3460 "OPENAI_API_KEY": PROXY_TOKEN_PLACEHOLDER
3461 },
3462 "config": r#"model_provider = "rightcode"
3463model = "gpt-5.4"
3464
3465[model_providers.rightcode]
3466name = "RightCode"
3467base_url = "http://127.0.0.1:15721/v1"
3468wire_api = "responses"
3469requires_openai_auth = true
3470"#
3471 }))
3472 .expect("seed taken-over Codex live config");
3473
3474 service
3475 .hot_switch_provider("codex", "b")
3476 .await
3477 .expect("hot switch Codex provider");
3478
3479 let backup = db
3480 .get_live_backup("codex")
3481 .await
3482 .expect("get live backup")
3483 .expect("backup exists");
3484 let stored: Value =
3485 serde_json::from_str(&backup.original_config).expect("parse backup json");
3486 let backup_config = stored
3487 .get("config")
3488 .and_then(Value::as_str)
3489 .expect("backup config string");
3490 let parsed_backup: toml::Value =
3491 toml::from_str(backup_config).expect("parse backup config");
3492 assert_eq!(
3493 parsed_backup.get("model_provider").and_then(|v| v.as_str()),
3494 Some("rightcode"),
3495 "provider-derived restore backup should retain stable Codex model_provider"
3496 );
3497 let backup_model_providers = parsed_backup
3498 .get("model_providers")
3499 .and_then(|v| v.as_table())
3500 .expect("backup model_providers");
3501 assert!(backup_model_providers.get("aihubmix").is_none());
3502 assert_eq!(
3503 backup_model_providers
3504 .get("rightcode")
3505 .and_then(|v| v.get("base_url"))
3506 .and_then(|v| v.as_str()),
3507 Some("https://aihubmix.example/v1"),
3508 "stable provider id should point at the hot-switched provider endpoint"
3509 );
3510
3511 service
3512 .restore_live_config_for_app(&AppType::Codex)
3513 .await
3514 .expect("restore Codex live config");
3515
3516 let live = service.read_codex_live().expect("read Codex live config");
3517 let live_config = live
3518 .get("config")
3519 .and_then(Value::as_str)
3520 .expect("live config string");
3521 let parsed_live: toml::Value = toml::from_str(live_config).expect("parse live config");
3522 assert_eq!(
3523 parsed_live.get("model_provider").and_then(|v| v.as_str()),
3524 Some("rightcode"),
3525 "restored Codex live config should not switch history buckets"
3526 );
3527 assert_eq!(
3528 live.get("auth")
3529 .and_then(|auth| auth.get("OPENAI_API_KEY"))
3530 .and_then(Value::as_str),
3531 Some("aihubmix-key"),
3532 "restore should still use the hot-switched provider auth"
3533 );
3534 }
3535
3536 #[tokio::test]
3537 #[serial]
3538 async fn update_live_backup_from_provider_for_gemini_keeps_only_env_snapshot() {
3539 let temp_home = TempDir::new().expect("create temp home");
3540 let _env = TestHomeEnvGuard::set(temp_home.path());
3541
3542 let settings_path = crate::gemini_config::get_gemini_settings_path();
3543 std::fs::create_dir_all(
3544 settings_path
3545 .parent()
3546 .expect("gemini settings parent directory"),
3547 )
3548 .expect("create gemini settings directory");
3549 write_json_file(
3550 &settings_path,
3551 &json!({
3552 "mcpServers": {
3553 "echo": {
3554 "command": "npx",
3555 "args": ["echo-server"]
3556 }
3557 }
3558 }),
3559 )
3560 .expect("seed gemini settings.json");
3561
3562 let db = Arc::new(Database::memory().expect("init db"));
3563 let service = ProxyService::new(db.clone());
3564 db.save_live_backup("gemini", r#"{"env":{"GEMINI_API_KEY":"stale-token"}}"#)
3565 .await
3566 .expect("seed active gemini backup");
3567 let provider = Provider::with_id(
3568 "gemini-provider".to_string(),
3569 "Gemini Provider".to_string(),
3570 json!({
3571 "env": {
3572 "GEMINI_API_KEY": "gemini-token"
3573 },
3574 "config": {
3575 "theme": "solarized"
3576 }
3577 }),
3578 None,
3579 );
3580
3581 service
3582 .update_live_backup_from_provider("gemini", &provider)
3583 .await
3584 .expect("update gemini live backup");
3585
3586 let backup = db
3587 .get_live_backup("gemini")
3588 .await
3589 .expect("get gemini live backup")
3590 .expect("gemini backup exists");
3591 let stored: Value =
3592 serde_json::from_str(&backup.original_config).expect("parse gemini backup json");
3593
3594 assert_eq!(
3595 stored.get("env"),
3596 Some(&json!({
3597 "GEMINI_API_KEY": "gemini-token"
3598 })),
3599 "Gemini live backup should keep the provider env for takeover restore"
3600 );
3601 assert!(
3602 stored.get("config").is_none(),
3603 "Gemini live backup should not snapshot settings.json/config; upstream keeps only env"
3604 );
3605 }
3606
3607 #[tokio::test]
3608 #[serial]
3609 async fn update_live_backup_from_provider_applies_claude_common_config_without_takeover() {
3610 let temp_home = TempDir::new().expect("create temp home");
3611 let _env = TestHomeEnvGuard::set(temp_home.path());
3612
3613 let db = Arc::new(Database::memory().expect("init db"));
3614 db.set_config_snippet(
3615 "claude",
3616 Some(
3617 serde_json::json!({
3618 "includeCoAuthoredBy": false
3619 })
3620 .to_string(),
3621 ),
3622 )
3623 .expect("set common config snippet");
3624
3625 let service = ProxyService::new(db.clone());
3626
3627 let mut provider = Provider::with_id(
3628 "claude-provider".to_string(),
3629 "Claude Provider".to_string(),
3630 json!({
3631 "env": {
3632 "ANTHROPIC_AUTH_TOKEN": "token",
3633 "ANTHROPIC_BASE_URL": "https://claude.example"
3634 }
3635 }),
3636 None,
3637 );
3638 provider.meta = Some(ProviderMeta {
3639 apply_common_config: Some(true),
3640 ..Default::default()
3641 });
3642
3643 service
3644 .update_live_backup_from_provider("claude", &provider)
3645 .await
3646 .expect("update claude live backup");
3647
3648 let backup = db
3649 .get_live_backup("claude")
3650 .await
3651 .expect("get claude live backup")
3652 .expect("claude backup exists");
3653 let stored: Value =
3654 serde_json::from_str(&backup.original_config).expect("parse claude backup json");
3655
3656 assert_eq!(
3657 stored.get("includeCoAuthoredBy").and_then(Value::as_bool),
3658 Some(false),
3659 "common config should be applied into Claude restore backup even before takeover is active"
3660 );
3661 }
3662
3663 #[tokio::test]
3664 #[serial]
3665 async fn switch_proxy_target_updates_live_backup_when_taken_over() {
3666 let temp_home = TempDir::new().expect("create temp home");
3667 let _env = TestHomeEnvGuard::set(temp_home.path());
3668
3669 let db = Arc::new(Database::memory().expect("init db"));
3670 let service = ProxyService::new(db.clone());
3671
3672 let provider_a = Provider::with_id(
3673 "a".to_string(),
3674 "A".to_string(),
3675 json!({
3676 "env": {
3677 "ANTHROPIC_API_KEY": "a-key"
3678 }
3679 }),
3680 None,
3681 );
3682 let provider_b = Provider::with_id(
3683 "b".to_string(),
3684 "B".to_string(),
3685 json!({
3686 "env": {
3687 "ANTHROPIC_API_KEY": "b-key"
3688 }
3689 }),
3690 None,
3691 );
3692 db.save_provider("claude", &provider_a)
3693 .expect("save provider a");
3694 db.save_provider("claude", &provider_b)
3695 .expect("save provider b");
3696 db.set_current_provider("claude", "a")
3697 .expect("set current provider");
3698
3699 db.save_live_backup("claude", "{\"env\":{}}")
3700 .await
3701 .expect("seed live backup");
3702
3703 service
3704 .switch_proxy_target("claude", "b")
3705 .await
3706 .expect("switch proxy target");
3707
3708 assert_eq!(
3709 crate::settings::get_current_provider(&AppType::Claude).as_deref(),
3710 Some("b")
3711 );
3712
3713 let backup = db
3714 .get_live_backup("claude")
3715 .await
3716 .expect("get live backup")
3717 .expect("backup exists");
3718 let expected = serde_json::to_string(&provider_b.settings_config).expect("serialize");
3719 assert_eq!(backup.original_config, expected);
3720 }
3721
3722 #[tokio::test]
3723 #[serial]
3724 async fn restore_live_from_current_provider_applies_codex_common_config() {
3725 let temp_home = TempDir::new().expect("create temp home");
3726 let _env = TestHomeEnvGuard::set(temp_home.path());
3727
3728 let db = Arc::new(Database::memory().expect("init db"));
3729 db.set_config_snippet(
3730 "codex",
3731 Some("disable_response_storage = true\n".to_string()),
3732 )
3733 .expect("set codex common config snippet");
3734
3735 let service = ProxyService::new(db.clone());
3736 let mut provider = Provider::with_id(
3737 "codex-provider".to_string(),
3738 "Codex Provider".to_string(),
3739 json!({
3740 "auth": {
3741 "OPENAI_API_KEY": "codex-token"
3742 },
3743 "config": r#"model_provider = "default"
3744model = "gpt-5.2-codex"
3745
3746[model_providers.default]
3747base_url = "https://api.example/v1"
3748"#
3749 }),
3750 None,
3751 );
3752 provider.meta = Some(ProviderMeta {
3753 apply_common_config: Some(true),
3754 ..Default::default()
3755 });
3756 db.save_provider("codex", &provider)
3757 .expect("save codex provider");
3758 db.set_current_provider("codex", &provider.id)
3759 .expect("set current codex provider");
3760
3761 service
3762 .restore_live_from_current_provider(&AppType::Codex)
3763 .await
3764 .expect("restore codex live from current provider");
3765
3766 let auth: Value = read_json_file(&get_codex_auth_path()).expect("read codex auth.json");
3767 assert_eq!(
3768 auth.get("OPENAI_API_KEY").and_then(Value::as_str),
3769 Some("codex-token"),
3770 "Codex fallback restore should write auth.json from the current provider"
3771 );
3772
3773 let config_text =
3774 std::fs::read_to_string(get_codex_config_path()).expect("read codex config.toml");
3775 assert!(
3776 config_text.contains("disable_response_storage = true"),
3777 "Codex fallback restore should apply the common config snippet like upstream"
3778 );
3779 }
3780}