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:
1 parent
71c63234f8
commit
c9c0c4cf1c
8 files changed
+502
-65
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(¤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<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,
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user