diff --git a/src-tauri/src/agents/backups/tests.rs b/src-tauri/src/agents/backups/tests.rs index 4ed651e..0723fd5 100644 --- a/src-tauri/src/agents/backups/tests.rs +++ b/src-tauri/src/agents/backups/tests.rs @@ -489,6 +489,87 @@ fn clear_integration_removes_claude_model_overrides_but_keeps_custom_settings() assert_eq!(fs::read_to_string(&paths[0]).unwrap(), external); } +#[test] +fn claude_code_subagent_model_survives_update_close_and_reconnect() { + let home = Home::new(); + let paths = config_paths("claude-code", &home.0).unwrap(); + apply(&home.0, AgentClient::ClaudeCode, "gpt-one").unwrap(); + + let mut connected = parse( + &paths[0], + Some(&fs::read_to_string(&paths[0]).unwrap()), + ) + .unwrap(); + connected["env"]["CLAUDE_CODE_SUBAGENT_MODEL"] = + serde_json::json!("user-subagent-model"); + save(&paths[0], render(&paths[0], &connected).unwrap()); + + apply(&home.0, AgentClient::ClaudeCode, "gpt-two").unwrap(); + let updated = parse( + &paths[0], + Some(&fs::read_to_string(&paths[0]).unwrap()), + ) + .unwrap(); + assert_eq!( + updated["env"]["CLAUDE_CODE_SUBAGENT_MODEL"], + "user-subagent-model" + ); + + clear_agent_managed_configuration(AgentClient::ClaudeCode, &home.0, 8317).unwrap(); + let closed = parse( + &paths[0], + Some(&fs::read_to_string(&paths[0]).unwrap()), + ) + .unwrap(); + assert_eq!( + closed["env"]["CLAUDE_CODE_SUBAGENT_MODEL"], + "user-subagent-model" + ); + assert!(closed.get("model").is_none()); + assert!(closed["env"].get("ANTHROPIC_BASE_URL").is_none()); + + apply(&home.0, AgentClient::ClaudeCode, "gpt-one").unwrap(); + let reconnected = parse( + &paths[0], + Some(&fs::read_to_string(&paths[0]).unwrap()), + ) + .unwrap(); + assert_eq!( + reconnected["env"]["CLAUDE_CODE_SUBAGENT_MODEL"], + "user-subagent-model" + ); +} + +#[test] +fn claude_desktop_cowork_hosts_survive_update_close_and_reconnect() { + if !AgentClient::ClaudeDesktop.supported_platform() { + return; + } + let home = Home::new(); + let paths = config_paths("claude-desktop", &home.0).unwrap(); + apply(&home.0, AgentClient::ClaudeDesktop, "gpt-one").unwrap(); + + let mut connected: Value = serde_json::from_slice(&fs::read(&paths[2]).unwrap()).unwrap(); + connected["coworkEgressAllowedHosts"] = serde_json::json!(["*"]); + save(&paths[2], connected.to_string()); + + apply(&home.0, AgentClient::ClaudeDesktop, "gpt-two").unwrap(); + let updated: Value = serde_json::from_slice(&fs::read(&paths[2]).unwrap()).unwrap(); + assert_eq!(updated["coworkEgressAllowedHosts"], serde_json::json!(["*"])); + + clear_agent_managed_configuration(AgentClient::ClaudeDesktop, &home.0, 8317).unwrap(); + let closed: Value = serde_json::from_slice(&fs::read(&paths[2]).unwrap()).unwrap(); + assert_eq!(closed["coworkEgressAllowedHosts"], serde_json::json!(["*"])); + assert!(closed.get("inferenceGatewayBaseUrl").is_none()); + + apply(&home.0, AgentClient::ClaudeDesktop, "gpt-one").unwrap(); + let reconnected: Value = serde_json::from_slice(&fs::read(&paths[2]).unwrap()).unwrap(); + assert_eq!( + reconnected["coworkEgressAllowedHosts"], + serde_json::json!(["*"]) + ); +} + #[test] fn clear_integration_preserves_claude_settings_for_other_endpoints() { let home = Home::new(); diff --git a/src-tauri/src/agents/configuration.rs b/src-tauri/src/agents/configuration.rs index 3b37f7a..2d9b769 100644 --- a/src-tauri/src/agents/configuration.rs +++ b/src-tauri/src/agents/configuration.rs @@ -598,13 +598,14 @@ pub(crate) fn build_claude_agent_config( "ANTHROPIC_DEFAULT_FABLE_MODEL", model_settings.sonnet.as_str(), ), - ("CLAUDE_CODE_SUBAGENT_MODEL", subagent_model.as_str()), ] { env.insert( key.to_string(), serde_json::Value::String(value.to_string()), ); } + env.entry("CLAUDE_CODE_SUBAGENT_MODEL".to_string()) + .or_insert_with(|| serde_json::Value::String(subagent_model)); env.insert( CLAUDE_CODE_MAX_CONTEXT_TOKENS_ENV.to_string(), serde_json::Value::String(max_context_tokens.to_string()), @@ -755,7 +756,6 @@ pub(crate) fn build_claude_desktop_profile( mappings: Option<&ClaudeDesktopModelMappings>, ) -> Result { let mut root = parse_agent_json_object(existing, "Claude Desktop 网关配置")?; - root.remove("coworkEgressAllowedHosts"); root.insert( "disableDeploymentModeChooser".to_string(), serde_json::json!(true), @@ -957,14 +957,19 @@ pub(crate) fn clear_agent_managed_configuration( let paths = config_paths(client.id(), home)?; let before = config_images(&paths)?; validate_client_config_images(client.id(), &before)?; - let mut after = before.clone(); - for (path, bytes) in prepare_agent_managed_removal(client, &paths, port)? { - let entry = after - .iter_mut() - .find(|(candidate, _)| candidate == &path) - .ok_or("配置清理路径不匹配")?; - entry.1 = bytes; - } + let after = if let Some(after) = prepare_recorded_integration_restore(client, &paths, &before)? { + after + } else { + let mut after = before.clone(); + for (path, bytes) in prepare_agent_managed_removal(client, &paths, port)? { + let entry = after + .iter_mut() + .find(|(candidate, _)| candidate == &path) + .ok_or("配置清理路径不匹配")?; + entry.1 = bytes; + } + after + }; let mut result = commit_config( client.id(), &paths, @@ -1049,7 +1054,6 @@ pub(crate) fn prepare_claude_desktop_managed_removal(paths: &[PathBuf]) -> Resul prepare_agent_json_removal(&paths[2], "Claude Desktop 网关配置", |root| { let mut updated = false; for key in [ - "coworkEgressAllowedHosts", "disableDeploymentModeChooser", "inferenceGatewayApiKey", "inferenceGatewayAuthScheme", @@ -1206,19 +1210,6 @@ pub(crate) fn prepare_codex_managed_removal(paths: &[PathBuf]) -> Result(&rendered) .map_err(|error| format!("验证恢复后的 Codex 配置失败: {error}"))?; @@ -1798,6 +1789,19 @@ pub(crate) fn build_restored_claude_code_config( CLAUDE_CODE_MAX_CONTEXT_TOKENS_ENV, CLAUDE_AUTOCOMPACT_PCT_OVERRIDE_ENV, DISABLE_AUTO_COMPACT_ENV, + "CLAUDE_CODE_SUBAGENT_MODEL", + "CLAUDE_CODE_EFFORT_LEVEL", + "ANTHROPIC_DEFAULT_OPUS_MODEL_NAME", + "ANTHROPIC_DEFAULT_OPUS_MODEL_DESCRIPTION", + "ANTHROPIC_DEFAULT_SONNET_MODEL_NAME", + "ANTHROPIC_DEFAULT_SONNET_MODEL_DESCRIPTION", + "ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME", + "ANTHROPIC_DEFAULT_HAIKU_MODEL_DESCRIPTION", + "ANTHROPIC_DEFAULT_FABLE_MODEL_NAME", + "ANTHROPIC_DEFAULT_FABLE_MODEL_DESCRIPTION", + "ANTHROPIC_CUSTOM_MODEL_OPTION", + "ANTHROPIC_CUSTOM_MODEL_OPTION_NAME", + "ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION", ] { restore_json_key(env, original_env, key); } @@ -1821,7 +1825,6 @@ pub(crate) fn build_restored_claude_desktop_config( 0 | 1 => restore_json_key(&mut root, original_root.as_ref(), "deploymentMode"), 2 => { for key in [ - "coworkEgressAllowedHosts", "disableDeploymentModeChooser", "inferenceGatewayApiKey", "inferenceGatewayAuthScheme", @@ -2601,6 +2604,19 @@ pub(crate) fn build_agent_session_restored_bytes( path: &Path, current: Option<&[u8]>, original: Option<&[u8]>, +) -> Result>, String> { + build_agent_session_restored_bytes_with_preference( + client, paths, path, current, original, true, + ) +} + +pub(crate) fn build_agent_session_restored_bytes_with_preference( + client: AgentClient, + paths: &[PathBuf], + path: &Path, + current: Option<&[u8]>, + original: Option<&[u8]>, + prefer_exact_original: bool, ) -> Result>, String> { let Some(current) = current else { return Ok(original.map(ToOwned::to_owned)); @@ -2645,11 +2661,13 @@ pub(crate) fn build_agent_session_restored_bytes( AgentClient::KimiCode => build_restored_kimi_code_config(current, original)?, AgentClient::GrokBuild => build_restored_grok_build_config(current, original)?, }; - if let (Some(restored), Some(original), Some(original_bytes)) = - (restored.as_deref(), original, original_bytes) - { - if agent_config_semantically_equal(client, restored, original) { - return Ok(Some(original_bytes.to_vec())); + if prefer_exact_original { + if let (Some(restored), Some(original), Some(original_bytes)) = + (restored.as_deref(), original, original_bytes) + { + if agent_config_semantically_equal(client, restored, original) { + return Ok(Some(original_bytes.to_vec())); + } } } Ok(restored.map(String::into_bytes)) @@ -2872,6 +2890,14 @@ pub(crate) fn merge_missing_codex_table_items( pub(crate) fn build_restored_codex_agent_config( current: Option<&str>, original: Option<&str>, +) -> Result, String> { + build_restored_codex_agent_config_with_policy(current, original, true) +} + +pub(crate) fn build_restored_codex_agent_config_with_policy( + current: Option<&str>, + original: Option<&str>, + retain_dormant_provider: bool, ) -> Result, String> { use toml_edit::Item; @@ -2923,20 +2949,22 @@ pub(crate) fn build_restored_codex_agent_config( provider.remove(key); } } - } else if let Some(providers) = current_document - .as_table_mut() - .get_mut("model_providers") - .and_then(Item::as_table_mut) - { - if let Some(provider) = providers - .get_mut(MANAGED_AGENT_PROVIDER_ID) + } else if !retain_dormant_provider { + if let Some(providers) = current_document + .as_table_mut() + .get_mut("model_providers") .and_then(Item::as_table_mut) { - for key in CODEX_MANAGED_PROVIDER_KEYS { - provider.remove(key); - } - if provider.is_empty() { - providers.remove(MANAGED_AGENT_PROVIDER_ID); + if let Some(provider) = providers + .get_mut(MANAGED_AGENT_PROVIDER_ID) + .and_then(Item::as_table_mut) + { + for key in CODEX_MANAGED_PROVIDER_KEYS { + provider.remove(key); + } + if provider.is_empty() { + providers.remove(MANAGED_AGENT_PROVIDER_ID); + } } } } diff --git a/src-tauri/src/agents/discovery.rs b/src-tauri/src/agents/discovery.rs index a4fbf38..b658703 100644 --- a/src-tauri/src/agents/discovery.rs +++ b/src-tauri/src/agents/discovery.rs @@ -1306,14 +1306,15 @@ pub(crate) fn agent_has_connection_evidence( Some("yaml" | "yml") => serde_yaml::from_str(content).map_err(|e| e.to_string())?, _ => json5::from_str(content).map_err(|e| e.to_string())?, }; - let provider_present = [ - "/provider/cpa-gui", - "/model_providers/cpa-gui", - "/providers/cpa-gui", - "/models/providers/cpa-gui", - ] - .iter() - .any(|pointer| value.pointer(pointer).is_some()); + let provider_present = client != AgentClient::Codex + && [ + "/provider/cpa-gui", + "/model_providers/cpa-gui", + "/providers/cpa-gui", + "/models/providers/cpa-gui", + ] + .iter() + .any(|pointer| value.pointer(pointer).is_some()); let selected = [ "/model_provider", "/model", diff --git a/src-tauri/src/agents/native_oauth/tests.rs b/src-tauri/src/agents/native_oauth/tests.rs index 7c10bb0..d36081e 100644 --- a/src-tauri/src/agents/native_oauth/tests.rs +++ b/src-tauri/src/agents/native_oauth/tests.rs @@ -387,7 +387,10 @@ fn codex_apply_close_restores_original_login_and_preserves_user_edits() { Some("https://example.test/edited") ); assert!(restored.get("model_catalog_json").is_none()); - assert!(restored.get("model_providers").is_none()); + assert_eq!( + restored["model_providers"][MANAGED_AGENT_PROVIDER_ID]["base_url"].as_str(), + Some("http://127.0.0.1:8317/v1") + ); let auth: serde_json::Value = serde_json::from_str(&home.read("auth.json")).unwrap(); assert_eq!(auth["tokens"]["refresh_token"], "new-refresh"); assert_eq!(auth["custom"], "edited"); @@ -423,7 +426,10 @@ fn codex_close_existing_cpa_without_recovery_record_removes_managed_fields() { assert_eq!(home.config()["approval_policy"].as_str(), Some("never")); assert!(home.config().get("model_provider").is_none()); assert!(home.config().get("model_catalog_json").is_none()); - assert!(home.config().get("model_providers").is_none()); + assert_eq!( + home.config()["model_providers"][MANAGED_AGENT_PROVIDER_ID]["base_url"].as_str(), + Some("http://127.0.0.1:8317/v1") + ); assert!(!home.path("auth.json").exists()); } @@ -435,7 +441,17 @@ fn codex_close_restores_preexisting_api_provider_and_key() { let original = home.config(); apply_cpa(&home, false).unwrap(); close_codex_configuration(&home.0).unwrap(); - assert_eq!(home.config(), original); + let restored = home.config(); + assert_eq!(restored["model_provider"], original["model_provider"]); + assert_eq!(restored["model"], original["model"]); + assert_eq!( + restored["model_providers"]["other"], + original["model_providers"]["other"] + ); + assert_eq!( + restored["model_providers"][MANAGED_AGENT_PROVIDER_ID]["base_url"].as_str(), + Some("http://127.0.0.1:8317/v1") + ); assert!(codex_auth_file_has_api_key( &home.path("auth.json"), "original-key" diff --git a/src-tauri/src/agents/transactions.rs b/src-tauri/src/agents/transactions.rs index 9fe43b1..242d29b 100644 --- a/src-tauri/src/agents/transactions.rs +++ b/src-tauri/src/agents/transactions.rs @@ -6,6 +6,222 @@ mod tests; pub(crate) type Images = Vec<(PathBuf, Option>)>; +const AGENT_INTEGRATION_STATE_VERSION: u8 = 1; +const AGENT_INTEGRATION_STATE_FILE: &str = "integration-state.json"; + +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct AgentIntegrationState { + version: u8, + client: String, + paths: Vec, + // `original` is promoted with user edits; `applied` remains the last CPA-written snapshot. + original: Images, + applied: Images, +} + +fn integration_managed_paths<'a>(client: &str, paths: &'a [PathBuf]) -> &'a [PathBuf] { + if client == "deepseek-harness" && paths.len() > 2 { + &paths[..2] + } else { + paths + } +} + +fn integration_state_path(client: &str, paths: &[PathBuf]) -> Result { + let paths = integration_managed_paths(client, paths); + let identity = sha256_bytes( + &serde_json::to_vec(paths).map_err(|_| "生成接入配置标识失败".to_string())?, + ); + Ok(agent_data_directory(paths)? + .join("agents") + .join(client) + .join(identity) + .join(AGENT_INTEGRATION_STATE_FILE)) +} + +fn parse_integration_state( + client: &str, + paths: &[PathBuf], + bytes: Option<&[u8]>, +) -> Result, String> { + let Some(bytes) = bytes else { + return Ok(None); + }; + let state: AgentIntegrationState = serde_json::from_slice(bytes) + .map_err(|_| "接入配置合并状态损坏,请先手动备份配置".to_string())?; + let paths = integration_managed_paths(client, paths); + let valid_images = |images: &Images| { + images.len() == paths.len() + && images + .iter() + .zip(paths) + .all(|((path, _), expected)| path == expected) + }; + if state.version != AGENT_INTEGRATION_STATE_VERSION + || state.client != client + || state.paths != paths + || !valid_images(&state.original) + || !valid_images(&state.applied) + { + return Err("接入配置合并状态与当前客户端不匹配,请先手动备份配置".into()); + } + Ok(Some(state)) +} + +fn integration_state_bytes(state: &AgentIntegrationState) -> Result, String> { + serde_json::to_vec(state).map_err(|_| "生成接入配置合并状态失败".to_string()) +} + +fn merge_user_value( + baseline: Option<&Value>, + applied: Option<&Value>, + current: Option<&Value>, +) -> Option { + // Three-way merge: unchanged CPA values return to the baseline, while external edits win. + if current == applied { + return baseline.cloned(); + } + match ( + applied.and_then(Value::as_object), + current.and_then(Value::as_object), + ) { + (Some(applied), Some(current)) => { + let baseline = baseline.and_then(Value::as_object); + let mut keys = applied + .keys() + .chain(current.keys()) + .chain(baseline.into_iter().flat_map(|value| value.keys())) + .cloned() + .collect::>(); + keys.sort(); + keys.dedup(); + let mut merged = serde_json::Map::new(); + for key in keys { + if let Some(value) = merge_user_value( + baseline.and_then(|value| value.get(&key)), + applied.get(&key), + current.get(&key), + ) { + merged.insert(key, value); + } + } + Some(Value::Object(merged)) + } + _ => current.cloned(), + } +} + +fn merge_user_image( + path: &Path, + baseline: Option<&[u8]>, + applied: Option<&[u8]>, + current: Option<&[u8]>, +) -> Result>, String> { + if current == applied { + return Ok(baseline.map(ToOwned::to_owned)); + } + let Some(current) = current else { + return Ok(None); + }; + let current_text = std::str::from_utf8(current) + .map_err(|_| format!("配置不是 UTF-8 文本: {}", path_to_string(path)))?; + let applied_value = parse( + path, + applied + .map(|bytes| std::str::from_utf8(bytes).map_err(|_| "接入状态不是 UTF-8 文本")) + .transpose()?, + )?; + let current_value = parse(path, Some(current_text))?; + let baseline_value = parse( + path, + baseline + .map(|bytes| std::str::from_utf8(bytes).map_err(|_| "原始状态不是 UTF-8 文本")) + .transpose()?, + )?; + let merged = merge_user_value( + baseline.map(|_| &baseline_value), + applied.map(|_| &applied_value), + Some(¤t_value), + ); + merged + .map(|value| render(path, &value).map(|value| value.into_bytes())) + .transpose() +} + +fn promote_user_changes( + paths: &[PathBuf], + baseline: &Images, + applied: &Images, + current: &Images, +) -> Result { + paths + .iter() + .zip(baseline) + .zip(applied) + .zip(current) + .map(|(((path, (_, baseline)), (_, applied)), (_, current))| { + Ok(( + path.clone(), + merge_user_image( + path, + baseline.as_deref(), + applied.as_deref(), + current.as_deref(), + )?, + )) + }) + .collect() +} + +fn prepare_integration_restore( + client: AgentClient, + paths: &[PathBuf], + current: &Images, + state: &AgentIntegrationState, +) -> Result { + let effective_original = promote_user_changes( + paths, + &state.original, + &state.applied, + current, + )?; + current + .iter() + .zip(&effective_original) + .map(|((path, current), (_, original))| { + Ok(( + path.clone(), + build_agent_session_restored_bytes_with_preference( + client, + paths, + path, + current.as_deref(), + original.as_deref(), + false, + )?, + )) + }) + .collect() +} + +pub(crate) fn prepare_recorded_integration_restore( + client: AgentClient, + paths: &[PathBuf], + current: &Images, +) -> Result, String> { + let state_path = integration_state_path(client.id(), paths)?; + let state = parse_integration_state( + client.id(), + paths, + read_agent_bytes(&state_path)?.as_deref(), + )?; + state + .as_ref() + .map(|state| prepare_integration_restore(client, paths, current, state)) + .transpose() +} + pub(crate) fn config_paths(client: &str, home: &Path) -> Result, String> { if client == PI_AGENT_ID { return Ok(vec![ @@ -86,7 +302,11 @@ pub(crate) fn write_config_images(client: &str, images: &Images) -> Result<(), S continue; } if let Some(bytes) = bytes { - if client == PI_AGENT_ID { + if path.file_name().and_then(|name| name.to_str()) + == Some(AGENT_INTEGRATION_STATE_FILE) + { + write_codex_private_file(path, bytes)?; + } else if client == PI_AGENT_ID { write_bytes_directly(path, bytes)?; } else { write_agent_configuration_file(AgentClient::parse(client)?, path, bytes)?; @@ -179,6 +399,50 @@ fn commit_config_transaction( } let mut previous = before.clone(); let mut target = after.clone(); + if client != PI_AGENT_ID && client != AgentClient::Codex.id() { + let managed_paths = integration_managed_paths(client, paths); + let managed_len = managed_paths.len(); + let state_path = integration_state_path(client, paths)?; + let state_before = read_agent_bytes(&state_path)?; + let current_state = parse_integration_state(client, paths, state_before.as_deref())?; + let state_after = match source { + "update" => { + let parsed_client = AgentClient::parse(client)?; + let next = if let Some(state) = current_state { + let original = promote_user_changes( + managed_paths, + &state.original, + &state.applied, + &before[..managed_len].to_vec(), + )?; + Some(AgentIntegrationState { + version: AGENT_INTEGRATION_STATE_VERSION, + client: client.to_string(), + paths: managed_paths.to_vec(), + original, + applied: after[..managed_len].to_vec(), + }) + } else if before[..managed_len] != after[..managed_len] + && !agent_has_managed_marker(parsed_client, managed_paths)? + { + Some(AgentIntegrationState { + version: AGENT_INTEGRATION_STATE_VERSION, + client: client.to_string(), + paths: managed_paths.to_vec(), + original: before[..managed_len].to_vec(), + applied: after[..managed_len].to_vec(), + }) + } else { + None + }; + next.as_ref().map(integration_state_bytes).transpose()? + } + "clear-integration" | "restore" | "template" => None, + _ => state_before.clone(), + }; + previous.push((state_path.clone(), state_before)); + target.push((state_path, state_after)); + } if source == "clear-integration" { let state_path = agent_state_path(paths)?; previous.push((state_path.clone(), read_agent_bytes(&state_path)?)); @@ -758,6 +1022,9 @@ fn restore_text( } else { paths }; + if parsed == AgentClient::Codex && base_paths.first().is_some_and(|config| config == path) { + return build_restored_codex_agent_config_with_policy(Some(current), target, false); + } build_agent_session_restored_bytes( parsed, base_paths, diff --git a/src-tauri/src/tests/agent_configuration.rs b/src-tauri/src/tests/agent_configuration.rs index 4c55a7c..d7bbedd 100644 --- a/src-tauri/src/tests/agent_configuration.rs +++ b/src-tauri/src/tests/agent_configuration.rs @@ -555,7 +555,10 @@ fn claude_desktop_config_builds_gateway_profile_and_index() { let meta: serde_json::Value = serde_json::from_str(&meta).unwrap(); assert_eq!(profile["keep"], true); - assert!(profile.get("coworkEgressAllowedHosts").is_none()); + assert_eq!( + profile["coworkEgressAllowedHosts"], + serde_json::json!(["*"]) + ); assert_eq!(profile["inferenceGatewayApiKey"], DEFAULT_API_KEY); assert_eq!(profile["inferenceGatewayBaseUrl"], "http://127.0.0.1:8317"); assert_eq!( diff --git a/src-tauri/src/tests/agent_state.rs b/src-tauri/src/tests/agent_state.rs index fd4ede9..e16e174 100644 --- a/src-tauri/src/tests/agent_state.rs +++ b/src-tauri/src/tests/agent_state.rs @@ -543,7 +543,6 @@ fn claude_desktop_version_three_state_can_be_closed_safely() { assert!(threep.get("deploymentMode").is_none()); assert_eq!(threep["keep"], "threep"); for key in [ - "coworkEgressAllowedHosts", "disableDeploymentModeChooser", "inferenceGatewayApiKey", "inferenceGatewayAuthScheme", @@ -553,6 +552,10 @@ fn claude_desktop_version_three_state_can_be_closed_safely() { ] { assert!(profile.get(key).is_none(), "managed key remains: {key}"); } + assert_eq!( + profile["coworkEgressAllowedHosts"], + serde_json::json!(["127.0.0.1"]) + ); assert_eq!(profile["keep"], "profile"); assert!(meta.get("appliedId").is_none()); assert_eq!(meta["keep"], "meta"); @@ -798,9 +801,10 @@ name = "Other" assert_eq!(codex["approval_policy"].as_str(), Some("never")); assert!(codex.get("model_provider").is_none()); assert!(codex.get("model").is_none()); - assert!(codex["model_providers"] - .get(MANAGED_AGENT_PROVIDER_ID) - .is_none()); + assert_eq!( + codex["model_providers"][MANAGED_AGENT_PROVIDER_ID]["base_url"].as_str(), + Some("http://127.0.0.1:8317/v1") + ); assert_eq!( codex["model_providers"]["other"]["name"].as_str(), Some("Other") @@ -1561,8 +1565,22 @@ fn codex_connection_evidence_survives_port_key_and_partial_config_changes() { assert!(!inspect_codex_agent_config(&paths[0],8317,"old-key").unwrap().0); assert!(agent_has_connection_evidence(AgentClient::Codex, &paths).unwrap()); fs::write(&paths[0], "[model_providers.cpa-gui]\nbase_url = 'http://127.0.0.1:1234/v1'\n").unwrap(); - assert!(agent_has_connection_evidence(AgentClient::Codex, &paths).unwrap()); + assert!(!agent_has_connection_evidence(AgentClient::Codex, &paths).unwrap()); fs::write(&paths[0], "{{broken").unwrap(); assert!(agent_has_connection_evidence(AgentClient::Codex, &paths).is_err()); fs::remove_dir_all(home).unwrap(); } + +#[test] +fn provider_only_connection_evidence_remains_actionable_for_non_codex_agents() { + let home = agent_test_home("provider-only-connection-evidence"); + let paths = agent_config_paths(AgentClient::OpenCode, &home); + fs::create_dir_all(paths[0].parent().unwrap()).unwrap(); + fs::write( + &paths[0], + r#"{"provider":{"cpa-gui":{"name":"EasyCLIProxyAPI"}}}"#, + ) + .unwrap(); + assert!(agent_has_connection_evidence(AgentClient::OpenCode, &paths).unwrap()); + fs::remove_dir_all(home).unwrap(); +} diff --git a/src-tauri/src/tests/agent_transactions.rs b/src-tauri/src/tests/agent_transactions.rs index 46f2a40..770d151 100644 --- a/src-tauri/src/tests/agent_transactions.rs +++ b/src-tauri/src/tests/agent_transactions.rs @@ -184,7 +184,10 @@ fn codex_disable_keeps_user_configuration_added_after_enable() { assert!(restored.get("model_provider").is_none()); assert!(restored.get("model").is_none()); assert!(restored.get("model_catalog_json").is_none()); - assert!(restored.get("model_providers").is_none()); + assert_eq!( + restored["model_providers"][MANAGED_AGENT_PROVIDER_ID]["base_url"].as_str(), + Some("http://127.0.0.1:8317/v1") + ); assert!(!codex_model_catalog_path(&home).exists()); fs::remove_dir_all(home).unwrap(); } @@ -310,12 +313,19 @@ fn legacy_agent_backup_restores_even_when_gui_port_changed() { assert!(agent_has_managed_marker(AgentClient::Codex, std::slice::from_ref(&path)).unwrap()); let result = disable_agent_modification(AgentClient::Codex, &home, 8317, false).unwrap(); assert_eq!(result.outcome, "disabled"); - assert_eq!(fs::read(&path).unwrap(), original); + let restored: toml::Value = toml::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!(restored["approval_policy"].as_str(), Some("never")); + assert!(restored.get("model_provider").is_none()); + assert!(restored.get("model").is_none()); + assert_eq!( + restored["model_providers"][MANAGED_AGENT_PROVIDER_ID]["base_url"].as_str(), + Some("http://127.0.0.1:9999/v1") + ); fs::remove_dir_all(home).unwrap(); } #[test] -fn legacy_generated_only_agent_config_is_removed_without_backup() { +fn legacy_generated_only_codex_config_keeps_a_dormant_provider_without_backup() { let home = agent_test_home("legacy-generated"); let path = home.join(".codex/config.toml"); fs::create_dir_all(path.parent().unwrap()).unwrap(); @@ -333,7 +343,13 @@ fn legacy_generated_only_agent_config_is_removed_without_backup() { let result = disable_agent_modification(AgentClient::Codex, &home, 8317, false).unwrap(); assert_eq!(result.outcome, "disabled"); - assert!(!path.exists()); + let restored: toml::Value = toml::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); + assert!(restored.get("model_provider").is_none()); + assert!(restored.get("model").is_none()); + assert_eq!( + restored["model_providers"][MANAGED_AGENT_PROVIDER_ID]["base_url"].as_str(), + Some("http://127.0.0.1:8317/v1") + ); fs::remove_dir_all(home).unwrap(); } @@ -398,7 +414,14 @@ fn updating_legacy_codex_state_adds_catalog_without_replacing_original_backup() assert!(auth_path.is_file()); disable_agent_modification(AgentClient::Codex, &home, 8317, false).unwrap(); - assert_eq!(fs::read(&path).unwrap(), original_config); + let restored: toml::Value = toml::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!(restored["approval_policy"].as_str(), Some("never")); + assert!(restored.get("model_provider").is_none()); + assert!(restored.get("model").is_none()); + assert_eq!( + restored["model_providers"][MANAGED_AGENT_PROVIDER_ID]["base_url"].as_str(), + Some("http://127.0.0.1:8317/v1") + ); assert_eq!(fs::read(&catalog_path).unwrap(), original_catalog); assert!(!auth_path.exists()); fs::remove_dir_all(home).unwrap();