fix(agents): preserve user settings and retain dormant Codex provider

- Restore integration settings with a three-way merge that preserves user edits
- Preserve Claude Code subagent models and Claude Desktop cowork settings
- Retain the dormant cpa-gui provider for historical Codex sessions
- Update integration state transactionally and preserve legacy cleanup behavior
- Add lifecycle and connection-state regression tests
This commit is contained in:
lzt404 committed 2026-09-19 20:42:30 +08:00
1 parent 71c63234f8
commit c9c0c4cf1c
8 files changed
+502 -65

No files matched your search

+81
View File
@@ -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();
+70 -42
View File
@@ -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<String, String> {
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<Images,
if managed_catalog {
document.remove("model_catalog_json");
}
if let Some(providers) = document
.get_mut("model_providers")
.and_then(Item::as_table_mut)
{
providers.remove(MANAGED_AGENT_PROVIDER_ID);
}
if document
.get("model_providers")
.and_then(Item::as_table)
.is_some_and(toml_edit::Table::is_empty)
{
document.remove("model_providers");
}
let rendered = document.to_string();
toml::from_str::<toml::Value>(&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<Option<Vec<u8>>, 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<Option<Vec<u8>>, 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<Option<String>, 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<Option<String>, 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);
}
}
}
}
+9 -8
View File
@@ -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",
+19 -3
View File
@@ -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"
+268 -1
View File
@@ -6,6 +6,222 @@ mod tests;
pub(crate) type Images = Vec<(PathBuf, Option<Vec<u8>>)>;
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<PathBuf>,
// `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<PathBuf, String> {
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<Option<AgentIntegrationState>, 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<Vec<u8>, String> {
serde_json::to_vec(state).map_err(|_| "生成接入配置合并状态失败".to_string())
}
fn merge_user_value(
baseline: Option<&Value>,
applied: Option<&Value>,
current: Option<&Value>,
) -> Option<Value> {
// 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::<Vec<_>>();
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<Option<Vec<u8>>, 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(&current_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<Images, String> {
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<Images, String> {
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<Option<Images>, 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<Vec<PathBuf>, 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,
+4 -1
View File
@@ -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!(
+23 -5
View File
@@ -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();
}
+28 -5
View File
@@ -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();