diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index c474827c..abcbc54f 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -493,6 +493,15 @@ impl ProxyService { } async fn should_drop_takeover_via_daemon(&self, app_type: &AppType) -> Result { + // The daemon owns its workers even when the persisted runtime marker + // is missing or stale. Let DropTakeover resolve authoritative state; + // its transport handling falls back locally only for an absent daemon + // and propagates other errors instead of reporting a false success. + #[cfg(unix)] + if crate::daemon::paths::socket_path().exists() { + return Ok(true); + } + let Some(session) = self.load_persisted_runtime_session_for_app(app_type) else { self.remove_stale_daemon_socket_if_unreachable(); return Ok(false); diff --git a/src-tauri/tests/proxy_daemon.rs b/src-tauri/tests/proxy_daemon.rs index 570a3550..0f682739 100644 --- a/src-tauri/tests/proxy_daemon.rs +++ b/src-tauri/tests/proxy_daemon.rs @@ -46,6 +46,7 @@ const ENV_KEYS: &[&str] = &[ "XDG_RUNTIME_DIR", "XDG_STATE_HOME", "CLAUDE_CONFIG_DIR", + "CODEX_HOME", ]; struct TestSandbox { @@ -82,6 +83,7 @@ impl TestSandbox { std::env::set_var("USERPROFILE", &home); std::env::set_var("CC_SWITCH_CONFIG_DIR", &config_dir); std::env::set_var("CLAUDE_CONFIG_DIR", &claude_config_dir); + std::env::set_var("CODEX_HOME", home.join(".codex")); std::env::set_var("XDG_RUNTIME_DIR", &runtime_dir); std::env::set_var("XDG_STATE_HOME", &state_dir); } @@ -351,6 +353,118 @@ fn proxy_enable_and_disable_cli_manage_daemon_worker() { ); } +#[test] +#[serial] +fn disable_with_missing_or_stale_metadata_stops_only_the_target_worker() { + for stale in [false, true] { + let sandbox = TestSandbox::new(); + seed_minimal_claude_provider(&sandbox); + let state = cc_switch_lib::AppState::try_new().expect("sandbox state"); + let codex_dir = sandbox._root.path().join(".codex"); + std::fs::create_dir_all(&codex_dir).unwrap(); + let config = "model = \"test-model\"\nmodel_provider = \"test\"\n[model_providers.test]\nname = \"Test\"\nbase_url = \"https://example.com/v1\"\nwire_api = \"responses\"\n"; + std::fs::write( + codex_dir.join("auth.json"), + r#"{"OPENAI_API_KEY":"test-key"}"#, + ) + .unwrap(); + std::fs::write(codex_dir.join("config.toml"), config).unwrap(); + let provider = cc_switch_lib::Provider::with_id( + "codex-provider".into(), + "Codex Provider".into(), + serde_json::json!({"auth":{"OPENAI_API_KEY":"test-key"},"config":config}), + None, + ); + state.db.save_provider("codex", &provider).unwrap(); + state + .db + .set_current_provider("codex", &provider.id) + .unwrap(); + state + .db + .set_app_proxy_preferred_port("codex", free_loopback_port()) + .unwrap(); + let mut daemon = sandbox.spawn_daemon(); + assert!(sandbox.wait_for_socket(Duration::from_secs(10))); + for app in ["claude", "codex"] { + let response = send_request( + sandbox.socket(), + &format!(r#"{{"kind":"ensure_worker","app_type":"{app}"}}"#), + ); + assert!(response.contains("\"kind\":\"worker\""), "{response}"); + } + let before: serde_json::Value = + serde_json::from_str(&send_request(sandbox.socket(), r#"{"kind":"status"}"#)).unwrap(); + let workers = before["workers"].as_array().unwrap(); + let claude_pid = workers.iter().find(|w| w["app_type"] == "claude").unwrap()["pid"].clone(); + let codex_port = workers.iter().find(|w| w["app_type"] == "codex").unwrap()["port"] + .as_u64() + .unwrap() as u16; + let runtime = tokio::runtime::Runtime::new().unwrap(); + for app in ["claude", "codex", "gemini"] { + let mut cfg = runtime + .block_on(state.db.get_proxy_config_for_app(app)) + .unwrap(); + cfg.enabled = false; + cfg.auto_failover_enabled = false; + runtime + .block_on(state.db.update_proxy_config_for_app(cfg)) + .unwrap(); + } + if stale { + let raw = state + .db + .get_setting("proxy_runtime_session") + .unwrap() + .unwrap(); + let mut marker: serde_json::Value = serde_json::from_str(&raw).unwrap(); + marker["workers"]["codex"]["session_token"] = serde_json::json!("stale-token"); + state + .db + .set_setting("proxy_runtime_session", &marker.to_string()) + .unwrap(); + } else { + state.db.delete_setting("proxy_runtime_session").unwrap(); + } + runtime + .block_on( + state + .proxy_service + .set_managed_session_for_app("codex", false), + ) + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(5); + loop { + let after: serde_json::Value = + serde_json::from_str(&send_request(sandbox.socket(), r#"{"kind":"status"}"#)) + .unwrap(); + let workers = after["workers"].as_array().unwrap(); + assert!( + workers.iter().any(|w| w["app_type"] == "claude" + && w["pid"] == claude_pid + && w["running"] == true), + "other worker changed: {after}" + ); + if !workers + .iter() + .any(|w| w["app_type"] == "codex" && w["running"] == true) + && std::net::TcpStream::connect(("127.0.0.1", codex_port)).is_err() + { + break; + } + assert!(Instant::now() < deadline, "Codex still running: {after}"); + std::thread::sleep(Duration::from_millis(50)); + } + // Exercise the public CLI path with missing metadata for the last worker. + state.db.delete_setting("proxy_runtime_session").unwrap(); + assert_command_success( + &run_cc_switch(&["proxy", "disable"]), + "disable last worker without metadata", + ); + assert!(wait_for_daemon_exit(&mut daemon, Duration::from_secs(5))); + } +} + #[test] #[serial] fn normal_startup_keeps_existing_daemon_route_visible() {