feat(agents): restore configuration disable and clear actions

This commit is contained in:
lzt404 committed 2026-09-18 18:17:31 +08:00
1 parent 050dbddca4
commit ec07502de9
14 files changed
+641 -236

No files matched your search

+300
View File
@@ -388,6 +388,306 @@ fn unwritable_backup_directory_does_not_block_updates_sync_clear_or_templates()
assert_eq!(fs::read_to_string(data.join("backups")).unwrap(), "blocked");
}
#[test]
fn clear_integration_needs_no_backup_and_removes_state_for_each_client() {
for client in [
AgentClient::ClaudeCode,
AgentClient::ClaudeDesktop,
AgentClient::OpenCode,
AgentClient::OpenClaw,
AgentClient::Hermes,
AgentClient::DeepSeekHarness,
AgentClient::ZCode,
AgentClient::KimiCode,
AgentClient::GrokBuild,
]
.into_iter()
.filter(|client| client.supported_platform())
{
let home = Home::new();
let paths = config_paths(client.id(), &home.0).unwrap();
apply(&home.0, client, "gpt-one").unwrap();
let mut expected = Vec::new();
for (path, bytes) in config_images(&paths).unwrap() {
let mut value = parse(&path, text(bytes.as_deref()).unwrap()).unwrap();
value["user_setting"] = serde_json::json!({"keep": true});
save(&path, render(&path, &value).unwrap());
expected.push((path, serde_json::json!({"user_setting": {"keep": true}})));
}
let state = agent_state_path(&paths).unwrap();
save(&state, "old state without a usable backup");
let extra_state = match client {
AgentClient::ClaudeDesktop => Some(desktop_mapping_path(&paths).unwrap()),
AgentClient::DeepSeekHarness => {
Some(deepseek_harness_catalog_state_path(&paths).unwrap())
}
_ => None,
};
if let Some(path) = &extra_state {
save(path, "stale state");
}
let backup = paths[0].with_extension("manual.bak");
save(&backup, "keep manual backup");
let result = clear_agent_managed_configuration(client, &home.0, 8317).unwrap();
assert!(!result.enabled, "{}", client.id());
assert!(!state.exists());
assert!(extra_state.is_none_or(|path| !path.exists()));
assert_eq!(fs::read_to_string(backup).unwrap(), "keep manual backup");
assert!(!agent_has_managed_marker(client, &paths).unwrap());
for (path, expected) in expected {
let value = parse(
&path,
text(read_agent_bytes(&path).unwrap().as_deref()).unwrap(),
)
.unwrap();
assert_eq!(
value["user_setting"],
expected["user_setting"],
"{}",
client.id()
);
}
let result = clear_agent_managed_configuration(client, &home.0, 8317).unwrap();
assert!(!result.enabled);
assert!(result.changed_files.is_empty(), "{}", client.id());
assert!(list_backups(client.id(), &home.0)
.unwrap()
.versions
.is_empty());
apply(&home.0, client, "gpt-two").unwrap();
assert!(agent_has_managed_marker(client, &paths).unwrap());
}
}
#[test]
fn clear_integration_removes_claude_model_overrides_but_keeps_custom_settings() {
let home = Home::new();
let paths = config_paths("claude-code", &home.0).unwrap();
let original = serde_json::json!({
"permissions": {"allow": ["Read"]},
"env": {"KEEP": "yes", "ANTHROPIC_CUSTOM_MODEL_OPTION_2": "user-model"}
});
save(&paths[0], original.to_string());
apply(&home.0, AgentClient::ClaudeCode, "gpt-one").unwrap();
clear_agent_managed_configuration(AgentClient::ClaudeCode, &home.0, 8317).unwrap();
let result = parse(&paths[0], Some(&fs::read_to_string(&paths[0]).unwrap())).unwrap();
assert_eq!(result, original);
apply(&home.0, AgentClient::ClaudeCode, "gpt-one").unwrap();
let mut edited = parse(&paths[0], Some(&fs::read_to_string(&paths[0]).unwrap())).unwrap();
edited["model"] = serde_json::json!("user-model");
save(&paths[0], edited.to_string());
clear_agent_managed_configuration(AgentClient::ClaudeCode, &home.0, 8317).unwrap();
assert_eq!(
parse(&paths[0], Some(&fs::read_to_string(&paths[0]).unwrap())).unwrap()["model"],
"user-model"
);
let external = r#"{"env":{"ANTHROPIC_BASE_URL":"https://api.example.com","ANTHROPIC_AUTH_TOKEN":"other"},"model":"external"}"#;
save(&paths[0], external);
clear_agent_managed_configuration(AgentClient::ClaudeCode, &home.0, 8317).unwrap();
assert_eq!(fs::read_to_string(&paths[0]).unwrap(), external);
}
#[test]
fn clear_integration_preserves_claude_settings_for_other_endpoints() {
let home = Home::new();
let paths = config_paths("claude-code", &home.0).unwrap();
for base_url in [
"http://localhost:11434",
"http://127.0.0.1:11434",
"http://127.0.0.1:83170",
"http://127.0.0.1:8317/other-proxy",
"http://127.0.0.1:8317?route=other",
"http://127.0.0.1:8317#other",
"http://other:secret@127.0.0.1:8317",
"http://127.0.0.1.example.com:8317",
"https://127.0.0.1:8317",
] {
let original = serde_json::json!({
"model": "local-model", "permissions": {"allow": ["Read"]},
"env": {"ANTHROPIC_BASE_URL": base_url, "ANTHROPIC_AUTH_TOKEN": "other-key", "ANTHROPIC_MODEL": "local-model"}
}).to_string();
save(&paths[0], &original);
let result =
clear_agent_managed_configuration(AgentClient::ClaudeCode, &home.0, 8317).unwrap();
assert!(result.changed_files.is_empty(), "{base_url}");
assert_eq!(
fs::read_to_string(&paths[0]).unwrap(),
original,
"{base_url}"
);
}
}
#[test]
fn clear_integration_uses_the_configured_claude_endpoint_without_a_backup() {
let home = Home::new();
let paths = config_paths("claude-code", &home.0).unwrap();
for port in [8317, 9527] {
// URL parsing accepts the trailing slash without treating another port as CPA.
let base_url = format!("http://127.0.0.1:{port}/");
save(
&paths[0],
build_claude_agent_config(
Some(r#"{"env":{"KEEP":"yes"}}"#),
&base_url,
"test-secret",
"gpt-one",
&models(),
None,
)
.unwrap(),
);
clear_agent_managed_configuration(AgentClient::ClaudeCode, &home.0, port).unwrap();
assert_eq!(
parse(&paths[0], Some(&fs::read_to_string(&paths[0]).unwrap())).unwrap(),
serde_json::json!({"env":{"KEEP":"yes"}})
);
}
for base_url in ["https://[::1]:9527", "https://192.168.1.10:9527"] {
save(
&paths[0],
build_claude_agent_config(None, base_url, "test-secret", "gpt-one", &models(), None)
.unwrap(),
);
let plan = prepare_claude_code_managed_removal(&paths, base_url).unwrap();
assert_eq!(plan, vec![(paths[0].clone(), None)], "{base_url}");
}
}
#[test]
fn clear_integration_handles_inline_and_mixed_toml_tables() {
let examples = [
(
AgentClient::GrokBuild,
r#"# custom settings
models = { default = "cpa-gui/gpt-one", keep = true }
model = { "cpa-gui/gpt-one" = { model = "gpt-one", base_url = "http://127.0.0.1:8317/v1", api_key = "test-secret" }, other = { model = "other", api_key = "keep-secret" } }
"#,
serde_json::json!({"models":{"keep":true},"model":{"other":{"model":"other","api_key":"keep-secret"}}}),
),
(
AgentClient::GrokBuild,
r#"# custom settings
model = { "cpa-gui/gpt-one" = { model = "gpt-one" }, other = { model = "other" } }
[models]
default = "other"
"#,
serde_json::json!({"models":{"default":"other"},"model":{"other":{"model":"other"}}}),
),
(
AgentClient::KimiCode,
r#"default_model = "cpa-gui/gpt-one"
# custom settings
providers = { "cpa-gui" = { type = "openai", base_url = "http://127.0.0.1:8317/v1", api_key = "test-secret" }, other = { api_key = "keep-secret" } }
models = { "cpa-gui/gpt-one" = { provider = "cpa-gui", model = "gpt-one" }, other = { provider = "other", model = "other" } }
"#,
serde_json::json!({"providers":{"other":{"api_key":"keep-secret"}},"models":{"other":{"provider":"other","model":"other"}}}),
),
(
AgentClient::KimiCode,
r#"# custom settings
default_model = "other"
providers = { "cpa-gui" = { api_key = "test-secret" }, other = { api_key = "keep-secret" } }
[models."cpa-gui/gpt-one"]
provider = "cpa-gui"
model = "gpt-one"
[models.other]
provider = "other"
model = "other"
"#,
serde_json::json!({"default_model":"other","providers":{"other":{"api_key":"keep-secret"}},"models":{"other":{"provider":"other","model":"other"}}}),
),
];
for (client, content, expected) in examples {
let home = Home::new();
let paths = config_paths(client.id(), &home.0).unwrap();
save(&paths[0], content);
let result = clear_agent_managed_configuration(client, &home.0, 8317).unwrap();
assert!(!result.enabled);
assert_eq!(result.changed_files, vec![path_to_string(&paths[0])]);
let rendered = fs::read_to_string(&paths[0]).unwrap();
assert_eq!(parse(&paths[0], Some(&rendered)).unwrap(), expected);
assert!(rendered.contains("# custom settings"));
assert!(!agent_has_connection_evidence(client, &paths).unwrap());
assert!(clear_agent_managed_configuration(client, &home.0, 8317)
.unwrap()
.changed_files
.is_empty());
}
}
#[test]
fn clear_integration_removes_empty_inline_toml_tables() {
for (client, content) in [
(
AgentClient::GrokBuild,
r#"models = { default = "cpa-gui/gpt-one" }
model = { "cpa-gui/gpt-one" = { model = "gpt-one", api_key = "test-secret" } }
"#,
),
(
AgentClient::KimiCode,
r#"default_model = "cpa-gui/gpt-one"
providers = { "cpa-gui" = { api_key = "test-secret" } }
models = { "cpa-gui/gpt-one" = { provider = "cpa-gui", model = "gpt-one" } }
"#,
),
] {
let home = Home::new();
let paths = config_paths(client.id(), &home.0).unwrap();
save(&paths[0], content);
clear_agent_managed_configuration(client, &home.0, 8317).unwrap();
assert!(!paths[0].exists());
}
}
#[test]
fn clear_integration_rejects_invalid_files_before_writing() {
let home = Home::new();
let paths = config_paths("deepseek-harness", &home.0).unwrap();
apply(&home.0, AgentClient::DeepSeekHarness, "gpt-one").unwrap();
save(&paths[1], "refs: [invalid");
let before = config_images(&paths).unwrap();
assert!(clear_agent_managed_configuration(AgentClient::DeepSeekHarness, &home.0, 8317).is_err());
assert_eq!(config_images(&paths).unwrap(), before);
}
#[test]
fn clear_integration_rolls_back_config_and_state_together() {
let home = Home::new();
let paths = config_paths("claude-code", &home.0).unwrap();
apply(&home.0, AgentClient::ClaudeCode, "gpt-one").unwrap();
let state = agent_state_path(&paths).unwrap();
save(&state, "legacy state");
let before = config_images(&paths).unwrap();
let after = paths.iter().map(|p| (p.clone(), None)).collect();
let mut first = true;
let error = commit_config_with_writer(
"claude-code",
&paths,
&before,
&after,
"clear-integration",
None,
None,
&mut |client, images| {
write_config_images(client, images)?;
if first {
first = false;
Err("injected failure".into())
} else {
Ok(())
}
},
)
.unwrap_err();
assert!(error.contains("已回滚"));
assert_eq!(config_images(&paths).unwrap(), before);
assert_eq!(fs::read_to_string(state).unwrap(), "legacy state");
}
#[test]
fn partial_writes_and_failed_verification_roll_back_without_persistent_snapshots() {
let home = Home::new();
+7 -5
View File
@@ -1124,11 +1124,10 @@ pub(crate) async fn set_agent_config_enabled(
.path()
.home_dir()
.map_err(|error| format!("无法获取用户目录: {error}"))?;
let config = gui_config_state.snapshot()?;
let port = config.port;
let api_key = effective_agent_api_key(&config);
if enabled {
let config = gui_config_state.snapshot()?;
let port = config.port;
let api_key = effective_agent_api_key(&config);
validate_agent_can_enable(client, &home, port, api_key)?;
let prepared = fetch_prepared_agent_models(client, &config).await?;
let model =
@@ -1170,11 +1169,14 @@ pub(crate) async fn set_agent_config_enabled(
)
}).await
} else {
let config = gui_config_state.snapshot()?;
let _guard = AGENT_CONFIG_FILE_LOCK
.lock()
.map_err(|_| "智能体配置文件锁已损坏".to_string())?;
let _ = force_restore;
Err("停用智能体配置接口已移除;如需整体重置,请使用“基础配置模板”".to_string())
let result = clear_agent_managed_configuration(client, &home, config.port)?;
app.state::<AgentConfigStatusCache>().clear()?;
Ok(result)
}
}
+227 -206
View File
@@ -469,7 +469,6 @@ fn deepseek_harness_credentials_refs_mut<'a>(
.ok_or_else(|| format!("{label} refs 必须是映射"))
}
#[cfg(test)]
fn remove_deepseek_harness_managed_credential(
root: &mut serde_norway::Mapping,
label: &str,
@@ -890,47 +889,94 @@ pub(crate) fn build_claude_desktop_meta(existing: Option<&str>) -> Result<String
render_agent_json(root, "Claude Desktop 配置索引")
}
pub(crate) fn clear_agent_managed_configuration(
client: AgentClient,
home: &Path,
port: u16,
) -> Result<AgentConfigActionResult, String> {
if client == AgentClient::Codex {
return close_codex_configuration(home);
}
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 mut result = commit_config(
client.id(),
&paths,
&before,
&after,
"clear-integration",
None,
)?;
result.enabled = false;
Ok(result)
}
#[cfg(test)]
pub(crate) fn update_agent_json_file<F>(path: &Path, label: &str, update: F) -> Result<bool, String>
pub(crate) fn remove_agent_managed_configuration(
client: AgentClient,
paths: &[PathBuf],
) -> Result<Vec<String>, String> {
let updates = prepare_agent_managed_removal(client, paths, GuiConfigFile::default().port)?;
write_config_images(client.id(), &updates)?;
Ok(updates
.iter()
.map(|(path, _)| path_to_string(path))
.collect())
}
pub(crate) fn prepare_agent_json_removal<F>(
path: &Path,
label: &str,
update: F,
) -> Result<Option<FileSnapshot>, String>
where
F: FnOnce(&mut serde_json::Map<String, serde_json::Value>) -> bool,
{
if !path.is_file() {
return Ok(false);
return Ok(None);
}
let content = fs::read_to_string(path)
.map_err(|error| format!("读取 {label} 失败 {}: {error}", path_to_string(path)))?;
let mut root = parse_agent_json_object(Some(&content), label)?;
if !update(&mut root) {
return Ok(false);
return Ok(None);
}
if root.is_empty() {
fs::remove_file(path)
.map_err(|error| format!("删除空的 {label} 失败 {}: {error}", path_to_string(path)))?;
let bytes = if root.is_empty() {
None
} else {
let rendered = render_agent_json(root, label)?;
write_bytes_directly(path, rendered.as_bytes())?;
}
Ok(true)
Some(render_agent_json(root, label)?.into_bytes())
};
Ok(Some((path.to_path_buf(), bytes)))
}
#[cfg(test)]
pub(crate) fn remove_claude_desktop_managed_configuration(
paths: &[PathBuf],
) -> Result<Vec<String>, String> {
pub(crate) fn prepare_claude_desktop_managed_removal(paths: &[PathBuf]) -> Result<Images, String> {
if paths.len() != 4 {
return Err("Claude Desktop 当前平台配置路径不可用".to_string());
}
let meta = read_agent_json_or_empty(&paths[3], "Claude Desktop 配置索引")?;
let applied_id = meta.get("appliedId").and_then(serde_json::Value::as_str);
let managed_active = applied_id == Some(CLAUDE_DESKTOP_PROFILE_ID)
|| (applied_id.is_none() && agent_has_managed_marker(AgentClient::ClaudeDesktop, paths)?);
let mut changed = Vec::new();
for (path, label) in [
(&paths[0], "Claude Desktop 主配置"),
(&paths[1], "Claude Desktop 3P 配置"),
] {
if update_agent_json_file(path, label, |root| {
if root
.get("deploymentMode")
.and_then(serde_json::Value::as_str)
== Some("3p")
if let Some(update) = prepare_agent_json_removal(path, label, |root| {
if managed_active
&& root
.get("deploymentMode")
.and_then(serde_json::Value::as_str)
== Some("3p")
{
root.remove("deploymentMode");
true
@@ -938,68 +984,74 @@ pub(crate) fn remove_claude_desktop_managed_configuration(
false
}
})? {
changed.push(path_to_string(path));
changed.push(update);
}
}
if update_agent_json_file(&paths[2], "Claude Desktop 网关配置", |root| {
let mut updated = false;
for key in [
"coworkEgressAllowedHosts",
"disableDeploymentModeChooser",
"inferenceGatewayApiKey",
"inferenceGatewayAuthScheme",
"inferenceGatewayBaseUrl",
"inferenceProvider",
"inferenceModels",
] {
updated |= root.remove(key).is_some();
}
updated
})? {
changed.push(path_to_string(&paths[2]));
if let Some(update) =
prepare_agent_json_removal(&paths[2], "Claude Desktop 网关配置", |root| {
let mut updated = false;
for key in [
"coworkEgressAllowedHosts",
"disableDeploymentModeChooser",
"inferenceGatewayApiKey",
"inferenceGatewayAuthScheme",
"inferenceGatewayBaseUrl",
"inferenceProvider",
"inferenceModels",
] {
updated |= root.remove(key).is_some();
}
updated
})?
{
changed.push(update);
}
if update_agent_json_file(&paths[3], "Claude Desktop 配置索引", |root| {
let mut updated = false;
if root.get("appliedId").and_then(serde_json::Value::as_str)
== Some(CLAUDE_DESKTOP_PROFILE_ID)
{
root.remove("appliedId");
updated = true;
}
let entries_empty = if let Some(entries) = root
.get_mut("entries")
.and_then(serde_json::Value::as_array_mut)
{
let previous_len = entries.len();
entries.retain(|entry| {
entry.get("id").and_then(serde_json::Value::as_str)
!= Some(CLAUDE_DESKTOP_PROFILE_ID)
});
updated |= entries.len() != previous_len;
entries.is_empty()
} else {
false
};
if entries_empty {
root.remove("entries");
}
updated
})? {
changed.push(path_to_string(&paths[3]));
if let Some(update) =
prepare_agent_json_removal(&paths[3], "Claude Desktop 配置索引", |root| {
let mut updated = false;
if root.get("appliedId").and_then(serde_json::Value::as_str)
== Some(CLAUDE_DESKTOP_PROFILE_ID)
{
root.remove("appliedId");
updated = true;
}
let entries_empty = if let Some(entries) = root
.get_mut("entries")
.and_then(serde_json::Value::as_array_mut)
{
let previous_len = entries.len();
entries.retain(|entry| {
entry.get("id").and_then(serde_json::Value::as_str)
!= Some(CLAUDE_DESKTOP_PROFILE_ID)
});
updated |= entries.len() != previous_len;
entries.is_empty()
} else {
false
};
if entries_empty {
root.remove("entries");
}
updated
})?
{
changed.push(update);
}
Ok(changed)
}
#[cfg(test)]
pub(crate) fn remove_claude_code_managed_configuration(
pub(crate) fn prepare_claude_code_managed_removal(
paths: &[PathBuf],
) -> Result<Vec<String>, String> {
expected_base_url: &str,
) -> Result<Images, String> {
let Some(path) = paths.first() else {
return Err("Claude Code 当前平台配置路径不可用".to_string());
};
let updated = update_agent_json_file(path, "Claude Code 配置", |root| {
let expected_base_url = reqwest::Url::parse(expected_base_url)
.map_err(|_| "CPA 接入地址无效".to_string())?;
let updated = prepare_agent_json_removal(path, "Claude Code 配置", |root| {
let managed_model = root
.get("env")
.and_then(serde_json::Value::as_object)
@@ -1011,7 +1063,9 @@ pub(crate) fn remove_claude_code_managed_configuration(
.and_then(serde_json::Value::as_object)
.and_then(|env| env.get("ANTHROPIC_BASE_URL"))
.and_then(serde_json::Value::as_str)
.is_some_and(is_managed_agent_base_url);
.is_some_and(|url| {
reqwest::Url::parse(url.trim()).is_ok_and(|url| url == expected_base_url)
});
if !managed {
return false;
}
@@ -1035,6 +1089,19 @@ pub(crate) fn remove_claude_code_managed_configuration(
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",
] {
env.remove(key);
}
@@ -1047,11 +1114,10 @@ pub(crate) fn remove_claude_code_managed_configuration(
}
true
})?;
Ok(updated.then(|| path_to_string(path)).into_iter().collect())
Ok(updated.into_iter().collect())
}
#[cfg(test)]
pub(crate) fn remove_codex_managed_configuration(paths: &[PathBuf]) -> Result<Vec<String>, String> {
pub(crate) fn prepare_codex_managed_removal(paths: &[PathBuf]) -> Result<Images, String> {
use toml_edit::{Document, Item};
let Some(path) = paths.first() else {
@@ -1099,38 +1165,22 @@ pub(crate) fn remove_codex_managed_configuration(paths: &[PathBuf]) -> Result<Ve
let rendered = document.to_string();
toml::from_str::<toml::Value>(&rendered)
.map_err(|error| format!("验证恢复后的 Codex 配置失败: {error}"))?;
if rendered.trim().is_empty() {
fs::remove_file(path).map_err(|error| {
format!("删除空的 Codex 配置失败 {}: {error}", path_to_string(path))
})?;
} else {
write_bytes_directly(path, rendered.as_bytes())?;
}
let mut changed = vec![path_to_string(path)];
let mut changed = vec![(
path.clone(),
(!rendered.trim().is_empty()).then(|| rendered.into_bytes()),
)];
if managed_catalog {
let catalog_path = path.with_file_name(CODEX_MODEL_CATALOG_FILE);
if catalog_path.is_file() {
fs::remove_file(&catalog_path).map_err(|error| {
format!(
"删除 Codex CPA 模型目录失败 {}: {error}",
path_to_string(&catalog_path)
)
})?;
changed.push(path_to_string(&catalog_path));
}
changed.push((path.with_file_name(CODEX_MODEL_CATALOG_FILE), None));
}
Ok(changed)
}
#[cfg(test)]
pub(crate) fn remove_opencode_managed_configuration(
paths: &[PathBuf],
) -> Result<Vec<String>, String> {
pub(crate) fn prepare_opencode_managed_removal(paths: &[PathBuf]) -> Result<Images, String> {
let Some(path) = paths.first() else {
return Err("OpenCode 当前平台配置路径不可用".to_string());
};
let prefix = format!("{MANAGED_AGENT_PROVIDER_ID}/");
let updated = update_agent_json5_file(path, "OpenCode 配置", |root| {
let updated = prepare_agent_json5_removal(path, "OpenCode 配置", |root| {
let mut changed = false;
if root
.get("model")
@@ -1154,18 +1204,17 @@ pub(crate) fn remove_opencode_managed_configuration(
}
changed
})?;
Ok(updated.then(|| path_to_string(path)).into_iter().collect())
Ok(updated.into_iter().collect())
}
#[cfg(test)]
pub(crate) fn remove_zcode_managed_configuration(paths: &[PathBuf]) -> Result<Vec<String>, String> {
pub(crate) fn prepare_zcode_managed_removal(paths: &[PathBuf]) -> Result<Images, String> {
if paths.is_empty() {
return Err("ZCode 当前平台配置路径不可用".to_string());
}
let prefix = format!("{MANAGED_AGENT_PROVIDER_ID}/");
let mut changed_paths = Vec::new();
for path in paths {
let updated = update_agent_json_file(path, "ZCode 配置", |root| {
let updated = prepare_agent_json_removal(path, "ZCode 配置", |root| {
let mut changed = false;
let remove_model = if root
.get("model")
@@ -1207,18 +1256,15 @@ pub(crate) fn remove_zcode_managed_configuration(paths: &[PathBuf]) -> Result<Ve
}
changed
})?;
if updated {
changed_paths.push(path_to_string(path));
if let Some(update) = updated {
changed_paths.push(update);
}
}
Ok(changed_paths)
}
#[cfg(test)]
pub(crate) fn remove_kimi_code_managed_configuration(
paths: &[PathBuf],
) -> Result<Vec<String>, String> {
remove_managed_toml_client_configuration(
pub(crate) fn prepare_kimi_code_managed_removal(paths: &[PathBuf]) -> Result<Images, String> {
prepare_managed_toml_client_removal(
paths,
"Kimi Code",
Some("providers"),
@@ -1228,11 +1274,8 @@ pub(crate) fn remove_kimi_code_managed_configuration(
)
}
#[cfg(test)]
pub(crate) fn remove_grok_build_managed_configuration(
paths: &[PathBuf],
) -> Result<Vec<String>, String> {
remove_managed_toml_client_configuration(
pub(crate) fn prepare_grok_build_managed_removal(paths: &[PathBuf]) -> Result<Images, String> {
prepare_managed_toml_client_removal(
paths,
"Grok Build",
None,
@@ -1242,15 +1285,14 @@ pub(crate) fn remove_grok_build_managed_configuration(
)
}
#[cfg(test)]
pub(crate) fn remove_managed_toml_client_configuration(
pub(crate) fn prepare_managed_toml_client_removal(
paths: &[PathBuf],
label: &str,
provider_section: Option<&str>,
model_entry_section: &str,
default_section: Option<&str>,
default_key: &str,
) -> Result<Vec<String>, String> {
) -> Result<Images, String> {
use toml_edit::{Document, Item};
let Some(path) = paths.first() else {
@@ -1267,14 +1309,15 @@ pub(crate) fn remove_managed_toml_client_configuration(
let prefix = format!("{MANAGED_AGENT_PROVIDER_ID}/");
let mut changed = false;
let default_table = if let Some(section) = default_section {
document
.as_table_mut()
.get_mut(section)
.and_then(Item::as_table_mut)
} else {
Some(document.as_table_mut())
};
let default_table: Option<&mut dyn toml_edit::TableLike> =
if let Some(section) = default_section {
document
.as_table_mut()
.get_mut(section)
.and_then(Item::as_table_like_mut)
} else {
Some(document.as_table_mut())
};
if let Some(table) = default_table {
if table
.get(default_key)
@@ -1290,7 +1333,7 @@ pub(crate) fn remove_managed_toml_client_configuration(
if let Some(providers) = document
.as_table_mut()
.get_mut(section)
.and_then(Item::as_table_mut)
.and_then(Item::as_table_like_mut)
{
changed |= providers.remove(MANAGED_AGENT_PROVIDER_ID).is_some();
}
@@ -1298,7 +1341,7 @@ pub(crate) fn remove_managed_toml_client_configuration(
if let Some(models) = document
.as_table_mut()
.get_mut(model_entry_section)
.and_then(Item::as_table_mut)
.and_then(Item::as_table_like_mut)
{
let managed = models
.iter()
@@ -1314,8 +1357,8 @@ pub(crate) fn remove_managed_toml_client_configuration(
let empty = document
.as_table()
.get(section)
.and_then(Item::as_table)
.is_some_and(toml_edit::Table::is_empty);
.and_then(Item::as_table_like)
.is_some_and(|table| table.is_empty());
if empty {
document.as_table_mut().remove(section);
}
@@ -1324,8 +1367,8 @@ pub(crate) fn remove_managed_toml_client_configuration(
let empty = document
.as_table()
.get(section)
.and_then(Item::as_table)
.is_some_and(toml_edit::Table::is_empty);
.and_then(Item::as_table_like)
.is_some_and(|table| table.is_empty());
if empty {
document.as_table_mut().remove(section);
}
@@ -1336,30 +1379,22 @@ pub(crate) fn remove_managed_toml_client_configuration(
let rendered = document.to_string();
toml::from_str::<toml::Value>(&rendered)
.map_err(|error| format!("验证恢复后的 {label} 配置失败: {error}"))?;
if rendered.trim().is_empty() {
fs::remove_file(path).map_err(|error| {
format!(
"删除空的 {label} 配置失败 {}: {error}",
path_to_string(path)
)
})?;
} else {
write_bytes_directly(path, rendered.as_bytes())?;
}
Ok(vec![path_to_string(path)])
Ok(vec![(
path.clone(),
(!rendered.trim().is_empty()).then(|| rendered.into_bytes()),
)])
}
#[cfg(test)]
pub(crate) fn update_agent_json5_file<F>(
pub(crate) fn prepare_agent_json5_removal<F>(
path: &Path,
label: &str,
update: F,
) -> Result<bool, String>
) -> Result<Option<FileSnapshot>, String>
where
F: FnOnce(&mut serde_json::Map<String, serde_json::Value>) -> bool,
{
if !path.is_file() {
return Ok(false);
return Ok(None);
}
let content = fs::read_to_string(path)
.map_err(|error| format!("读取 {label} 失败 {}: {error}", path_to_string(path)))?;
@@ -1370,33 +1405,31 @@ where
.cloned()
.ok_or_else(|| format!("{label} 根节点必须是对象"))?;
if !update(&mut root) {
return Ok(false);
return Ok(None);
}
if root.is_empty() {
fs::remove_file(path)
.map_err(|error| format!("删除空的 {label} 失败 {}: {error}", path_to_string(path)))?;
let bytes = if root.is_empty() {
None
} else {
let rendered = render_agent_json(root, label)?;
let comments = extract_json5_comments(&content);
let rendered = if comments.is_empty() {
rendered
} else {
format!("{}\n{rendered}", comments.join("\n"))
};
write_bytes_directly(path, rendered.as_bytes())?;
}
Ok(true)
Some(
if comments.is_empty() {
rendered
} else {
format!("{}\n{rendered}", comments.join("\n"))
}
.into_bytes(),
)
};
Ok(Some((path.to_path_buf(), bytes)))
}
#[cfg(test)]
pub(crate) fn remove_openclaw_managed_configuration(
paths: &[PathBuf],
) -> Result<Vec<String>, String> {
pub(crate) fn prepare_openclaw_managed_removal(paths: &[PathBuf]) -> Result<Images, String> {
let Some(path) = paths.first() else {
return Err("OpenClaw 当前平台配置路径不可用".to_string());
};
let prefix = format!("{MANAGED_AGENT_PROVIDER_ID}/");
let updated = update_agent_json5_file(path, "OpenClaw 配置", |root| {
let updated = prepare_agent_json5_removal(path, "OpenClaw 配置", |root| {
let mut changed = false;
if let Some(models) = root
.get_mut("models")
@@ -1457,13 +1490,10 @@ pub(crate) fn remove_openclaw_managed_configuration(
}
changed
})?;
Ok(updated.then(|| path_to_string(path)).into_iter().collect())
Ok(updated.into_iter().collect())
}
#[cfg(test)]
pub(crate) fn remove_hermes_managed_configuration(
paths: &[PathBuf],
) -> Result<Vec<String>, String> {
pub(crate) fn prepare_hermes_managed_removal(paths: &[PathBuf]) -> Result<Images, String> {
let Some(path) = paths.first() else {
return Err("Hermes 当前平台配置路径不可用".to_string());
};
@@ -1519,37 +1549,35 @@ pub(crate) fn remove_hermes_managed_configuration(
if !changed {
return Ok(Vec::new());
}
if root.is_empty() {
fs::remove_file(path).map_err(|error| {
format!("删除空的 Hermes 配置失败 {}: {error}", path_to_string(path))
})?;
let bytes = if root.is_empty() {
None
} else {
let rendered = render_updated_core_yaml(&mut document, updated)?;
write_bytes_directly(path, rendered.as_bytes())?;
}
Ok(vec![path_to_string(path)])
Some(render_updated_core_yaml(&mut document, updated)?.into_bytes())
};
Ok(vec![(path.clone(), bytes)])
}
#[cfg(test)]
pub(crate) fn remove_agent_managed_configuration(
pub(crate) fn prepare_agent_managed_removal(
client: AgentClient,
paths: &[PathBuf],
) -> Result<Vec<String>, String> {
port: u16,
) -> Result<Images, String> {
match client {
AgentClient::ClaudeCode => remove_claude_code_managed_configuration(paths),
AgentClient::ClaudeDesktop => remove_claude_desktop_managed_configuration(paths),
AgentClient::Codex => remove_codex_managed_configuration(paths),
AgentClient::OpenCode => remove_opencode_managed_configuration(paths),
AgentClient::OpenClaw => remove_openclaw_managed_configuration(paths),
AgentClient::Hermes => remove_hermes_managed_configuration(paths),
AgentClient::DeepSeekHarness => remove_deepseek_harness_managed_configuration(paths),
AgentClient::ZCode => remove_zcode_managed_configuration(paths),
AgentClient::KimiCode => remove_kimi_code_managed_configuration(paths),
AgentClient::GrokBuild => remove_grok_build_managed_configuration(paths),
AgentClient::ClaudeCode => {
prepare_claude_code_managed_removal(paths, &managed_core_loopback_origin(port))
}
AgentClient::ClaudeDesktop => prepare_claude_desktop_managed_removal(paths),
AgentClient::Codex => prepare_codex_managed_removal(paths),
AgentClient::OpenCode => prepare_opencode_managed_removal(paths),
AgentClient::OpenClaw => prepare_openclaw_managed_removal(paths),
AgentClient::Hermes => prepare_hermes_managed_removal(paths),
AgentClient::DeepSeekHarness => prepare_deepseek_harness_managed_removal(paths),
AgentClient::ZCode => prepare_zcode_managed_removal(paths),
AgentClient::KimiCode => prepare_kimi_code_managed_removal(paths),
AgentClient::GrokBuild => prepare_grok_build_managed_removal(paths),
}
}
#[cfg(test)]
pub(crate) fn remove_deepseek_harness_settings_fields(root: &mut serde_norway::Mapping) -> bool {
let mut changed = false;
let mut remove_llm = false;
@@ -1588,10 +1616,9 @@ pub(crate) fn remove_deepseek_harness_settings_fields(root: &mut serde_norway::M
changed
}
#[cfg(test)]
pub(crate) fn remove_deepseek_harness_managed_configuration(
pub(crate) fn prepare_deepseek_harness_managed_removal(
paths: &[PathBuf],
) -> Result<Vec<String>, String> {
) -> Result<Images, String> {
if paths.len() != 2 {
return Err("DeepSeek Harness 配置路径数量无效".to_string());
}
@@ -1610,13 +1637,10 @@ pub(crate) fn remove_deepseek_harness_managed_configuration(
)?;
if changed {
let root = parse_agent_yaml_mapping(Some(&rendered), "DeepSeek Harness settings")?;
if root.is_empty() {
fs::remove_file(&paths[0])
.map_err(|error| format!("删除空的 DeepSeek Harness settings 失败: {error}"))?;
} else {
write_bytes_directly(&paths[0], rendered.as_bytes())?;
}
changed_paths.push(path_to_string(&paths[0]));
changed_paths.push((
paths[0].clone(),
(!root.is_empty()).then(|| rendered.into_bytes()),
));
}
}
if paths[1].is_file() {
@@ -1636,14 +1660,11 @@ pub(crate) fn remove_deepseek_harness_managed_configuration(
)?;
if changed {
let root = parse_agent_yaml_mapping(Some(&rendered), "DeepSeek Harness credentials")?;
if deepseek_harness_credentials_document_is_empty(&root) {
fs::remove_file(&paths[1]).map_err(|error| {
format!("删除空的 DeepSeek Harness credentials 失败: {error}")
})?;
} else {
write_deepseek_harness_file(&paths[1], rendered.as_bytes(), true)?;
}
changed_paths.push(path_to_string(&paths[1]));
changed_paths.push((
paths[1].clone(),
(!deepseek_harness_credentials_document_is_empty(&root))
.then(|| rendered.into_bytes()),
));
}
}
Ok(changed_paths)
+9 -2
View File
@@ -179,7 +179,12 @@ fn commit_config_transaction(
}
let mut previous = before.clone();
let mut target = after.clone();
if client == "deepseek-harness" && matches!(source, "template" | "restore") {
if source == "clear-integration" {
let state_path = agent_state_path(paths)?;
previous.push((state_path.clone(), read_agent_bytes(&state_path)?));
target.push((state_path, None));
}
if client == "deepseek-harness" && matches!(source, "template" | "restore" | "clear-integration") {
let state_path = deepseek_harness_catalog_state_path(paths)?;
let state_before = read_agent_bytes(&state_path)?;
let state_after = if source == "template" {
@@ -193,7 +198,9 @@ fn commit_config_transaction(
if client == "claude-desktop" {
let state_path = desktop_mapping_path(paths)?;
let state_before = read_agent_bytes(&state_path)?;
let next = if source == "restore" {
let next = if source == "clear-integration" {
None
} else if source == "restore" {
mappings
} else {
mappings.or_else(|| matching_desktop_mappings(paths, after))
-1
View File
@@ -1401,7 +1401,6 @@ struct PreparedAgentModels {
codex_catalog: Option<String>,
}
#[cfg(test)]
type FileSnapshot = (PathBuf, Option<Vec<u8>>);
#[cfg(test)]
type AgentRecordExtension = (AgentModificationRecord, Vec<FileSnapshot>);
+2 -2
View File
@@ -1648,7 +1648,7 @@ fn deepseek_harness_removal_preserves_other_versioned_credentials() {
)
.unwrap();
let changed = remove_deepseek_harness_managed_configuration(&paths).unwrap();
let changed = remove_agent_managed_configuration(AgentClient::DeepSeekHarness, &paths).unwrap();
let value: serde_norway::Value =
serde_norway::from_str(&fs::read_to_string(&paths[1]).unwrap()).unwrap();
@@ -1677,7 +1677,7 @@ fn deepseek_harness_removal_deletes_semantically_empty_credentials_file() {
)
.unwrap();
let changed = remove_deepseek_harness_managed_configuration(&paths).unwrap();
let changed = remove_agent_managed_configuration(AgentClient::DeepSeekHarness, &paths).unwrap();
assert_eq!(changed, vec![path_to_string(&paths[1])]);
assert!(!paths[1].exists());
+4
View File
@@ -1139,6 +1139,10 @@ export const jaOverrides = {
"agents.clear.description": "Codex の auth.json と config.toml を削除します。自動バックアップは作成しません。既存の手動バックアップから全ファイルと独自設定を復元できます。",
'agents.clear.confirm': '設定を消去',
'agents.clear.success': 'Codex 設定を消去しました。',
'agents.clearIntegration.title': '{name} の接続設定を解除',
'agents.clearIntegration.description': 'CPA 接続設定を削除し、その他の設定は保持します。解除後はエージェントを再起動してください。',
'agents.clearIntegration.confirm': '接続設定の解除を確認',
'agents.clearIntegration.success': 'CPA 接続設定を解除しました。{name} を再起動してください。',
'apiAccess.compatibleName': 'OpenAI 互換 {number}',
'apiAccess.error.headerMissingColon': 'カスタムヘッダーの {number} 行目にコロンがありません',
'apiAccess.error.headerInvalid': 'カスタムヘッダーの {number} 行目の形式が無効です',
+4
View File
@@ -1139,6 +1139,10 @@ export const en: Record<MessageKey, string> = {
"agents.clear.description": "This deletes auth.json and config.toml from the Codex configuration directory. No automatic backup is created. An existing manual backup can restore the entire file set and custom contents.",
'agents.clear.confirm': 'Clear Configuration',
'agents.clear.success': 'The Codex configuration has been cleared.',
'agents.clearIntegration.title': 'Clear {name} integration',
'agents.clearIntegration.description': 'Remove CPA integration settings and keep other settings. Restart the agent afterward.',
'agents.clearIntegration.confirm': 'Confirm clear integration',
'agents.clearIntegration.success': 'CPA integration cleared. Restart {name}.',
'apiAccess.compatibleName': 'OpenAI Compatible {number}',
'apiAccess.error.headerMissingColon': 'Custom header line {number} is missing a colon',
'apiAccess.error.headerInvalid': 'Custom header line {number} is invalid',
+4
View File
@@ -1138,6 +1138,10 @@ export const zhCN = {
"agents.clear.description": "这会删除 Codex 配置目录中的 auth.json 和 config.toml,不会自动备份。已有手动备份可恢复整组原始文件及自定义内容。",
'agents.clear.confirm': '确认清空配置',
'agents.clear.success': 'Codex 配置已清空。',
'agents.clearIntegration.title': '清除 {name} 接入',
'agents.clearIntegration.description': '移除 CPA 接入配置并保留其他设置。清除后请重启智能体。',
'agents.clearIntegration.confirm': '确认清除接入',
'agents.clearIntegration.success': '已清除 CPA 接入,请重启 {name}。',
'apiAccess.compatibleName': 'OpenAI 兼容 {number}',
'apiAccess.error.headerMissingColon': '自定义请求头第 {number} 行缺少冒号',
'apiAccess.error.headerInvalid': '自定义请求头第 {number} 行格式无效',
+3 -3
View File
@@ -112,10 +112,10 @@ export function AgentConfigManagementPanel({
</button>
</div>
</section> : null}
{codex ? <section className="agent-management-row">
<div><h3>{t('agents.modify.clear')}</h3><p>{t('agents.management.clearDescription')}</p></div>
{!pi ? <section className="agent-management-row">
<div><h3>{t(codex ? 'agents.modify.clear' : 'agents.nativeOAuth.restore')}</h3><p>{t(codex ? 'agents.management.clearDescription' : 'agents.clearIntegration.description')}</p></div>
<div className="agent-management-actions">
<button type="button" className="danger-button" onClick={onClear} disabled={busy}><Trash2 size={16} />{t('agents.modify.clear')}</button>
<button type="button" className="danger-button" onClick={onClear} disabled={busy || (!codex && !canClearIntegration)}><Trash2 size={16} />{t(codex ? 'agents.modify.clear' : 'agents.nativeOAuth.restore')}</button>
</div>
</section> : null}
{pi ? <section className="agent-management-row">
+33 -15
View File
@@ -1032,8 +1032,8 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
const connectionState = activeStatus?.connectionState ?? 'invalid';
const appliedModel = activeStatus?.currentModel ?? activeStatus?.appliedModel ?? '';
const canCloseCodexConfiguration = selected === 'codex' && !nativeOauth
&& connectionState === 'configured';
const canCloseConfiguration = !isPiClient && !nativeOauth
&& (connectionState === 'configured' || (selected !== 'codex' && connectionState === 'needs-update'));
const configurationActionLabel = t(nativeOauth ? 'agents.modify.update'
: connectionState === 'not-configured' ? selected === 'codex' ? 'agents.modify.apply' : 'agents.modify.connect'
: connectionState === 'needs-update' ? 'agents.modify.repair' : 'agents.modify.update');
@@ -1307,10 +1307,14 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
setConfigurationNotice('');
setClearNotice('');
try {
await invoke<AgentConfigActionResult>('close_codex_config_modification');
await removeSelectedConfiguration();
clearPendingChanges();
if (isClaudeModelMappingClient) claudeModelMappingsDirtyRef.current[selected] = false;
await reloadStatusesAfterAction();
setConfigurationNotice(t('agents.modify.closed'));
setModelSelectionError('');
setModelError('');
setConfigurationNotice(selected === 'codex' ? t('agents.modify.closed')
: t('agents.clearIntegration.success', { name: activeDefinition.name }));
} catch (cause) {
setConfigurationError(String(cause));
} finally {
@@ -1495,15 +1499,29 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
}
};
const clearCodexConfiguration = async () => {
const removeSelectedConfiguration = () => selected === 'codex'
? invoke<AgentConfigActionResult>('close_codex_config_modification')
: invoke<AgentConfigActionResult>('set_agent_config_enabled', {
client: selected, model: '', enabled: false, forceRestore: false,
claudeCodeModelMappings: null, claudeDesktopModelMappings: null,
});
const clearConfiguration = async () => {
setBusyAction('clear');
setConfigurationError('');
setConfigurationNotice('');
setClearError('');
setClearNotice('');
try {
await invoke<string[]>('clear_codex_config');
if (selected === 'codex') await invoke<string[]>('clear_codex_config');
else await removeSelectedConfiguration();
clearPendingChanges();
if (isClaudeModelMappingClient) claudeModelMappingsDirtyRef.current[selected] = false;
setClearConfirmOpen(false);
setClearNotice(t('agents.clear.success'));
setModelSelectionError('');
setModelError('');
setClearNotice(selected === 'codex' ? t('agents.clear.success')
: t('agents.clearIntegration.success', { name: activeDefinition.name }));
await reloadStatusesAfterAction();
setOauthConfigurationDraft(null);
} catch (requestError) {
@@ -1738,9 +1756,9 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
</button>
) : null;
const closeConfigurationButton = canCloseCodexConfiguration ? (
const closeConfigurationButton = canCloseConfiguration ? (
<button type="button" className="secondary-button agent-close-configuration"
title={t('agents.modify.closeHint')}
title={t(selected === 'codex' ? 'agents.modify.closeHint' : 'agents.clearIntegration.description')}
disabled={busy || configurationWriteBlocked}
onClick={() => void closeConfigurationChanges()}>
{busyAction === 'close-config' ? <LoaderCircle size={16} className="spin" /> : null}
@@ -1871,7 +1889,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
<div className="agent-save-bar">
{configurationFeedback}
<div className={`agent-save-actions${selected === 'codex' ? ' agent-codex-save-actions' : ''}`}>
<div className={`agent-save-actions${!isPiClient ? ' agent-codex-save-actions' : ''}`}>
{closeConfigurationButton}
<button
type="button"
@@ -2178,7 +2196,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
<div className="agent-save-bar">
{configurationFeedback}
{selected === 'claude-desktop' && !activeStatus?.claudeDesktopModelMappings ? <p className="agent-inline-message warning">{t('agents.backup.mappingRequired')}</p> : null}
<div className={`agent-save-actions${selected === 'codex' ? ' agent-codex-save-actions' : ''}`}>
<div className={`agent-save-actions${!isPiClient ? ' agent-codex-save-actions' : ''}`}>
{closeConfigurationButton}
<button type="button" className="primary-button" onClick={applySelectedConfiguration}
disabled={busy || !canEnable || configurationWriteBlocked}
@@ -2513,17 +2531,17 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
<div className="config-dialog-backdrop">
<section className="config-dialog agent-restore-dialog" role="alertdialog" aria-modal="true" aria-labelledby="agent-clear-title">
<div className="config-dialog-heading">
<div><AlertTriangle size={19} /><h2 id="agent-clear-title">{t('agents.clear.title')}</h2></div>
<div><AlertTriangle size={19} /><h2 id="agent-clear-title">{selected === 'codex' ? t('agents.clear.title') : t('agents.clearIntegration.title', { name: activeDefinition.name })}</h2></div>
</div>
<p>{t('agents.clear.description')}</p>
<p>{t(selected === 'codex' ? 'agents.clear.description' : 'agents.clearIntegration.description')}</p>
{clearError ? (
<MessageNotice message={clearError} onDismiss={() => setClearError('')} />
) : null}
<div className="config-dialog-actions two-actions">
<button type="button" className="secondary-button" onClick={closeClearConfirmation} disabled={busy}>{t('common.cancel')}</button>
<button type="button" className="danger-button" onClick={() => void clearCodexConfiguration()} disabled={busy}>
<button type="button" className="danger-button" onClick={() => void clearConfiguration()} disabled={busy}>
{busyAction === 'clear' ? <LoaderCircle size={16} className="spin" /> : <Trash2 size={16} />}
{t('agents.clear.confirm')}
{t(selected === 'codex' ? 'agents.clear.confirm' : 'agents.clearIntegration.confirm')}
</button>
</div>
</section>
+39
View File
@@ -80,6 +80,45 @@ const path = require('node:path');
await open(mode + 'client=kimi-code');
assert.equal(await button('重启 App').count(), 0);
for (const client of ['claude-code', 'claude-desktop', 'opencode', 'openclaw', 'hermes', 'deepseek-harness', 'zcode', 'kimi-code', 'grok-build']) {
await open(mode + 'client=' + client);
assert.ok(await button('关闭配置修改').isEnabled());
await tab('配置管理').click();
assert.ok(await button('清除接入').isEnabled());
await button('清除接入').click();
const dialog = page.getByRole('alertdialog');
assert.match(await dialog.innerText(), /保留其他设置/);
await button('取消').click();
assert.equal((await calls('set_agent_config_enabled')).length, 0);
await button('清除接入').click();
await button('确认清除接入').click();
await dialog.waitFor({ state: 'detached' });
assert.deepEqual((await calls('set_agent_config_enabled')).map(call => call.args), [{
client, model: '', enabled: false, forceRestore: false,
claudeCodeModelMappings: null, claudeDesktopModelMappings: null,
}]);
assert.equal((await calls('clear_codex_config')).length, 0);
await tab('基础配置').click();
assert.equal(await button('关闭配置修改').count(), 0);
assert.ok(await button('一键接入').isVisible());
}
await open(mode + 'client=claude-code&state=needs-update');
assert.ok(await button('关闭配置修改').isEnabled());
await open(mode + 'client=claude-code&no-core&defer-clear');
await button('关闭配置修改').click();
await page.waitForFunction(() => !!window.fixtureFinishClear);
assert.ok(await tab('配置管理').isDisabled());
await page.evaluate(() => window.fixtureFinishClear());
await button('关闭配置修改').waitFor({ state: 'detached' });
await page.getByText('已清除 CPA 接入,请重启 Claude Code。', { exact: true }).waitFor();
await open(mode + 'client=claude-code&fail-clear');
await tab('配置管理').click();
await button('清除接入').click();
await button('确认清除接入').click();
await page.getByText(/模拟清除失败/).waitFor();
assert.ok(await page.getByRole('alertdialog').isVisible());
await button('取消').click();
await open(mode + 'client=pi&update');
assert.equal(await button('卸载 Pi 插件').count(), 0);
await tab('配置管理').click();
+3 -2
View File
@@ -215,10 +215,11 @@ const base = 'http://127.0.0.1:1421';
for (const client of ['opencode', 'zcode']) {
await page.goto(`${base}/tests/fixtures/agent-backups.html?reset-selections&client=${client}`, { waitUntil: 'domcontentloaded' });
await page.getByRole('button', { name: '更新配置', exact: true }).waitFor();
assert.equal(await close().count(), 0, 'Other clients keep their existing controls');
assert.equal(await close().count(), 1, 'Other managed clients can also close configuration');
assert.equal(await restore().count(), 0);
await manage();
assert.equal(await restore().count(), 0, 'Other clients have no official Codex login action in management');
assert.equal(await restore().count(), 1, 'Other clients can clear CPA integration in management');
assert.equal(await page.locator('#agent-clear-integration').count(), 0, 'The official Codex login action remains Codex-only');
}
assert.deepEqual(errors, []);
+6
View File
@@ -70,6 +70,12 @@ mockIPC(async (cmd,args:any) => {
if(cmd==='stop_deepseek_harness_process')return harnessStatus={running:false,pid:null,mode:null};
if(cmd==='launch_agent')return null;
if(cmd==='clear_codex_config') { currentModel=null; return []; }
if(cmd==='set_agent_config_enabled' && !args.enabled) {
if(params.has('fail-clear'))throw new Error('模拟清除失败,已回滚');
if(params.has('defer-clear'))await new Promise<void>(resolve=>{(window as any).fixtureFinishClear=resolve;});
currentModel=null;delete currentMappings[args.client];
return {outcome:'updated',enabled:false,model:null,changedFiles:[],conflictFiles:[]};
}
if(cmd==='uninstall_pi_provider')return null;
if(['update_agent_config','repair_pi_provider','install_pi_provider','update_pi_provider'].includes(cmd)) {
count++;if(params.has('fail-apply')&&count===1)throw new Error('模拟配置写入失败');