diff --git a/src-tauri/src/agents.rs b/src-tauri/src/agents.rs index c6d310e..0ec6482 100644 --- a/src-tauri/src/agents.rs +++ b/src-tauri/src/agents.rs @@ -5,12 +5,16 @@ use super::*; mod commands; mod configuration; mod discovery; -mod history; +mod transactions; +mod backups; +mod templates; mod launch; mod state; pub(crate) use commands::*; pub(crate) use configuration::*; pub(crate) use discovery::*; -pub(crate) use history::*; +pub(crate) use transactions::*; +pub(crate) use backups::*; +pub(crate) use templates::*; pub(crate) use launch::*; pub(crate) use state::*; diff --git a/src-tauri/src/agents/backups.rs b/src-tauri/src/agents/backups.rs new file mode 100644 index 0000000..d9e3a90 --- /dev/null +++ b/src-tauri/src/agents/backups.rs @@ -0,0 +1,523 @@ +//! Explicit, immutable backups. Legacy history and dated backups are never read or migrated. +use super::*; +use serde_json::Value; +use std::sync::atomic::{AtomicU64, Ordering}; + +mod restore; +pub(crate) use restore::*; + +static SEQUENCE: AtomicU64 = AtomicU64::new(0); + +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct BackupFile { + path: PathBuf, + bytes: Option>, + hash: String, +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct BackupVersion { + version: u8, + id: String, + client: String, + created_at: String, + files: Vec, + mappings: Option, +} + +#[derive(Serialize, Deserialize)] +struct BackupPackage { + payload: BackupVersion, + checksum: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct BackupFileSummary { + path: String, + exists: Option, + size: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct BackupSummary { + pub(crate) id: String, + created_at: Option, + file_count: usize, + location: String, + files: Vec, + restorable: bool, + error: Option, +} + +#[derive(Serialize)] +pub(crate) struct BackupList { + versions: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct BackupDifference { + pub(crate) file: String, + pub(crate) field: String, + pub(crate) before: String, + pub(crate) after: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct BackupPreview { + pub(crate) revision: String, + pub(crate) files: Vec, + pub(crate) differences: Vec, +} + +pub(crate) fn agent_data_directory(paths: &[PathBuf]) -> Result { + #[cfg(test)] + { + // Tests cannot reach the running application's data or real client configuration. + let temp = std::env::temp_dir(); + let relative = paths + .first() + .ok_or("没有配置路径")? + .strip_prefix(&temp) + .map_err(|_| "测试配置必须位于临时目录")?; + let directory = temp.join( + relative + .components() + .next() + .ok_or("缺少测试目录")? + .as_os_str(), + ); + assert!(paths.iter().all(|p| p.starts_with(&directory))); + return Ok(directory.join("cpa-data")); + } + #[cfg(not(test))] + { + let _ = paths; + core_base_dir() + } +} + +fn path_identity(paths: &[PathBuf]) -> String { + sha256_bytes(&serde_json::to_vec(paths).expect("paths serialize")) +} + +fn backup_directory(client: &str, paths: &[PathBuf]) -> Result { + // Only client IDs returned by config_paths reach here; validate again for internal callers. + if client != PI_AGENT_ID { + AgentClient::parse(client)?; + } + Ok(agent_data_directory(paths)? + .join("backups/agents") + .join(client) + .join(path_identity(paths))) +} + +fn valid_version_id(id: &str) -> bool { + !id.is_empty() && id.len() <= 100 && id.bytes().all(|c| c.is_ascii_digit() || c == b'-') +} + +fn version_path(client: &str, paths: &[PathBuf], id: &str) -> Result { + if !valid_version_id(id) { + return Err("备份版本编号无效".into()); + } + Ok(backup_directory(client, paths)?.join(format!("{id}.json"))) +} + +fn make_version(client: &str, paths: &[PathBuf], images: &Images) -> BackupVersion { + let now = chrono::Utc::now(); + BackupVersion { + version: 1, + id: format!( + "{}-{}-{}", + now.format("%Y%m%d%H%M%S%f"), + std::process::id(), + SEQUENCE.fetch_add(1, Ordering::Relaxed) + ), + client: client.into(), + created_at: now.to_rfc3339(), + files: images + .iter() + .map(|(path, bytes)| BackupFile { + path: path.clone(), + bytes: bytes.clone(), + hash: image_hash(bytes.as_deref()), + }) + .collect(), + mappings: if client == "claude-desktop" { + matching_desktop_mappings(paths, images) + } else { + None + }, + } +} + +fn write_version(paths: &[PathBuf], version: &BackupVersion) -> Result<(), String> { + let path = version_path(&version.client, paths, &version.id)?; + validate_config_path(&path)?; + let parent = path.parent().ok_or("备份目录无效")?; + fs::create_dir_all(parent).map_err(|_| "创建手动备份目录失败")?; + validate_config_path(&path)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(parent, fs::Permissions::from_mode(0o700)) + .map_err(|_| "设置备份目录权限失败")?; + } + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let payload = serde_json::to_vec(version).map_err(|_| "生成备份失败")?; + let package = BackupPackage { + payload: version.clone(), + checksum: sha256_bytes(&payload), + }; + let bytes = serde_json::to_vec(&package).map_err(|_| "生成备份失败")?; + let mut file = options + .open(&path) + .map_err(|_| "创建手动备份失败,请检查备份目录权限")?; + if file + .write_all(&bytes) + .and_then(|_| file.sync_all()) + .is_err() + { + drop(file); + let _ = fs::remove_file(&path); + return Err("保存手动备份失败".into()); + } + drop(file); + if fs::read(&path).ok().as_deref() != Some(bytes.as_slice()) { + let _ = fs::remove_file(&path); + return Err("手动备份写后校验失败".into()); + } + Ok(()) +} + +fn read_version(client: &str, paths: &[PathBuf], id: &str) -> Result { + let path = version_path(client, paths, id)?; + let bytes = read_agent_bytes(&path)?.ok_or("备份文件缺失")?; + let package: BackupPackage = + serde_json::from_slice(&bytes).map_err(|_| "备份包损坏,无法恢复,可删除此版本")?; + let version = package.payload; + if package.checksum != sha256_bytes(&serde_json::to_vec(&version).map_err(|_| "备份校验失败")?) + || version.version != 1 + || version.client != client + || version.id != id + || version.files.len() != paths.len() + || version.files.iter().zip(paths).any(|(file, path)| { + file.path != *path || file.hash != image_hash(file.bytes.as_deref()) + }) + { + return Err("备份包路径或内容校验失败,无法恢复,可删除此版本".into()); + } + Ok(version) +} + +fn backup_images(version: &BackupVersion) -> Images { + version + .files + .iter() + .map(|f| (f.path.clone(), f.bytes.clone())) + .collect() +} + +fn validate_restorable(version: &BackupVersion) -> Result<(), String> { + let images = backup_images(version); + validate_client_config_images(&version.client, &images) + .map_err(|_| "备份含有无法解析的配置,已保留原文但不可恢复".to_string())?; + if version.client == "claude-desktop" + && desktop_profile_needs_mapping(&images)? + && version.mappings.is_none() + { + return Err("备份缺少可靠的 Claude Desktop 模型映射,请重新选择模型后创建备份".into()); + } + Ok(()) +} + +fn file_summaries(images: &Images) -> Vec { + images + .iter() + .map(|(path, bytes)| BackupFileSummary { + path: path_to_string(path), + exists: Some(bytes.is_some()), + size: bytes.as_ref().map(Vec::len), + }) + .collect() +} + +fn backup_summary(client: &str, paths: &[PathBuf], id: &str) -> Result { + let location = path_to_string(&version_path(client, paths, id)?); + let (created_at, files, error) = match read_version(client, paths, id) { + Ok(version) => ( + Some(version.created_at.clone()), + file_summaries(&backup_images(&version)), + validate_restorable(&version).err(), + ), + Err(error) => ( + None, + paths + .iter() + .map(|p| BackupFileSummary { + path: path_to_string(p), + exists: None, + size: None, + }) + .collect(), + Some(error), + ), + }; + Ok(BackupSummary { + id: id.into(), + created_at, + file_count: paths.len(), + location, + files, + restorable: error.is_none(), + error, + }) +} + +pub(crate) fn create_backup(client: &str, home: &Path) -> Result { + let paths = config_paths(client, home)?; + let images = config_images(&paths)?; + let state_revision = mapping_revision(client, &paths)?; + let version = make_version(client, &paths, &images); + if config_images(&paths)? != images || mapping_revision(client, &paths)? != state_revision { + return Err("备份期间配置发生变化,请重试".into()); + } + // Raw bytes are deliberately saved even if parsing fails. + write_version(&paths, &version)?; + backup_summary(client, &paths, &version.id) +} + +pub(crate) fn list_backups(client: &str, home: &Path) -> Result { + let paths = config_paths(client, home)?; + let directory = backup_directory(client, &paths)?; + validate_config_path(&directory.join(".path-check"))?; + let entries = match fs::read_dir(&directory) { + Ok(entries) => entries, + Err(e) if e.kind() == io::ErrorKind::NotFound => { + return Ok(BackupList { + versions: Vec::new(), + }) + } + Err(_) => return Err("读取手动备份目录失败".into()), + }; + let mut versions = Vec::new(); + for entry in entries { + let path = entry.map_err(|_| "读取手动备份目录失败")?.path(); + if path.extension().and_then(|e| e.to_str()) != Some("json") { + continue; + } + if let Some(id) = path + .file_stem() + .and_then(|v| v.to_str()) + .filter(|id| valid_version_id(id)) + { + versions.push(backup_summary(client, &paths, id)?); + } + } + versions.sort_by(|a, b| b.id.cmp(&a.id)); + Ok(BackupList { versions }) +} + +pub(crate) fn delete_backup(client: &str, home: &Path, id: &str) -> Result<(), String> { + let paths = config_paths(client, home)?; + let path = version_path(client, &paths, id)?; + // Check parents, then unlink only this entry. A damaged or symlinked package can be deleted safely. + validate_config_path(&path.parent().ok_or("备份目录无效")?.join(".path-check"))?; + fs::remove_file(&path).map_err(|_| "删除手动备份失败,请刷新列表后重试".to_string()) +} + +fn preview( + client: &str, + paths: &[PathBuf], + id: &str, +) -> Result<(BackupPreview, Images, Images), String> { + let package = read_agent_bytes(&version_path(client, paths, id)?)?.ok_or("备份文件缺失")?; + let version = read_version(client, paths, id)?; + validate_restorable(&version)?; + let current = config_images(paths)?; + let after = backup_images(&version); + // Entire package hash binds preview to bytes, existence, paths and Desktop mappings. + if read_agent_bytes(&version_path(client, paths, id)?)?.as_deref() != Some(&package) { + return Err("备份在预览期间发生变化,请重新预览".into()); + } + let revision = sha256_bytes( + format!( + "{}:{}:{}", + image_revision(¤t), + sha256_bytes(&package), + mapping_revision(client, paths)? + ) + .as_bytes(), + ); + let mut differences = current + .iter() + .zip(&after) + .filter(|((_, a), (_, b))| a != b) + .map(|((path, bytes), (_, next))| BackupDifference { + file: path_to_string(path), + field: "file".into(), + before: if bytes.is_some() { + "present" + } else { + "missing" + } + .into(), + after: if next.is_some() { "replace" } else { "remove" }.into(), + }) + .collect::>(); + if client == "claude-desktop" && matching_desktop_mappings(paths, ¤t) != version.mappings + { + differences.push(BackupDifference { + file: path_to_string(&desktop_mapping_path(paths)?), + field: "modelMappings".into(), + before: "current".into(), + after: "backup".into(), + }); + } + Ok(( + BackupPreview { + revision, + files: file_summaries(&after), + differences, + }, + current, + after, + )) +} + +#[derive(Serialize, Deserialize)] +struct DesktopMappingState { + version: u8, + profile_models_hash: String, + mappings: ClaudeDesktopModelMappings, +} + +pub(crate) fn desktop_mapping_path(paths: &[PathBuf]) -> Result { + Ok(agent_data_directory(paths)? + .join("agents/claude-desktop") + .join(path_identity(paths)) + .join("current-mapping.json")) +} + +fn profile_models_hash(images: &Images) -> Option { + let (path, bytes) = images.get(2)?; + let profile = parse(path, text(bytes.as_deref()).ok()?).ok()?; + Some(sha256_bytes( + &serde_json::to_vec(profile.get("inferenceModels")?).ok()?, + )) +} + +pub(crate) fn desktop_mapping_bytes( + images: &Images, + mappings: Option<&ClaudeDesktopModelMappings>, +) -> Result>, String> { + mappings + .map(|mappings| { + let profile_models_hash = + profile_models_hash(images).ok_or("Claude Desktop 模型配置无效")?; + serde_json::to_vec(&DesktopMappingState { + version: 1, + profile_models_hash, + mappings: mappings.clone(), + }) + .map_err(|_| "生成当前模型映射失败".into()) + }) + .transpose() +} + +pub(crate) fn matching_desktop_mappings( + paths: &[PathBuf], + images: &Images, +) -> Option { + let raw = read_agent_bytes(&desktop_mapping_path(paths).ok()?).ok()??; + let state: DesktopMappingState = serde_json::from_slice(&raw).ok()?; + (state.version == 1 && Some(state.profile_models_hash) == profile_models_hash(images)) + .then_some(state.mappings) +} + +pub(crate) fn current_desktop_mappings(home: &Path) -> Option { + let paths = config_paths("claude-desktop", home).ok()?; + matching_desktop_mappings(&paths, &config_images(&paths).ok()?) +} + +pub(crate) fn mapping_revision(client: &str, paths: &[PathBuf]) -> Result { + if client != "claude-desktop" { + return Ok(String::new()); + } + Ok(image_hash( + read_agent_bytes(&desktop_mapping_path(paths)?)?.as_deref(), + )) +} + +#[tauri::command] +pub(crate) fn create_agent_config_backup( + app: tauri::AppHandle, + client: String, +) -> Result { + let home = app.path().home_dir().map_err(|_| "无法获取用户目录")?; + let _guard = AGENT_CONFIG_FILE_LOCK + .lock() + .map_err(|_| "配置文件锁已损坏")?; + create_backup(&client, &home) +} + +#[tauri::command] +pub(crate) fn list_agent_config_backups( + app: tauri::AppHandle, + client: String, +) -> Result { + let home = app.path().home_dir().map_err(|_| "无法获取用户目录")?; + let _guard = AGENT_CONFIG_FILE_LOCK + .lock() + .map_err(|_| "配置文件锁已损坏")?; + list_backups(&client, &home) +} + +#[tauri::command] +pub(crate) fn delete_agent_config_backup( + app: tauri::AppHandle, + client: String, + id: String, +) -> Result<(), String> { + let home = app.path().home_dir().map_err(|_| "无法获取用户目录")?; + let _guard = AGENT_CONFIG_FILE_LOCK + .lock() + .map_err(|_| "配置文件锁已损坏")?; + delete_backup(&client, &home, &id) +} + +#[cfg(test)] +mod tests; + +#[cfg(test)] +pub(crate) fn test_backup_count(client: AgentClient, home: &Path) -> usize { + list_backups(client.id(), home).unwrap().versions.len() +} + +#[cfg(test)] +pub(crate) fn test_restore_backup(client: AgentClient, home: &Path, id: &str) { + let paths = config_paths(client.id(), home).unwrap(); + let version = read_version(client.id(), &paths, id).unwrap(); + let (_, before, after) = preview(client.id(), &paths, id).unwrap(); + commit_config_with_mappings( + client.id(), + &paths, + &before, + &after, + "restore", + None, + version.mappings, + ) + .unwrap(); +} diff --git a/src-tauri/src/agents/history/restore.rs b/src-tauri/src/agents/backups/restore.rs similarity index 79% rename from src-tauri/src/agents/history/restore.rs rename to src-tauri/src/agents/backups/restore.rs index fc40d56..80db2a1 100644 --- a/src-tauri/src/agents/history/restore.rs +++ b/src-tauri/src/agents/backups/restore.rs @@ -7,11 +7,11 @@ struct CoreRestore { pub(crate) struct RestorePlan { paths: Vec, - pub(crate) preview: HistoryPreview, + pub(crate) preview: BackupPreview, local_revision: String, before: Images, after: Images, - version: HistoryVersion, + version: BackupVersion, core: Option, } @@ -19,7 +19,7 @@ fn local_restore_plan(client: &str, home: &Path, id: &str) -> Result Result Result>(); let routes = [ CLAUDE_DESKTOP_OPUS_MODEL_ID, CLAUDE_DESKTOP_SONNET_MODEL_ID, CLAUDE_DESKTOP_HAIKU_MODEL_ID, ]; - if !names - .iter() - .any(|name| routes.iter().any(|route| route.eq_ignore_ascii_case(name))) - { - return Ok(None); - } let mappings = plan.version.mappings.as_ref().ok_or( - "此历史版本缺少 Claude Desktop 模型映射,无法安全恢复内核路由,请重新配置模型", + "此备份版本缺少 Claude Desktop 模型映射,无法安全恢复内核路由,请重新配置模型", )?; Ok(Some( routes @@ -98,7 +95,7 @@ fn attach_core_restore( let old = source(&original); let new = source(&target); if old != new { - plan.preview.differences.push(HistoryDifference { + plan.preview.differences.push(BackupDifference { file: "CPA/config.yaml".into(), field: format!("modelMappings.{route}"), before: old.unwrap_or_else(|| "—".into()), @@ -127,8 +124,10 @@ pub(crate) async fn prepare_restore_plan( ) -> Result { let mut plan = local_restore_plan(client, home, id)?; if let Some(models) = desktop_restore_models(&plan)? { - let mappings = plan.version.mappings.as_ref().ok_or("缺少历史模型映射")?; - let before = fetch_management_config_yaml(config).await?; + let mappings = plan.version.mappings.as_ref().ok_or("缺少备份模型映射")?; + let before = fetch_management_config_yaml(config) + .await + .map_err(agent_core_error)?; let after = match ensure_claude_desktop_model_aliases_in_yaml(&before, mappings, &models) { Ok(after) => after, Err(_) => { @@ -138,10 +137,11 @@ pub(crate) async fn prepare_restore_plan( mappings, &models, &definitions, - )? + ) + .map_err(agent_core_error)? } }; - attach_core_restore(&mut plan, before, after)?; + attach_core_restore(&mut plan, before, after).map_err(agent_core_error)?; } Ok(plan) } @@ -152,7 +152,7 @@ pub(crate) async fn execute_restore_plan( revision: &str, ) -> Result { if plan.preview.revision != revision { - return Err("预览后配置发生变化,请重新选择历史版本".into()); + return Err("预览后配置发生变化,请重新选择备份版本".into()); } let commit = || { let _guard = AGENT_CONFIG_FILE_LOCK @@ -164,32 +164,34 @@ pub(crate) async fn execute_restore_plan( || serde_json::to_vec(&version).map_err(|e| e.to_string())? != serde_json::to_vec(&plan.version).map_err(|e| e.to_string())? { - return Err("恢复期间配置或历史发生变化,请重新选择历史版本".into()); + return Err("恢复期间配置或备份发生变化,请重新选择备份版本".into()); } - commit_history_with_mappings( + commit_config_with_mappings( &plan.version.client, &plan.paths, &plan.before, &plan.after, "restore", - plan.version.model.clone(), + plan.version.mappings.as_ref().map(|m| m.sonnet.clone()), plan.version.mappings.clone(), ) }; match &plan.core { Some(core) => { - commit_management_alias_config_changes(config, &core.before, &core.after, commit).await + commit_management_alias_config_changes(config, &core.before, &core.after, commit) + .await + .map_err(agent_core_error) } None => commit(), } } #[tauri::command] -pub(crate) async fn preview_agent_config_history( +pub(crate) async fn preview_agent_config_backup( app: tauri::AppHandle, client: String, id: String, -) -> Result { +) -> Result { let home = app.path().home_dir().map_err(|e| e.to_string())?; let config = app.state::().snapshot()?; Ok(prepare_restore_plan(&config, &client, &home, &id) @@ -198,7 +200,7 @@ pub(crate) async fn preview_agent_config_history( } #[tauri::command] -pub(crate) async fn restore_agent_config_history( +pub(crate) async fn restore_agent_config_backup( app: tauri::AppHandle, client: String, id: String, @@ -211,3 +213,27 @@ pub(crate) async fn restore_agent_config_history( app.state::().clear()?; Ok(result) } + +pub(super) fn desktop_profile_needs_mapping(images: &Images) -> Result { + let (path, bytes) = &images[2]; + let profile = parse(path, text(bytes.as_deref())?)?; + let names = profile + .get("inferenceModels") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|model| model.get("name").and_then(Value::as_str)) + .collect::>(); + let routes = [ + CLAUDE_DESKTOP_OPUS_MODEL_ID, + CLAUDE_DESKTOP_SONNET_MODEL_ID, + CLAUDE_DESKTOP_HAIKU_MODEL_ID, + ]; + if !names + .iter() + .any(|name| routes.iter().any(|route| route.eq_ignore_ascii_case(name))) + { + return Ok(false); + } + Ok(true) +} diff --git a/src-tauri/src/agents/backups/tests.rs b/src-tauri/src/agents/backups/tests.rs new file mode 100644 index 0000000..bdff7ae --- /dev/null +++ b/src-tauri/src/agents/backups/tests.rs @@ -0,0 +1,822 @@ +use super::*; + +struct Home(PathBuf); +impl Home { + fn new() -> Self { + let path = std::env::temp_dir().join(format!( + "cpa-backups-{}-{}", + std::process::id(), + SEQUENCE.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir_all(&path).unwrap(); + Self(path) + } +} +impl Drop for Home { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} +fn save(path: &Path, content: impl AsRef<[u8]>) { + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, content).unwrap(); +} +fn models() -> Vec { + ["gpt-one", "gpt-two"] + .into_iter() + .map(|name| AgentModelOption { + name: name.into(), + alias: None, + is_alias: false, + context_window: Some(200_000), + }) + .collect() +} +fn catalog(model: &str) -> String { + let payload = serde_json::json!({"models":[{"id":model}]}); + let runtime = codex_catalog::parse_runtime_models(&payload).unwrap(); + codex_catalog::prepare_catalog(&runtime).unwrap().json +} +fn apply(home: &Path, client: AgentClient, model: &str) -> Result { + let catalog = catalog(model); + let mappings = ClaudeDesktopModelMappings::all(model); + apply_agent_configuration_with_oauth( + client, + home, + 8317, + "test-secret", + model, + AgentConfigurationOptions { + models: &models(), + codex_catalog: Some(&catalog), + oauth_configuration: false, + claude_code_model_mappings: Some(&mappings), + claude_desktop_model_mappings: (client == AgentClient::ClaudeDesktop) + .then_some(&mappings), + }, + ) +} +fn template(home: &Path, client: AgentClient) -> Result { + let catalog = catalog("gpt-one"); + let mappings = ClaudeDesktopModelMappings::all("gpt-one"); + reset_agent_configuration_to_default_with_oauth(AgentDefaultConfiguration { + client, + home, + port: 8317, + api_key: "test-secret", + model: "gpt-one", + models: &models(), + codex_catalog: Some(&catalog), + oauth_configuration: false, + claude_code_model_mappings: Some(&mappings), + claude_desktop_model_mappings: (client == AgentClient::ClaudeDesktop).then_some(&mappings), + }) +} +fn clients() -> Vec { + [ + "claude-code", + "claude-desktop", + "codex", + "opencode", + "openclaw", + "hermes", + "deepseek-harness", + "zcode", + "kimi-code", + "grok-build", + ] + .into_iter() + .map(|id| AgentClient::parse(id).unwrap()) + .filter(|c| c.supported_platform()) + .collect() +} + +#[test] +fn all_client_updates_preserve_custom_settings_and_create_no_backups() { + for client in clients() { + let home = Home::new(); + apply(&home.0, client, "gpt-one") + .unwrap_or_else(|e| panic!("{} initial: {e}", client.id())); + let paths = config_paths(client.id(), &home.0).unwrap(); + for path in &paths { + let raw = read_agent_bytes(path).unwrap(); + let mut root = parse(path, text(raw.as_deref()).unwrap()).unwrap(); + root["custom"] = + serde_json::json!({"nested": {"array": [1, "two", false], "secret": "keep-me"}}); + save(path, render(path, &root).unwrap()); + } + apply(&home.0, client, "gpt-two").unwrap_or_else(|e| panic!("{} update: {e}", client.id())); + for path in &paths { + let raw = read_agent_bytes(path).unwrap(); + let root = parse(path, text(raw.as_deref()).unwrap()).unwrap(); + assert_eq!( + root["custom"]["nested"]["secret"], + "keep-me", + "{}", + client.id() + ); + } + let before = config_images(&paths).unwrap(); + assert_eq!( + apply(&home.0, client, "gpt-two").unwrap().outcome, + "unchanged", + "{}", + client.id() + ); + assert_eq!(before, config_images(&paths).unwrap()); + assert!(!agent_data_directory(&paths) + .unwrap() + .join("backups") + .exists()); + } +} + +#[test] +fn codex_catalog_reset_removes_managed_fields_and_preserves_extensions_on_disk() { + for sync in [false, true] { + let home = Home::new(); + apply(&home.0, AgentClient::Codex, "gpt-6-astra").unwrap(); + let paths = config_paths("codex", &home.0).unwrap(); + let catalog_path = codex_model_catalog_path(&home.0); + let runtime = codex_catalog::parse_runtime_models( + &serde_json::json!({"models": [{"id": "gpt-6-astra"}]}), + ) + .unwrap(); + let defaults = codex_catalog::prepare_catalog(&runtime).unwrap(); + let mut expected: Value = serde_json::from_str(&defaults.json).unwrap(); + // The editor restores 95% by dropping its override on this official model. + assert!(expected["models"][0] + .get("effective_context_window_percent") + .is_none()); + expected["custom"] = serde_json::json!({"keep": true}); + expected["models"][0]["extensions"] = + serde_json::json!({"headers": {"custom": "keep"}, "options": [1, 2]}); + let mut customized = expected.clone(); + customized["models"][0]["effective_context_window_percent"] = serde_json::json!(80); + save(&catalog_path, serde_json::to_vec(&customized).unwrap()); + let before = config_images(&paths).unwrap(); + let update = || { + if sync { + sync_codex_model_catalog_if_configured( + &home.0, + 8317, + "test-secret", + &defaults.models, + &defaults.json, + ) + .unwrap() + } else { + apply(&home.0, AgentClient::Codex, "gpt-6-astra") + .unwrap() + .outcome + == "updated" + } + }; + + assert!(update(), "sync={sync}"); + let actual: Value = serde_json::from_slice(&fs::read(&catalog_path).unwrap()).unwrap(); + assert_eq!(actual, expected, "sync={sync}"); + for (path, bytes) in before { + if path != catalog_path { + assert_eq!(read_agent_bytes(&path).unwrap(), bytes); + } + } + let restored = config_images(&paths).unwrap(); + assert!(!update(), "a repeated reset must be a no-op, sync={sync}"); + assert_eq!(config_images(&paths).unwrap(), restored); + assert_eq!(test_backup_count(AgentClient::Codex, &home.0), 0); + } +} + +#[test] +fn all_templates_repair_corrupt_files_and_restore_exact_manual_backup() { + for client in clients() { + let home = Home::new(); + template(&home.0, client).unwrap_or_else(|e| panic!("{}: {e}", client.id())); + let paths = config_paths(client.id(), &home.0).unwrap(); + for path in &paths { + let raw = read_agent_bytes(path).unwrap(); + let mut root = parse(path, text(raw.as_deref()).unwrap()).unwrap(); + root["custom"] = serde_json::json!({"keep":true}); + save(path, render(path, &root).unwrap()); + } + let original = config_images(&paths).unwrap(); + let version = create_backup(client.id(), &home.0).unwrap(); + assert!(version.restorable, "{}: {:?}", client.id(), version.error); + for path in &paths { + save(path, b"{{broken test-secret"); + } + let broken = config_images(&paths).unwrap(); + let error = apply(&home.0, client, "gpt-two").unwrap_err(); + assert!(!error.contains("test-secret")); + assert_eq!(config_images(&paths).unwrap(), broken); + template(&home.0, client).unwrap(); + for path in &paths { + let raw = read_agent_bytes(path).unwrap(); + assert!(parse(path, text(raw.as_deref()).unwrap()) + .unwrap() + .get("custom") + .is_none()); + } + test_restore_backup(client, &home.0, &version.id); + assert_eq!(original, config_images(&paths).unwrap()); + assert_eq!(test_backup_count(client, &home.0), 1); + } +} + +#[test] +fn backup_restores_absence_and_does_not_touch_other_files_or_versions() { + for client in clients() { + let home = Home::new(); + let paths = config_paths(client.id(), &home.0).unwrap(); + save( + &paths[0], + render(&paths[0], &serde_json::json!({"custom":"original"})).unwrap(), + ); + let original = config_images(&paths).unwrap(); + let first = create_backup(client.id(), &home.0).unwrap(); + let second = create_backup(client.id(), &home.0).unwrap(); + assert_ne!(first.id, second.id); + let unrelated = paths[0].parent().unwrap().join("unmanaged.txt"); + save(&unrelated, "untouched"); + template(&home.0, client).unwrap(); + test_restore_backup(client, &home.0, &first.id); + assert_eq!(original, config_images(&paths).unwrap()); + delete_backup(client.id(), &home.0, &first.id).unwrap(); + assert_eq!(original, config_images(&paths).unwrap()); + assert_eq!(fs::read_to_string(&unrelated).unwrap(), "untouched"); + assert_eq!( + list_backups(client.id(), &home.0).unwrap().versions[0].id, + second.id + ); + } +} + +#[test] +fn corrupt_raw_and_non_utf8_backups_are_saved_but_not_restorable() { + let home = Home::new(); + let paths = config_paths("codex", &home.0).unwrap(); + for raw in [b"invalid test-secret".to_vec(), vec![0xff, 0xfe, 0x00]] { + save(&paths[0], &raw); + let backup = create_backup("codex", &home.0).unwrap(); + assert!(!backup.restorable); + assert!(!backup.error.as_ref().unwrap().contains("test-secret")); + assert_eq!( + read_version("codex", &paths, &backup.id).unwrap().files[0].bytes, + Some(raw) + ); + assert!(preview("codex", &paths, &backup.id).is_err()); + delete_backup("codex", &home.0, &backup.id).unwrap(); + } +} + +#[test] +fn restore_validation_uses_the_clients_actual_json_format() { + for (client, restorable) in [ + ("claude-code", false), + ("opencode", true), + ("openclaw", true), + ("pi", false), + ] { + let home = Home::new(); + let paths = config_paths(client, &home.0).unwrap(); + save(&paths[0], "// JSON5\n{custom:'keep'}\n"); + let backup = create_backup(client, &home.0).unwrap(); + assert_eq!(backup.restorable, restorable, "{client}"); + assert_eq!( + read_version(client, &paths, &backup.id).unwrap().files[0].bytes, + Some(b"// JSON5\n{custom:'keep'}\n".to_vec()) + ); + } +} + +#[test] +fn damaged_packages_and_traversal_are_rejected_and_deletable() { + let home = Home::new(); + let paths = config_paths("codex", &home.0).unwrap(); + let backup = create_backup("codex", &home.0).unwrap(); + let path = version_path("codex", &paths, &backup.id).unwrap(); + let original = fs::read(&path).unwrap(); + let package: BackupPackage = serde_json::from_slice(&original).unwrap(); + for tampering in ["checksum", "path", "missing", "hash", "mapping"] { + let mut changed = package.payload.clone(); + match tampering { + "path" => changed.files[0].path = home.0.join("outside"), + "missing" => { + changed.files.pop(); + } + "hash" => changed.files[0].bytes = Some(b"secret".to_vec()), + "mapping" => changed.mappings = Some(ClaudeDesktopModelMappings::all("other")), + _ => {} + } + let checksum = if tampering == "path" || tampering == "missing" || tampering == "hash" { + sha256_bytes(&serde_json::to_vec(&changed).unwrap()) + } else { + "wrong".into() + }; + save( + &path, + serde_json::to_vec(&BackupPackage { + payload: changed, + checksum, + }) + .unwrap(), + ); + assert!(preview("codex", &paths, &backup.id).is_err()); + assert!(!list_backups("codex", &home.0).unwrap().versions[0].restorable); + } + save(&path, "not a package"); + assert_eq!(list_backups("codex", &home.0).unwrap().versions.len(), 1); + for id in ["../escape", "..\\escape", "C:/escape", "", "1/2"] { + assert!(delete_backup("codex", &home.0, id).is_err()); + assert!(read_version("codex", &paths, id).is_err()); + } + delete_backup("codex", &home.0, &backup.id).unwrap(); +} + +#[test] +fn previews_track_configuration_backup_and_mapping_changes_without_exposing_values() { + let home = Home::new(); + let paths = config_paths("codex", &home.0).unwrap(); + apply(&home.0, AgentClient::Codex, "gpt-one").unwrap(); + let backup = create_backup("codex", &home.0).unwrap(); + apply(&home.0, AgentClient::Codex, "gpt-two").unwrap(); + let first = preview("codex", &paths, &backup.id).unwrap().0; + assert!(!serde_json::to_string(&first) + .unwrap() + .contains("test-secret")); + save(&paths[0], "invalid test-secret"); + let next = preview("codex", &paths, &backup.id).unwrap().0; + assert_ne!(first.revision, next.revision); + let mut version = read_version("codex", &paths, &backup.id).unwrap(); + version.created_at = "2020-01-01T00:00:00Z".into(); + let path = version_path("codex", &paths, &backup.id).unwrap(); + save( + &path, + serde_json::to_vec(&BackupPackage { + checksum: sha256_bytes(&serde_json::to_vec(&version).unwrap()), + payload: version, + }) + .unwrap(), + ); + assert_ne!( + next.revision, + preview("codex", &paths, &backup.id).unwrap().0.revision + ); +} + +#[test] +fn unwritable_backup_directory_does_not_block_updates_sync_clear_or_templates() { + let home = Home::new(); + let paths = config_paths("codex", &home.0).unwrap(); + let data = agent_data_directory(&paths).unwrap(); + save(&data.join("backups"), "blocked"); + apply(&home.0, AgentClient::Codex, "gpt-one").unwrap(); + assert!(create_backup("codex", &home.0).is_err()); + apply(&home.0, AgentClient::Codex, "gpt-two").unwrap(); + sync_codex_model_catalog_if_configured( + &home.0, + 8317, + "test-secret", + &models(), + &catalog("gpt-two"), + ) + .unwrap(); + clear_codex_config_files(&home.0).unwrap(); + template(&home.0, AgentClient::Codex).unwrap(); + assert_eq!(fs::read_to_string(data.join("backups")).unwrap(), "blocked"); +} + +#[test] +fn partial_writes_and_failed_verification_roll_back_without_persistent_snapshots() { + let home = Home::new(); + let paths = config_paths("codex", &home.0).unwrap(); + save(&paths[0], "model = 'old'\n"); + let before = config_images(&paths).unwrap(); + let mut after = before.clone(); + after[0].1 = Some(b"model = 'new'\n".to_vec()); + after[1].1 = Some(b"{}".to_vec()); + for silent in [false, true] { + let mut first = true; + let error = commit_config_with_writer( + "codex", + &paths, + &before, + &after, + "update", + None, + None, + &mut |client, images| { + if first { + first = false; + write_config_images(client, &vec![images[0].clone()])?; + return if silent { + Ok(()) + } else { + Err("failure secret-token".into()) + }; + } + write_config_images(client, images) + }, + ) + .unwrap_err(); + assert!(error.contains("已回滚")); + assert!(!error.contains("secret-token")); + assert_eq!(before, config_images(&paths).unwrap()); + } + let error = commit_config_with_writer( + "codex", + &paths, + &before, + &after, + "update", + None, + None, + &mut |_, _| Err("secret-token".into()), + ) + .unwrap_err(); + assert!(error.contains("回滚失败")); + assert!(!error.contains("secret-token")); + assert!(list_backups("codex", &home.0).unwrap().versions.is_empty()); + save(&paths[0], "external = true"); + assert!( + commit_config("codex", &paths, &before, &after, "update", None) + .unwrap_err() + .contains("其他程序") + ); + assert_eq!(fs::read_to_string(&paths[0]).unwrap(), "external = true"); +} + +#[test] +fn old_backups_remain_untouched_and_are_not_listed() { + let home = Home::new(); + let paths = config_paths("opencode", &home.0).unwrap(); + let old = paths[0] + .parent() + .unwrap() + .join(".cpa-config-history/opencode/old/1.json"); + save(&old, "old history"); + let legacy = agent_backup_path(&paths[0]).unwrap(); + save(&legacy, "old backup"); + assert!(list_backups("opencode", &home.0) + .unwrap() + .versions + .is_empty()); + apply(&home.0, AgentClient::OpenCode, "gpt-one").unwrap(); + assert_eq!(fs::read_to_string(old).unwrap(), "old history"); + assert_eq!(fs::read_to_string(legacy).unwrap(), "old backup"); +} + +#[test] +fn pi_template_only_writes_configuration_and_package_references() { + let home = Home::new(); + let paths = config_paths("pi", &home.0).unwrap(); + save(&paths[0], "invalid"); + save(&paths[1], "invalid"); + let package = home.0.join("installed-package"); + save(&package, "package contents"); + let updates = build_pi_template_updates(&home.0, 8317, "secret", "gpt-one").unwrap(); + config_updates( + "pi", + &home.0, + &config_images(&paths).unwrap(), + &updates, + "template", + None, + None, + ) + .unwrap(); + assert!(pi_provider_package_installed(&home.0).unwrap()); + let first = create_backup("pi", &home.0).unwrap(); + let original = config_images(&paths).unwrap(); + repair_pi_provider_inner(&home.0, 8318, "other-secret", "gpt-two").unwrap(); + let (_, before, after) = preview("pi", &paths, &first.id).unwrap(); + commit_config("pi", &paths, &before, &after, "restore", None).unwrap(); + assert_eq!(original, config_images(&paths).unwrap()); + assert_eq!(fs::read_to_string(package).unwrap(), "package contents"); + assert_eq!(list_backups("pi", &home.0).unwrap().versions.len(), 1); +} + +#[test] +fn desktop_current_mapping_survives_backup_deletion_and_rejects_unreliable_profile() { + 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 original = config_images(&paths).unwrap(); + let backup = create_backup("claude-desktop", &home.0).unwrap(); + let revision = preview("claude-desktop", &paths, &backup.id) + .unwrap() + .0 + .revision; + apply(&home.0, AgentClient::ClaudeDesktop, "gpt-two").unwrap(); + assert_eq!(original, config_images(&paths).unwrap()); + assert_ne!( + revision, + preview("claude-desktop", &paths, &backup.id) + .unwrap() + .0 + .revision + ); + delete_backup("claude-desktop", &home.0, &backup.id).unwrap(); + assert_eq!(current_desktop_mappings(&home.0).unwrap().opus, "gpt-two"); + let persisted: DesktopMappingState = + serde_json::from_slice(&fs::read(desktop_mapping_path(&paths).unwrap()).unwrap()).unwrap(); + assert_eq!(persisted.mappings.opus, "gpt-two"); + save(&paths[2], "{\"inferenceModels\":[]}"); + assert!(current_desktop_mappings(&home.0).is_none()); +} + +#[test] +fn desktop_model_extensions_do_not_resurrect_disabled_one_million_context_flags() { + if !AgentClient::ClaudeDesktop.supported_platform() { + return; + } + let home = Home::new(); + let paths = config_paths("claude-desktop", &home.0).unwrap(); + let mut mappings = ClaudeDesktopModelMappings::all("gpt-one"); + mappings.opus_1m = true; + let options = |m| AgentConfigurationOptions { + models: &[], + codex_catalog: None, + oauth_configuration: false, + claude_code_model_mappings: None, + claude_desktop_model_mappings: Some(m), + }; + apply_agent_configuration_with_oauth( + AgentClient::ClaudeDesktop, + &home.0, + 8317, + "secret", + "gpt-one", + options(&mappings), + ) + .unwrap(); + let mut value: Value = serde_json::from_slice(&fs::read(&paths[2]).unwrap()).unwrap(); + value["inferenceModels"][0]["custom"] = serde_json::json!({"keep":true}); + save(&paths[2], value.to_string()); + let next_mappings = ClaudeDesktopModelMappings::all("gpt-one"); + apply_agent_configuration_with_oauth( + AgentClient::ClaudeDesktop, + &home.0, + 8317, + "secret", + "gpt-one", + options(&next_mappings), + ) + .unwrap(); + let value: Value = serde_json::from_slice(&fs::read(&paths[2]).unwrap()).unwrap(); + assert_eq!(value["inferenceModels"][0]["custom"]["keep"], true); + assert!(value["inferenceModels"][0].get("supports1m").is_none()); + assert!(value["inferenceModels"][0].get("prefer1m").is_none()); +} + +#[test] +fn semantic_noop_keeps_original_comments_and_guard_rejects_dropped_custom_values() { + let home = Home::new(); + let paths = config_paths("opencode", &home.0).unwrap(); + save( + &paths[0], + "// comment\n{model:'old', custom:{nested:true}}\n", + ); + let before = config_images(&paths).unwrap(); + let update = AgentFileUpdate { + path: paths[0].clone(), + after: "{\"model\":\"old\",\"custom\":{\"nested\":true}}".into(), + }; + assert_eq!( + config_updates( + "opencode", + &home.0, + &before, + &[update], + "update", + None, + None + ) + .unwrap() + .outcome, + "unchanged" + ); + assert_eq!(before, config_images(&paths).unwrap()); + let update = AgentFileUpdate { + path: paths[0].clone(), + after: "{}".into(), + }; + assert!(config_updates( + "opencode", + &home.0, + &before, + &[update], + "update", + None, + None + ) + .is_err()); +} + +#[cfg(windows)] +#[test] +fn occupied_file_rejects_write_and_preserves_original() { + use std::os::windows::fs::OpenOptionsExt; + let home = Home::new(); + let paths = config_paths("codex", &home.0).unwrap(); + save(&paths[0], "model='old'"); + let before = config_images(&paths).unwrap(); + let mut after = before.clone(); + after[0].1 = Some(b"model='new'".to_vec()); + let _occupied = fs::OpenOptions::new() + .read(true) + .share_mode(1) + .open(&paths[0]) + .unwrap(); + assert!(commit_config("codex", &paths, &before, &after, "update", None).is_err()); + assert_eq!(before, config_images(&paths).unwrap()); +} + +#[test] +fn directory_at_file_path_is_rejected() { + let home = Home::new(); + let paths = config_paths("codex", &home.0).unwrap(); + fs::create_dir_all(&paths[0]).unwrap(); + assert!(config_images(&paths).is_err()); +} + +fn link_directory(target: &Path, link: &Path) { + #[cfg(unix)] + std::os::unix::fs::symlink(target, link).unwrap(); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + let output = Command::new("cmd.exe") + .args(["/C", "mklink", "/J"]) + .arg(link) + .arg(target) + .creation_flags(0x08000000) + .output() + .unwrap(); + assert!( + output.status.success(), + "temporary junction creation failed" + ); + } +} + +#[test] +fn linked_configuration_and_backup_directories_are_rejected() { + let home = Home::new(); + let outside = Home::new(); + let paths = config_paths("codex", &home.0).unwrap(); + save(&outside.0.join("config.toml"), "custom='outside'"); + let link = paths[0].parent().unwrap(); + link_directory(&outside.0, link); + assert!(create_backup("codex", &home.0).is_err()); + assert!(template(&home.0, AgentClient::Codex).is_err()); + assert_eq!( + fs::read_to_string(outside.0.join("config.toml")).unwrap(), + "custom='outside'" + ); + fs::remove_dir(link).unwrap(); + let data = agent_data_directory(&paths).unwrap(); + fs::create_dir_all(&data).unwrap(); + link_directory(&outside.0, &data.join("backups")); + assert!(create_backup("codex", &home.0).is_err()); + assert!(list_backups("codex", &home.0).is_err()); + assert!(delete_backup("codex", &home.0, "1").is_err()); + fs::remove_dir(data.join("backups")).unwrap(); +} + +#[tokio::test] +async fn restore_rejects_configuration_or_backup_changes_since_confirmation() { + let home = Home::new(); + let paths = config_paths("codex", &home.0).unwrap(); + let config = GuiConfigFile::default(); + template(&home.0, AgentClient::Codex).unwrap(); + let backup = create_backup("codex", &home.0).unwrap(); + let plan = prepare_restore_plan(&config, "codex", &home.0, &backup.id) + .await + .unwrap(); + let revision = plan.preview.revision.clone(); + save(&paths[0], "external='keep'"); + let current = config_images(&paths).unwrap(); + assert!(execute_restore_plan(&config, plan, &revision) + .await + .is_err()); + assert_eq!(current, config_images(&paths).unwrap()); + let plan = prepare_restore_plan(&config, "codex", &home.0, &backup.id) + .await + .unwrap(); + let revision = plan.preview.revision.clone(); + let mut version = read_version("codex", &paths, &backup.id).unwrap(); + version.created_at = "2021-01-01T00:00:00Z".into(); + save( + &version_path("codex", &paths, &backup.id).unwrap(), + serde_json::to_vec(&BackupPackage { + checksum: sha256_bytes(&serde_json::to_vec(&version).unwrap()), + payload: version, + }) + .unwrap(), + ); + assert!(execute_restore_plan(&config, plan, &revision) + .await + .is_err()); + assert_eq!(current, config_images(&paths).unwrap()); +} + +#[test] +fn nested_model_options_and_third_party_providers_survive_all_updates() { + for client in clients() { + let home = Home::new(); + apply(&home.0, client, "gpt-one").unwrap(); + let paths = config_paths(client.id(), &home.0).unwrap(); + let mut touched = Vec::new(); + for path in &paths { + let raw = read_agent_bytes(path).unwrap(); + let mut root = parse(path, text(raw.as_deref()).unwrap()).unwrap(); + let container = match client { + AgentClient::Codex if path == &paths[0] => Some("/model_providers"), + AgentClient::OpenCode | AgentClient::ZCode => Some("/provider"), + AgentClient::OpenClaw => Some("/models/providers"), + AgentClient::DeepSeekHarness if path == &paths[0] => Some("/llm-pi-ai/providers"), + AgentClient::KimiCode => Some("/providers"), + _ => None, + }; + if let Some(pointer) = container { + root.pointer_mut(pointer) + .unwrap() + .as_object_mut() + .unwrap() + .insert( + "third-party".into(), + serde_json::json!({"key":"keep-secret","nested":{"enabled":true}}), + ); + touched.push(( + path.clone(), + format!("{pointer}/third-party"), + serde_json::json!({"key":"keep-secret","nested":{"enabled":true}}), + )); + } + let inventory = match client { + AgentClient::ClaudeDesktop if path == &paths[2] => { + Some("/inferenceModels/0".to_string()) + } + AgentClient::OpenCode | AgentClient::ZCode => { + Some("/provider/cpa-gui/models/gpt-one".into()) + } + AgentClient::OpenClaw => Some("/models/providers/cpa-gui/models/0".into()), + AgentClient::Hermes => Some("/custom_providers/0/models/gpt-one".into()), + AgentClient::DeepSeekHarness if path == &paths[0] => Some(format!( + "/llm-pi-ai/providers/{DEEPSEEK_HARNESS_PROVIDER_ID}/models/0" + )), + AgentClient::KimiCode => Some("/models/cpa-gui~1gpt-one".into()), + AgentClient::GrokBuild => Some("/model/cpa-gui~1gpt-one".into()), + _ => None, + }; + if let Some(pointer) = inventory { + let extensions = serde_json::json!({"headers":{"custom":"keep"},"array":[1,2]}); + root.pointer_mut(&pointer) + .unwrap() + .as_object_mut() + .unwrap() + .insert("extensions".into(), extensions.clone()); + touched.push((path.clone(), format!("{pointer}/extensions"), extensions)); + } + save(path, render(path, &root).unwrap()); + } + // Change credentials without reordering the selected model inventory. + let catalog = catalog("gpt-one"); + let mappings = ClaudeDesktopModelMappings::all("gpt-one"); + apply_agent_configuration_with_oauth( + client, + &home.0, + 8318, + "new-secret", + "gpt-one", + AgentConfigurationOptions { + models: &models(), + codex_catalog: Some(&catalog), + oauth_configuration: false, + claude_code_model_mappings: Some(&mappings), + claude_desktop_model_mappings: (client == AgentClient::ClaudeDesktop) + .then_some(&mappings), + }, + ) + .unwrap_or_else(|e| panic!("{}: {e}", client.id())); + for (path, pointer, expected) in touched { + let raw = read_agent_bytes(&path).unwrap(); + let actual = parse(&path, text(raw.as_deref()).unwrap()).unwrap(); + assert_eq!( + actual.pointer(&pointer), + Some(&expected), + "{} {pointer}", + client.id() + ); + } + assert!(list_backups(client.id(), &home.0) + .unwrap() + .versions + .is_empty()); + } +} diff --git a/src-tauri/src/agents/commands.rs b/src-tauri/src/agents/commands.rs index 9d4803b..e289111 100644 --- a/src-tauri/src/agents/commands.rs +++ b/src-tauri/src/agents/commands.rs @@ -948,13 +948,13 @@ pub(crate) fn agent_uses_cpa_runtime_context_windows(client: AgentClient) -> boo pub(crate) fn resolve_claude_desktop_model_mappings( client: AgentClient, models: &[AgentModelOption], - selected_model: &str, + _selected_model: &str, requested: Option, ) -> Result, String> { if client != AgentClient::ClaudeDesktop { return Ok(None); } - let requested = requested.unwrap_or_else(|| ClaudeDesktopModelMappings::all(selected_model)); + let requested = requested.ok_or("请重新选择 Claude Desktop 的模型映射")?; let resolve = |model: &str| resolve_available_agent_model(models, &validate_agent_model(model)?); Ok(Some(ClaudeDesktopModelMappings { @@ -1059,98 +1059,25 @@ pub(crate) async fn apply_agent_config( &model, claude_desktop_model_mappings, )?; - if let Some(mappings) = claude_desktop_model_mappings.as_ref() { - ensure_claude_desktop_model_aliases(&config, mappings, &prepared.models).await?; - } - let _guard = AGENT_CONFIG_FILE_LOCK - .lock() - .map_err(|_| "智能体配置文件锁已损坏".to_string())?; - apply_agent_configuration_with_oauth( - client, - &home, - config.port, - api_key, - &model, - AgentConfigurationOptions { - models: &prepared.models, - codex_catalog: prepared.codex_catalog.as_deref(), - oauth_configuration, - claude_code_model_mappings: claude_code_model_mappings.as_ref(), - claude_desktop_model_mappings: claude_desktop_model_mappings.as_ref(), - }, - ) -} - -#[tauri::command] -pub(crate) fn close_agent_config_modification( - app: tauri::AppHandle, - client: String, -) -> Result { - let client = AgentClient::parse(&client)?; - let home = app - .path() - .home_dir() - .map_err(|error| format!("无法获取用户目录: {error}"))?; - let _guard = AGENT_CONFIG_FILE_LOCK - .lock() - .map_err(|_| "智能体配置文件锁已损坏".to_string())?; - restore_agent_session_configuration(client, &home)?; - Ok(action_result("closed", false, None, Vec::new(), Vec::new())) -} - -#[tauri::command] -pub(crate) async fn reset_agent_config_to_default( - app: tauri::AppHandle, - gui_config_state: tauri::State<'_, GuiConfigState>, - client: String, - model: String, - oauth_configuration: bool, - claude_code_model_mappings: Option, - claude_desktop_model_mappings: Option, -) -> Result { - let client = AgentClient::parse(&client)?; - let home = app - .path() - .home_dir() - .map_err(|error| format!("无法获取用户目录: {error}"))?; - let config = gui_config_state.snapshot()?; - let api_key = effective_agent_api_key(&config); - if client == AgentClient::Codex && oauth_configuration { - validate_codex_oauth_login(&home)?; - } - validate_agent_can_enable(client, &home, config.port, api_key)?; - let prepared = fetch_prepared_agent_models(client, &config).await?; - let model = resolve_available_agent_model(&prepared.models, &validate_agent_model(&model)?)?; - let claude_code_model_mappings = resolve_claude_code_model_mappings( - client, - &prepared.models, - &model, - claude_code_model_mappings, - )?; - let claude_desktop_model_mappings = resolve_claude_desktop_model_mappings( - client, - &prepared.models, - &model, - claude_desktop_model_mappings, - )?; - if let Some(mappings) = claude_desktop_model_mappings.as_ref() { - ensure_claude_desktop_model_aliases(&config, mappings, &prepared.models).await?; - } - let _guard = AGENT_CONFIG_FILE_LOCK - .lock() - .map_err(|_| "智能体配置文件锁已损坏".to_string())?; - reset_agent_configuration_to_default_with_oauth(AgentDefaultConfiguration { - client, - home: &home, - port: config.port, - api_key, - model: &model, - models: &prepared.models, - codex_catalog: prepared.codex_catalog.as_deref(), - oauth_configuration, - claude_code_model_mappings: claude_code_model_mappings.as_ref(), - claude_desktop_model_mappings: claude_desktop_model_mappings.as_ref(), - }) + commit_agent_with_core(&config, claude_desktop_model_mappings.as_ref(), &prepared.models, || { + let _guard = AGENT_CONFIG_FILE_LOCK + .lock() + .map_err(|_| "智能体配置文件锁已损坏".to_string())?; + apply_agent_configuration_with_oauth( + client, + &home, + config.port, + api_key, + &model, + AgentConfigurationOptions { + models: &prepared.models, + codex_catalog: prepared.codex_catalog.as_deref(), + oauth_configuration, + claude_code_model_mappings: claude_code_model_mappings.as_ref(), + claude_desktop_model_mappings: claude_desktop_model_mappings.as_ref(), + }, + ) + }).await } #[tauri::command] @@ -1203,37 +1130,36 @@ pub(crate) async fn set_agent_config_enabled( &model, claude_desktop_model_mappings, )?; - if let Some(mappings) = claude_desktop_model_mappings.as_ref() { - ensure_claude_desktop_model_aliases(&config, mappings, &prepared.models).await?; - } - let _guard = AGENT_CONFIG_FILE_LOCK - .lock() - .map_err(|_| "智能体配置文件锁已损坏".to_string())?; - let oauth_configuration = if client == AgentClient::Codex { - current_codex_oauth_configuration(&home)? - } else { - false - }; - apply_agent_configuration_with_oauth( - client, - &home, - port, - api_key, - &model, - AgentConfigurationOptions { - models: &prepared.models, - codex_catalog: prepared.codex_catalog.as_deref(), - oauth_configuration, - claude_code_model_mappings: claude_code_model_mappings.as_ref(), - claude_desktop_model_mappings: claude_desktop_model_mappings.as_ref(), - }, - ) + commit_agent_with_core(&config, claude_desktop_model_mappings.as_ref(), &prepared.models, || { + let _guard = AGENT_CONFIG_FILE_LOCK + .lock() + .map_err(|_| "智能体配置文件锁已损坏".to_string())?; + let oauth_configuration = if client == AgentClient::Codex { + current_codex_oauth_configuration(&home)? + } else { + false + }; + apply_agent_configuration_with_oauth( + client, + &home, + port, + api_key, + &model, + AgentConfigurationOptions { + models: &prepared.models, + codex_catalog: prepared.codex_catalog.as_deref(), + oauth_configuration, + claude_code_model_mappings: claude_code_model_mappings.as_ref(), + claude_desktop_model_mappings: claude_desktop_model_mappings.as_ref(), + }, + ) + }).await } else { let _guard = AGENT_CONFIG_FILE_LOCK .lock() .map_err(|_| "智能体配置文件锁已损坏".to_string())?; let _ = force_restore; - Err("停用智能体配置接口已移除;如需整体重置,请使用“默认配置”".to_string()) + Err("停用智能体配置接口已移除;如需整体重置,请使用“基础配置模板”".to_string()) } } diff --git a/src-tauri/src/agents/configuration.rs b/src-tauri/src/agents/configuration.rs index 281ec93..2a43fb2 100644 --- a/src-tauri/src/agents/configuration.rs +++ b/src-tauri/src/agents/configuration.rs @@ -246,12 +246,8 @@ pub(crate) fn build_agent_updates_with_oauth( } pub(crate) fn read_optional_text(path: &Path) -> Result, String> { - if !path.is_file() { - return Ok(None); - } - fs::read_to_string(path) - .map(Some) - .map_err(|error| format!("读取配置失败 {}: {error}", path_to_string(path))) + read_agent_bytes(path)?.map(|bytes| String::from_utf8(bytes) + .map_err(|_| "配置不是 UTF-8 文本,请使用手动备份恢复或基础配置模板修复".to_string())).transpose() } pub(crate) fn read_agent_yaml_mapping_or_empty( @@ -420,12 +416,7 @@ fn validate_deepseek_harness_versioned_credentials( let Some(key) = key.as_str() else { return Err(format!("{label} 顶层字段名必须是字符串")); }; - if !matches!( - key, - "version" | "refs" | "records" | DEEPSEEK_HARNESS_CREDENTIAL - ) { - return Err(format!("{label} 包含未知顶层字段 {key}")); - } + let _ = key; // Preserve extension fields from the client and other integrations. } for section in ["refs", "records"] { if yaml_mapping_value(root, section).is_some_and(|value| !value.is_mapping()) { @@ -457,6 +448,7 @@ 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, @@ -870,6 +862,7 @@ pub(crate) fn build_claude_desktop_meta(existing: Option<&str>) -> Result(path: &Path, label: &str, update: F) -> Result where F: FnOnce(&mut serde_json::Map) -> bool, @@ -893,6 +886,7 @@ where Ok(true) } +#[cfg(test)] pub(crate) fn remove_claude_desktop_managed_configuration( paths: &[PathBuf], ) -> Result, String> { @@ -970,6 +964,7 @@ pub(crate) fn remove_claude_desktop_managed_configuration( Ok(changed) } +#[cfg(test)] pub(crate) fn remove_claude_code_managed_configuration( paths: &[PathBuf], ) -> Result, String> { @@ -1027,6 +1022,7 @@ pub(crate) fn remove_claude_code_managed_configuration( Ok(updated.then(|| path_to_string(path)).into_iter().collect()) } +#[cfg(test)] pub(crate) fn remove_codex_managed_configuration(paths: &[PathBuf]) -> Result, String> { use toml_edit::{Document, Item}; @@ -1098,6 +1094,7 @@ pub(crate) fn remove_codex_managed_configuration(paths: &[PathBuf]) -> Result Result, String> { @@ -1132,6 +1129,7 @@ pub(crate) fn remove_opencode_managed_configuration( Ok(updated.then(|| path_to_string(path)).into_iter().collect()) } +#[cfg(test)] pub(crate) fn remove_zcode_managed_configuration(paths: &[PathBuf]) -> Result, String> { if paths.is_empty() { return Err("ZCode 当前平台配置路径不可用".to_string()); @@ -1188,6 +1186,7 @@ pub(crate) fn remove_zcode_managed_configuration(paths: &[PathBuf]) -> Result Result, String> { @@ -1201,6 +1200,7 @@ pub(crate) fn remove_kimi_code_managed_configuration( ) } +#[cfg(test)] pub(crate) fn remove_grok_build_managed_configuration( paths: &[PathBuf], ) -> Result, String> { @@ -1214,6 +1214,7 @@ pub(crate) fn remove_grok_build_managed_configuration( ) } +#[cfg(test)] pub(crate) fn remove_managed_toml_client_configuration( paths: &[PathBuf], label: &str, @@ -1320,6 +1321,7 @@ pub(crate) fn remove_managed_toml_client_configuration( Ok(vec![path_to_string(path)]) } +#[cfg(test)] pub(crate) fn update_agent_json5_file( path: &Path, label: &str, @@ -1358,6 +1360,7 @@ where Ok(true) } +#[cfg(test)] pub(crate) fn remove_openclaw_managed_configuration( paths: &[PathBuf], ) -> Result, String> { @@ -1429,6 +1432,7 @@ pub(crate) fn remove_openclaw_managed_configuration( Ok(updated.then(|| path_to_string(path)).into_iter().collect()) } +#[cfg(test)] pub(crate) fn remove_hermes_managed_configuration( paths: &[PathBuf], ) -> Result, String> { @@ -1498,6 +1502,7 @@ pub(crate) fn remove_hermes_managed_configuration( Ok(vec![path_to_string(path)]) } +#[cfg(test)] pub(crate) fn remove_agent_managed_configuration( client: AgentClient, paths: &[PathBuf], @@ -1516,6 +1521,7 @@ pub(crate) fn remove_agent_managed_configuration( } } +#[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; @@ -1554,6 +1560,7 @@ pub(crate) fn remove_deepseek_harness_settings_fields(root: &mut serde_norway::M changed } +#[cfg(test)] pub(crate) fn remove_deepseek_harness_managed_configuration( paths: &[PathBuf], ) -> Result, String> { @@ -3310,7 +3317,8 @@ pub(crate) fn build_hermes_agent_config( serde_norway::Value::String(MANAGED_AGENT_PROVIDER_ID.to_string()), ); let rendered = if let Some(existing) = existing.filter(|value| !value.trim().is_empty()) { - render_yaml_value_changes(existing, &original, &root)? + render_yaml_value_changes(existing, &original, &root) + .or_else(|_| serde_norway::to_string(&root).map_err(|_| "生成 Hermes 配置失败".to_string()))? } else { serde_norway::to_string(&root).map_err(|error| format!("生成 Hermes 配置失败: {error}"))? }; diff --git a/src-tauri/src/agents/discovery.rs b/src-tauri/src/agents/discovery.rs index a6ac899..ec5a611 100644 --- a/src-tauri/src/agents/discovery.rs +++ b/src-tauri/src/agents/discovery.rs @@ -460,7 +460,7 @@ pub(crate) fn install_pi_provider_inner( let settings_path = pi_provider_settings_path(home); let mut changed_files = Vec::new(); if !pi_provider_package_installed(home)? { - history_package_operation(home, "plugin-install", || install_pi_package(executable, home, proxy_url))?; + config_package_operation(home, "plugin-install", || install_pi_package(executable, home, proxy_url))?; changed_files.push(path_to_string(&settings_path)); } @@ -485,7 +485,8 @@ pub(crate) fn repair_pi_provider_inner( let config_path = pi_provider_config_path(home); let settings_path = pi_provider_settings_path(home); let _guard = AGENT_CONFIG_FILE_LOCK.lock().map_err(|_| "配置文件锁已损坏")?; - let before = history_images(&history_paths(PI_AGENT_ID, home)?)?; + let before = config_images(&config_paths(PI_AGENT_ID, home)?)?; + validate_config_images(&before)?; if !pi_provider_package_installed(home)? { return Err("Pi CLIProxyAPI provider is not installed".to_string()); } @@ -508,7 +509,7 @@ pub(crate) fn repair_pi_provider_inner( ) })?; let rendered_settings = build_pi_provider_settings(&settings, default_model)?; - history_updates(PI_AGENT_ID, home, &before, &[ + config_updates(PI_AGENT_ID, home, &before, &[ AgentFileUpdate { path: config_path, after: rendered }, AgentFileUpdate { path: settings_path, after: rendered_settings }, ], "update", Some(default_model.to_string()), None) @@ -525,7 +526,7 @@ pub(crate) fn update_pi_provider_inner( if !pi_provider_package_installed(home)? { return Err("Pi CLIProxyAPI provider 插件尚未安装".to_string()); } - history_package_operation(home, "plugin-update", || update_pi_package(executable, home, proxy_url))?; + config_package_operation(home, "plugin-update", || update_pi_package(executable, home, proxy_url))?; let mut result = repair_pi_provider_inner(home, port, api_key, default_model)?; result.outcome = "updated".to_string(); Ok(result) @@ -544,7 +545,7 @@ pub(crate) fn uninstall_pi_provider_inner( Vec::new(), )); } - history_package_operation(home, "plugin-remove", || remove_pi_package(executable, home))?; + config_package_operation(home, "plugin-remove", || remove_pi_package(executable, home))?; Ok(action_result( "removed", false, @@ -756,13 +757,13 @@ pub(crate) fn validate_codex_oauth_login_at(auth_path: &Path) -> Result<(), Stri } pub(crate) fn clear_codex_config_files(home: &Path) -> Result, String> { - let paths = history_paths("codex",home)?; - let before = history_images(&paths)?; + let paths = config_paths("codex",home)?; + let before = config_images(&paths)?; let mut after = before.clone(); for (path, bytes) in &mut after { if path.file_name().and_then(|v| v.to_str()).is_some_and(|v| v == "auth.json" || v == "config.toml") { *bytes = None; } } - Ok(commit_history("codex", &paths, &before, &after, "clear", None)?.changed_files) + Ok(commit_config("codex", &paths, &before, &after, "clear", None)?.changed_files) } pub(crate) fn codex_model_catalog_path(home: &Path) -> PathBuf { @@ -886,7 +887,10 @@ pub(crate) fn inspect_agent_config( ) -> AgentConfigStatus { let paths = agent_config_paths(client, home); let config_exists = paths.iter().any(|path| path.is_file()); - let result = inspect_agent_managed_config(client, &paths, port, api_key).and_then( + let result = config_paths(client.id(), home) + .and_then(|paths| config_images(&paths)) + .and_then(|images| validate_config_images(&images)) + .and_then(|_| inspect_agent_managed_config(client, &paths, port, api_key)).and_then( |(configured, model, oauth_configuration)| { if client == AgentClient::Codex && configured { let model = model @@ -901,7 +905,7 @@ pub(crate) fn inspect_agent_config( Ok((configured, model, oauth_configuration)) => { (configured, model, oauth_configuration, true, None) } - Err(error) => (false, None, false, false, Some(error)), + Err(_) => (false, None, false, false, Some("配置读取或解析失败,请检查文件权限,或使用手动备份恢复、基础配置模板修复".to_string())), }; let executable = find_agent_executable(client, home); // Desktop application executables are not CLIs. Invoking them with @@ -962,7 +966,7 @@ pub(crate) fn inspect_agent_config( } let configuration_synchronized = agent_configuration_is_synchronized(client, home, configured); - // Legacy backup warnings belong in the history dialog, not the current configuration status. + // Legacy backup state never participates in current configuration discovery. AgentConfigStatus { id: client.id().to_string(), @@ -990,7 +994,7 @@ pub(crate) fn inspect_agent_config( claude_code_model_mappings: (client == AgentClient::ClaudeCode) .then(|| inspect_claude_code_model_mappings(&paths[0]).ok().flatten()) .flatten(), - claude_desktop_model_mappings: (client == AgentClient::ClaudeDesktop).then(|| current_desktop_history_mappings(home)).flatten(), + claude_desktop_model_mappings: (client == AgentClient::ClaudeDesktop).then(|| current_desktop_mappings(home)).flatten(), warnings, error, } diff --git a/src-tauri/src/agents/history.rs b/src-tauri/src/agents/history.rs deleted file mode 100644 index 7b66ed1..0000000 --- a/src-tauri/src/agents/history.rs +++ /dev/null @@ -1,1043 +0,0 @@ -//! Configuration history is independent of the old apply/close session state. -use super::*; -use serde_json::Value; -use std::collections::BTreeSet; -use std::sync::atomic::{AtomicU64, Ordering}; - -static SEQUENCE: AtomicU64 = AtomicU64::new(0); -type Images = Vec<(PathBuf, Option>)>; - -mod restore; -pub(crate) use restore::*; - -#[derive(Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -struct HistoryFile { - path: PathBuf, - bytes: Option>, - hash: String, - managed_fields: Vec>, -} - -#[derive(Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -struct HistoryVersion { - version: u8, - id: String, - client: String, - created_at: String, - source: String, - model: Option, - files: Vec, - legacy_identity: Option, - #[serde(default)] - mappings: Option, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct HistorySummary { - id: String, - created_at: String, - source: String, - model: Option, - file_count: usize, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct HistoryList { - versions: Vec, - warnings: Vec, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct HistoryDifference { - file: String, - field: String, - before: String, - after: String, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct HistoryPreview { - pub(crate) revision: String, - pub(crate) differences: Vec, -} - -pub(crate) fn history_paths(client: &str, home: &Path) -> Result, String> { - let paths = history_paths_inner(client, home)?; - #[cfg(test)] - assert!(paths.iter().all(|path| path.starts_with(home))); - Ok(paths) -} - -fn history_paths_inner(client: &str, home: &Path) -> Result, String> { - if client == PI_AGENT_ID { - return Ok(vec![ - pi_provider_config_path(home), - pi_provider_settings_path(home), - ]); - } - let client = AgentClient::parse(client)?; - if !client.supported_platform() { - return Err("当前平台不支持此智能体配置".into()); - } - let paths = agent_config_paths(client, home); - Ok(expected_agent_record_paths(client, &paths)) -} - -pub(crate) fn history_images(paths: &[PathBuf]) -> Result { - paths - .iter() - .map(|path| Ok((path.clone(), read_agent_bytes(path)?))) - .collect() -} - -fn image_hash(bytes: Option<&[u8]>) -> String { - bytes.map(sha256_bytes).unwrap_or_else(|| "missing".into()) -} - -fn image_revision(images: &Images) -> String { - sha256_bytes( - images - .iter() - .map(|(path, bytes)| { - format!("{}:{}", path_to_string(path), image_hash(bytes.as_deref())) - }) - .collect::>() - .join("\n") - .as_bytes(), - ) -} - -fn history_directory(client: &str, paths: &[PathBuf]) -> PathBuf { - let identity = paths - .iter() - .map(|p| path_to_string(p)) - .collect::>() - .join("\n"); - paths[0] - .parent() - .unwrap_or_else(|| Path::new(".")) - .join(".cpa-config-history") - .join(client) - .join(sha256_bytes(identity.as_bytes())) -} - -fn version_path(client: &str, paths: &[PathBuf], id: &str) -> Result { - if id.is_empty() || id.len() > 100 || !id.bytes().all(|c| c.is_ascii_digit() || c == b'-') { - return Err("历史版本编号无效".into()); - } - Ok(history_directory(client, paths).join(format!("{id}.json"))) -} - -fn text(bytes: Option<&[u8]>) -> Result, String> { - bytes - .map(|bytes| std::str::from_utf8(bytes).map_err(|_| "配置不是 UTF-8 文本".to_string())) - .transpose() -} - -fn parse(path: &Path, content: Option<&str>) -> Result { - let Some(content) = content else { - return Ok(serde_json::json!({})); - }; - let content = content.strip_prefix('\u{feff}').unwrap_or(content); - let result = match path.extension().and_then(|v| v.to_str()) { - Some("toml") => toml::from_str::(content) - .map_err(|e| e.to_string()) - .and_then(|v| serde_json::to_value(v).map_err(|e| e.to_string())), - Some("yaml" | "yml") => serde_norway::from_str::(content).map_err(|e| e.to_string()), - _ => json5::from_str::(content).map_err(|e| e.to_string()), - }; - let value = result.map_err(|e| format!("{} 配置格式错误: {e}", path_to_string(path)))?; - if !value.is_object() { - return Err(format!("{} 配置根节点必须是对象", path_to_string(path))); - } - Ok(value) -} - -fn render(path: &Path, value: &Value) -> Result { - match path.extension().and_then(|v| v.to_str()) { - Some("toml") => toml::to_string_pretty(value).map_err(|e| e.to_string()), - Some("yaml" | "yml") => serde_norway::to_string(value).map_err(|e| e.to_string()), - _ => serde_json::to_string_pretty(value) - .map(|v| format!("{v}\n")) - .map_err(|e| e.to_string()), - } -} - -fn collect_changes( - before: Option<&Value>, - after: Option<&Value>, - prefix: Vec, - output: &mut BTreeSet>, -) { - if before == after { - return; - } - let a = before.and_then(Value::as_object); - let b = after.and_then(Value::as_object); - if a.is_some() || b.is_some() { - let keys = a - .into_iter() - .flat_map(|v| v.keys()) - .chain(b.into_iter().flat_map(|v| v.keys())) - .collect::>(); - if keys.is_empty() { - output.insert(prefix); - } else { - for key in keys { - let mut next = prefix.clone(); - next.push(key.clone()); - collect_changes( - a.and_then(|v| v.get(key)), - b.and_then(|v| v.get(key)), - next, - output, - ); - } - } - } else { - output.insert(prefix); - } -} - -fn get<'a>(value: &'a Value, path: &[String]) -> Option<&'a Value> { - let mut value = value; - for part in path { - value = value.get(part)?; - } - Some(value) -} - -fn set(value: &mut Value, path: &[String], item: Option<&Value>) { - if path.is_empty() { - return; - } - if !value.is_object() { - *value = serde_json::json!({}); - } - let object = value.as_object_mut().unwrap(); - if path.len() == 1 { - if let Some(item) = item { - object.insert(path[0].clone(), item.clone()); - } else { - object.remove(&path[0]); - } - } else if item.is_some() || object.contains_key(&path[0]) { - let child = object - .entry(path[0].clone()) - .or_insert_with(|| serde_json::json!({})); - set(child, &path[1..], item); - if child.as_object().is_some_and(|v| v.is_empty()) { - object.remove(&path[0]); - } - } -} - -fn special_keys(client: &str, path: &Path) -> Option<&'static [&'static str]> { - let name = path - .file_name() - .and_then(|v| v.to_str()) - .unwrap_or_default(); - match (client, name) { - ("codex", "auth.json") => Some(&["auth_mode", "OPENAI_API_KEY", "tokens", "last_refresh"]), - ("codex", CODEX_MODEL_CATALOG_FILE) => Some(&["models"]), - ("pi", PI_AGENT_CONFIG_FILE) => Some(&["baseUrl", "apiKey"]), - ("pi", PI_AGENT_SETTINGS_FILE) => Some(&["defaultProvider", "defaultModel"]), - _ => None, - } -} - -fn restore_text( - client: &str, - paths: &[PathBuf], - path: &Path, - current: &str, - target: Option<&str>, -) -> Result, String> { - if let Some(keys) = special_keys(client, path) { - let mut root = parse(path, Some(current))?; - let target_value = parse(path, target)?; - for key in keys { - set(&mut root, &[key.to_string()], target_value.get(key)); - } - return if root.as_object().unwrap().is_empty() && target.is_none() { - Ok(None) - } else { - render(path, &root).map(Some) - }; - } - if client == "claude-desktop" && paths.get(3).is_some_and(|p| p == path) { - let mut root = parse(path, Some(current))?; - let target = parse(path, target)?; - set(&mut root, &["appliedId".into()], target.get("appliedId")); - let mut entries = root - .get("entries") - .and_then(Value::as_array) - .cloned() - .unwrap_or_default(); - let original = target - .get("entries") - .and_then(Value::as_array) - .and_then(|entries| { - entries.iter().find(|e| { - e.get("id").and_then(Value::as_str) == Some(CLAUDE_DESKTOP_PROFILE_ID) - }) - }); - let mut managed = entries - .iter() - .find(|e| e.get("id").and_then(Value::as_str) == Some(CLAUDE_DESKTOP_PROFILE_ID)) - .cloned() - .unwrap_or_else(|| serde_json::json!({})); - entries.retain(|e| e.get("id").and_then(Value::as_str) != Some(CLAUDE_DESKTOP_PROFILE_ID)); - for key in ["id", "name"] { - set( - &mut managed, - &[key.into()], - original.and_then(|v| v.get(key)), - ); - } - if managed.as_object().is_some_and(|o| !o.is_empty()) { - managed["id"] = serde_json::json!(CLAUDE_DESKTOP_PROFILE_ID); - entries.push(managed); - } - if entries.is_empty() { - root.as_object_mut().unwrap().remove("entries"); - } else { - root["entries"] = Value::Array(entries); - } - return render(path, &root).map(Some); - } - let parsed = AgentClient::parse(client)?; - let base_paths = if parsed == AgentClient::Codex { - &paths[..1] - } else { - paths - }; - build_agent_session_restored_bytes( - parsed, - base_paths, - path, - Some(current.as_bytes()), - target.map(str::as_bytes), - )? - .map(|v| String::from_utf8(v).map_err(|e| e.to_string())) - .transpose() -} - -fn managed_fields( - client: &str, - paths: &[PathBuf], - path: &Path, - bytes: Option<&[u8]>, -) -> Result>, String> { - if let Some(keys) = special_keys(client, path) { - return Ok(keys.iter().map(|key| vec![key.to_string()]).collect()); - } - let original = text(bytes)?; - let root = parse(path, original)?; - let empty = render(path, &serde_json::json!({}))?; - let stripped = restore_text(client, paths, path, original.unwrap_or(&empty), None)?; - let stripped_root = parse(path, stripped.as_deref())?; - let mut fields = BTreeSet::new(); - collect_changes(Some(&root), Some(&stripped_root), Vec::new(), &mut fields); - if client == "claude-code" { - for key in ["CLAUDE_CODE_SUBAGENT_MODEL", "CLAUDE_CODE_EFFORT_LEVEL"] { - fields.insert(vec!["env".into(), key.into()]); - } - if let Some(env) = root.get("env").and_then(Value::as_object) { - for key in env.keys().filter(|key| { - key.starts_with("ANTHROPIC_DEFAULT_") - || key.starts_with("ANTHROPIC_MODEL_") - || key.starts_with("ANTHROPIC_CUSTOM_MODEL_OPTION") - }) { - fields.insert(vec!["env".into(), key.clone()]); - } - } - } - fields.remove(&Vec::new()); - Ok(fields.into_iter().collect()) -} - -fn snapshot_model(images: &Images) -> Option { - for (path, bytes) in images { - let Ok(root) = parse(path, text(bytes.as_deref()).ok().flatten()) else { - continue; - }; - for pointer in [ - "/model", - "/model/default", - "/model/main", - "/defaultModel", - "/env/ANTHROPIC_MODEL", - "/agent-default-model/model", - "/inferenceModels/0/labelOverride", - ] { - if let Some(value) = root.pointer(pointer).and_then(Value::as_str) { - return Some(value.into()); - } - } - } - None -} - -// Package commands remain responsible for installation. Only their known config files are snapshotted. -pub(crate) fn history_package_operation( - home: &Path, - source: &str, - operation: impl FnOnce() -> Result<(), String>, -) -> Result<(), String> { - let _guard = AGENT_CONFIG_FILE_LOCK - .lock() - .map_err(|_| "配置文件锁已损坏")?; - let paths = history_paths(PI_AGENT_ID, home)?; - let before = history_images(&paths)?; - let prior = make_version( - PI_AGENT_ID, - &paths, - &before, - &format!("before-{source}"), - None, - ); - write_version(&paths, &prior)?; - if history_images(&paths)? != before { - return Err("备份期间配置发生变化,请刷新后重试".into()); - } - let result = operation().and_then(|_| { - let after = history_images(&paths)?; - write_version( - &paths, - &make_version(PI_AGENT_ID, &paths, &after, source, None), - ) - }); - if let Err(error) = result { - return match history_write_images(PI_AGENT_ID, &before) { - Ok(()) => Err(error), - Err(rollback) => Err(format!( - "{error};配置回滚失败: {rollback},原内容仍在配置历史中" - )), - }; - } - Ok(()) -} - -fn make_version( - client: &str, - paths: &[PathBuf], - images: &Images, - source: &str, - model: Option, -) -> HistoryVersion { - let now = chrono::Utc::now(); - HistoryVersion { - version: 1, - id: format!( - "{}-{}-{}", - now.format("%Y%m%d%H%M%S%f"), - std::process::id(), - SEQUENCE.fetch_add(1, Ordering::Relaxed) - ), - client: client.into(), - created_at: now.to_rfc3339(), - source: source.into(), - model: model.or_else(|| snapshot_model(images)), - legacy_identity: None, - mappings: None, - files: images - .iter() - .map(|(path, bytes)| HistoryFile { - path: path.clone(), - bytes: bytes.clone(), - hash: image_hash(bytes.as_deref()), - managed_fields: managed_fields(client, paths, path, bytes.as_deref()) - .unwrap_or_default(), - }) - .collect(), - } -} - -fn write_version(paths: &[PathBuf], version: &HistoryVersion) -> Result<(), String> { - let path = version_path(&version.client, paths, &version.id)?; - let parent = path.parent().unwrap(); - fs::create_dir_all(parent).map_err(|e| format!("创建配置历史目录失败: {e}"))?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(parent, fs::Permissions::from_mode(0o700)) - .map_err(|e| e.to_string())?; - } - let mut options = fs::OpenOptions::new(); - options.write(true).create_new(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options.mode(0o600); - } - let mut file = options - .open(&path) - .map_err(|e| format!("创建配置历史失败: {e}"))?; - let bytes = serde_json::to_vec(version).map_err(|e| e.to_string())?; - if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) { - drop(file); - let _ = fs::remove_file(path); - return Err(format!("保存配置历史失败: {error}")); - } - Ok(()) -} - -fn read_version(client: &str, paths: &[PathBuf], id: &str) -> Result { - let bytes = - fs::read(version_path(client, paths, id)?).map_err(|e| format!("读取配置历史失败: {e}"))?; - let version: HistoryVersion = - serde_json::from_slice(&bytes).map_err(|e| format!("配置历史损坏: {e}"))?; - if version.version != 1 - || version.client != client - || version.id != id - || version.files.len() != paths.len() - || version.files.iter().zip(paths).any(|(file, path)| { - file.path != *path || file.hash != image_hash(file.bytes.as_deref()) - }) - { - return Err("配置历史路径或内容校验失败".into()); - } - Ok(version) -} - -fn versions(client: &str, paths: &[PathBuf]) -> Result<(Vec, Vec), String> { - let dir = history_directory(client, paths); - if !dir.exists() { - return Ok((Vec::new(), Vec::new())); - } - let mut result = Vec::new(); - let mut warnings = Vec::new(); - for entry in fs::read_dir(dir).map_err(|e| e.to_string())? { - let path = entry.map_err(|e| e.to_string())?.path(); - if path.extension().and_then(|v| v.to_str()) != Some("json") { - continue; - } - let Some(id) = path.file_stem().and_then(|v| v.to_str()) else { - continue; - }; - match read_version(client, paths, id) { - Ok(version) => result.push(version), - Err(_) => warnings.push(format!("历史版本 {id} 校验失败,已保留但不可恢复")), - } - } - result.sort_by(|a, b| b.id.cmp(&a.id)); - Ok((result, warnings)) -} - -pub(crate) fn history_write_images(client: &str, images: &Images) -> Result<(), String> { - for (path, bytes) in images { - if read_agent_bytes(path)? == *bytes { - continue; - } - if let Some(bytes) = bytes { - if client == PI_AGENT_ID { - write_bytes_directly(path, bytes)?; - } else { - write_agent_configuration_file(AgentClient::parse(client)?, path, bytes)?; - } - } else if path.exists() { - fs::remove_file(path).map_err(|e| format!("删除配置失败: {e}"))?; - } - } - Ok(()) -} - -pub(crate) fn commit_history( - client: &str, - paths: &[PathBuf], - before: &Images, - after: &Images, - source: &str, - model: Option, -) -> Result { - commit_history_with_mappings(client, paths, before, after, source, model, None) -} - -fn commit_history_with_mappings( - client: &str, - paths: &[PathBuf], - before: &Images, - after: &Images, - source: &str, - model: Option, - mappings: Option, -) -> Result { - commit_history_with_writer( - client, - paths, - before, - after, - source, - model, - mappings, - &mut history_write_images, - ) -} - -fn commit_history_with_writer( - client: &str, - paths: &[PathBuf], - before: &Images, - after: &Images, - source: &str, - model: Option, - mappings: Option, - writer: &mut impl FnMut(&str, &Images) -> Result<(), String>, -) -> Result { - if before.len() != paths.len() - || after.len() != paths.len() - || before - .iter() - .zip(paths) - .any(|((p, _), expected)| p != expected) - || after - .iter() - .zip(paths) - .any(|((p, _), expected)| p != expected) - { - return Err("配置更新路径不匹配".into()); - } - let changed = before - .iter() - .zip(after) - .filter(|((_, a), (_, b))| a != b) - .map(|((p, _), _)| path_to_string(p)) - .collect::>(); - if image_revision(&history_images(paths)?) != image_revision(before) { - return Err("配置已被其他程序修改,请刷新后重试".into()); - } - let prior_mappings = matching_desktop_mappings(client, paths, before); - let next_mappings = mappings.or_else(|| matching_desktop_mappings(client, paths, after)); - if changed.is_empty() && prior_mappings == next_mappings { - return Ok(action_result( - "unchanged", - true, - model, - Vec::new(), - Vec::new(), - )); - } - let mut prior = make_version(client, paths, before, &format!("before-{source}"), None); - prior.mappings = prior_mappings; - if let Some(mappings) = prior.mappings.as_ref() { - prior.model = Some(mappings.opus.clone()); - } - let mut next = make_version(client, paths, after, source, model.clone()); - next.mappings = next_mappings; - write_version(paths, &prior)?; - if image_revision(&history_images(paths)?) != image_revision(before) { - return Err("备份期间配置发生变化,请刷新后重试".into()); - } - let transaction = writer(client, after).and_then(|_| write_version(paths, &next)); - if let Err(error) = transaction { - return match writer(client, before) { - Ok(()) => Err(error), - Err(rollback) => Err(format!( - "{error};回滚失败: {rollback},原内容仍在配置历史中" - )), - }; - } - let mut result = action_result("updated", true, model, changed, Vec::new()); - result.history_version = Some(next.id); - Ok(result) -} - -pub(crate) fn history_updates( - client: &str, - home: &Path, - before: &Images, - updates: &[AgentFileUpdate], - source: &str, - model: Option, - mappings: Option<&ClaudeDesktopModelMappings>, -) -> Result { - let paths = history_paths(client, home)?; - if source != "default" { - for (path, bytes) in before { - parse(path, text(bytes.as_deref())?)?; - } - } - let mut after = before.clone(); - for update in updates { - let entry = after - .iter_mut() - .find(|(path, _)| path == &update.path) - .ok_or("配置更新路径不匹配")?; - let mut rendered = update.after.clone(); - if client == "codex" - && source != "default" - && update.path.file_name().and_then(|v| v.to_str()) == Some(CODEX_MODEL_CATALOG_FILE) - { - let mut root = parse(&update.path, text(entry.1.as_deref())?)?; - let generated = parse(&update.path, Some(&rendered))?; - set(&mut root, &["models".into()], generated.get("models")); - if root != generated { - rendered = render(&update.path, &root)?; - } - } - // Ignore format-only changes; preserve exact existing bytes on a semantic no-op. - let same = text(entry.1.as_deref()) - .ok() - .flatten() - .and_then(|value| parse(&update.path, Some(value)).ok()) - .zip(parse(&update.path, Some(&rendered)).ok()) - .is_some_and(|(a, b)| a == b); - if !same { - entry.1 = Some(rendered.into_bytes()); - } - } - commit_history_with_mappings( - client, - &paths, - before, - &after, - source, - model, - mappings.cloned(), - ) -} - -fn restored_images( - client: &str, - paths: &[PathBuf], - current: &Images, - version: &HistoryVersion, -) -> Result { - current - .iter() - .zip(&version.files) - .map(|((path, bytes), target)| { - let root = parse(path, text(bytes.as_deref())?)?; - let target_root = parse(path, text(target.bytes.as_deref())?)?; - // Derive ownership from trusted restoration rules, not arbitrary paths from a history file. - let fields = managed_fields(client, paths, path, bytes.as_deref())? - .into_iter() - .chain(managed_fields( - client, - paths, - path, - target.bytes.as_deref(), - )?) - .collect::>(); - let mut projected = serde_json::json!({}); - for field in &fields { - set(&mut projected, field, get(&target_root, field)); - } - // Provider lists mix app-owned and user entries. Project only our provider. - if client == "hermes" { - if let Some(providers) = projected - .get_mut("custom_providers") - .and_then(Value::as_array_mut) - { - providers.retain(|p| { - p.get("name").and_then(Value::as_str) == Some(MANAGED_AGENT_PROVIDER_ID) - }); - for provider in providers { - if let Some(object) = provider.as_object_mut() { - object.retain(|key, _| { - ["name", "base_url", "api_key", "api_mode", "model", "models"] - .contains(&key.as_str()) - }); - } - } - } - } - let projected_text = render(path, &projected)?; - let empty = render(path, &serde_json::json!({}))?; - let base = text(bytes.as_deref())?.unwrap_or(&empty); - let restored = restore_text(client, paths, path, base, Some(&projected_text))?; - let mut restored_root = parse(path, restored.as_deref())?; - // Include explicitly managed environment fields not covered by legacy restore helpers. - if client == "claude-code" { - for field in &fields { - set(&mut restored_root, field, get(&target_root, field)); - } - } - let rendered = if parse(path, restored.as_deref())? != restored_root { - Some(render(path, &restored_root)?) - } else { - restored - }; - let result = if root == restored_root { - bytes.clone() - } else if restored_root.as_object().is_some_and(|v| v.is_empty()) - && target.bytes.is_none() - { - None - } else { - rendered.map(String::into_bytes) - }; - Ok((path.clone(), result)) - }) - .collect() -} - -fn hidden(value: Option<&Value>, field: &[String]) -> String { - let Some(value) = value else { - return "—".into(); - }; - if field.iter().any(|part| { - let key = part.to_lowercase(); - ["key", "token", "auth", "secret", "credential", "password"] - .iter() - .any(|s| key.contains(s)) - }) { - return "••••••".into(); - } - // Arrays/objects may contain credentials; display only their type and size. - if let Some(array) = value.as_array() { - return format!("[{} items]", array.len()); - } - if value.is_object() { - return "{…}".into(); - } - let rendered = value - .as_str() - .map(str::to_string) - .unwrap_or_else(|| value.to_string()); - if rendered.contains("://") && (rendered.contains('@') || rendered.contains('?')) { - return "••••••".into(); - } - rendered.chars().take(180).collect() -} - -fn preview( - client: &str, - paths: &[PathBuf], - id: &str, -) -> Result<(HistoryPreview, Images, Images), String> { - let version = read_version(client, paths, id)?; - let current = history_images(paths)?; - let after = restored_images(client, paths, ¤t, &version)?; - let mut differences = Vec::new(); - for ((path, bytes), (_, next)) in current.iter().zip(&after) { - let root = parse(path, text(bytes.as_deref())?)?; - let target = parse(path, text(next.as_deref())?)?; - let mut fields = BTreeSet::new(); - collect_changes(Some(&root), Some(&target), Vec::new(), &mut fields); - for field in fields { - differences.push(HistoryDifference { - file: path_to_string(path), - field: field.join("."), - before: hidden(get(&root, &field), &field), - after: hidden(get(&target, &field), &field), - }); - } - } - let current_mappings = matching_desktop_mappings(client, paths, ¤t); - if client == "claude-desktop" && current_mappings != version.mappings { - let before = serde_json::to_value(¤t_mappings).map_err(|e| e.to_string())?; - let after = serde_json::to_value(&version.mappings).map_err(|e| e.to_string())?; - let mut fields = BTreeSet::new(); - collect_changes(Some(&before), Some(&after), Vec::new(), &mut fields); - for field in fields { - differences.push(HistoryDifference { - file: path_to_string(&paths[2]), - field: format!("modelMappings.{}", field.join(".")), - before: hidden(get(&before, &field), &field), - after: hidden(get(&after, &field), &field), - }); - } - } - let revision = sha256_bytes( - format!("{}:{}", image_revision(¤t), serde_json::to_string(¤t_mappings).map_err(|e| e.to_string())?).as_bytes(), - ); - Ok(( - HistoryPreview { - revision, - differences, - }, - current, - after, - )) -} - -#[tauri::command] -pub(crate) fn list_agent_config_history( - app: tauri::AppHandle, - client: String, -) -> Result { - let home = app.path().home_dir().map_err(|e| e.to_string())?; - let _guard = AGENT_CONFIG_FILE_LOCK - .lock() - .map_err(|_| "配置文件锁已损坏")?; - let paths = history_paths(&client, &home)?; - let mut warnings = import_legacy_history(&client, &home, &paths)?; - let (entries, errors) = versions(&client, &paths)?; - warnings.extend(errors); - Ok(HistoryList { - versions: entries - .into_iter() - .map(|v| HistorySummary { - id: v.id, - created_at: v.created_at, - source: v.source, - model: v.model, - file_count: v.files.len(), - }) - .collect(), - warnings, - }) -} - -pub(crate) fn import_legacy_history( - client: &str, - home: &Path, - paths: &[PathBuf], -) -> Result, String> { - if client == PI_AGENT_ID { - return Ok(Vec::new()); - } - let parsed = AgentClient::parse(client)?; - let state_path = agent_state_path(&agent_config_paths(parsed, home))?; - let raw = read_agent_bytes(&state_path)?.or_else(|| { - CODEX_APPLIED_STATES.lock().ok().and_then(|states| { - states - .get(&state_path) - .and_then(|state| serde_json::to_vec(state).ok()) - }) - }); - let Some(raw) = raw else { - let found = paths.iter().any(|path| { - latest_dated_agent_backup_path(path) - .ok() - .flatten() - .is_some() - }); - return Ok(if found { - vec!["发现没有可验证记录的旧备份,已保留原文件".into()] - } else { - Vec::new() - }); - }; - let identity = sha256_bytes(&raw); - if versions(client, paths)? - .0 - .iter() - .any(|v| v.legacy_identity.as_deref() == Some(&identity)) - { - return Ok(Vec::new()); - } - let mut images = Vec::new(); - if let Ok(record) = serde_json::from_slice::(&raw) { - if validate_agent_record(parsed, &agent_config_paths(parsed, home), &record).is_err() { - return Ok(vec!["旧备份记录无法验证,已保留原文件".into()]); - } - for path in paths { - let Some(file) = record.files.iter().find(|f| &f.path == path) else { - return Ok(vec!["旧备份文件范围不完整,已保留原文件".into()]); - }; - let bytes = if file.existed_before { - read_agent_bytes(&file.backup_path)? - } else { - None - }; - if file.existed_before - && (bytes.is_none() - || file.original_sha256.as_deref() != Some(&image_hash(bytes.as_deref()))) - { - return Ok(vec!["旧备份内容校验失败,已保留原文件".into()]); - } - images.push((path.clone(), bytes)); - } - } else if let Ok(state) = serde_json::from_slice::(&raw) { - if validate_agent_applied_state(parsed, &agent_config_paths(parsed, home), &state).is_err() - { - return Ok(vec!["旧应用状态无法验证,已保留原文件".into()]); - } - for path in paths { - let Some(file) = state.backup_files.iter().find(|f| &f.path == path) else { - return Ok(vec!["旧备份文件范围不完整,已保留原文件".into()]); - }; - let bytes = if file.existed_before { - read_agent_bytes(&file.backup_path)? - } else { - None - }; - if file.existed_before && bytes.is_none() { - return Ok(vec!["旧备份文件缺失,已保留原记录".into()]); - } - images.push((path.clone(), bytes)); - } - } else { - return Ok(vec!["旧备份记录无法读取,已保留原文件".into()]); - } - let mut version = make_version(client, paths, &images, "legacy", None); - version.legacy_identity = Some(identity); - write_version(paths, &version)?; - Ok(Vec::new()) -} - -fn matching_desktop_mappings( - client: &str, - paths: &[PathBuf], - images: &Images, -) -> Option { - if client != "claude-desktop" { - return None; - } - let profile = images.get(2)?; - let current = parse(&profile.0, text(profile.1.as_deref()).ok()?).ok()?; - let models = current.get("inferenceModels")?; - versions(client, paths) - .ok()? - .0 - .into_iter() - .find_map(|version| { - let mappings = version.mappings?; - let saved = version.files.get(2)?; - let root = parse(&saved.path, text(saved.bytes.as_deref()).ok()?).ok()?; - (root.get("inferenceModels") == Some(models)).then_some(mappings) - }) -} - -pub(crate) fn current_desktop_history_mappings(home: &Path) -> Option { - let paths = history_paths("claude-desktop", home).ok()?; - let images = history_images(&paths).ok()?; - if let Some(mappings) = matching_desktop_mappings("claude-desktop", &paths, &images) { - return Some(mappings); - } - // Compatibility metadata is read without invoking the old state migration/write path. - let path = agent_state_path(&paths).ok()?; - let state: AgentAppliedState = serde_json::from_slice(&fs::read(path).ok()?).ok()?; - validate_agent_applied_state(AgentClient::ClaudeDesktop, &paths, &state).ok()?; - state.claude_desktop_model_mappings -} - -#[cfg(test)] -pub(crate) fn test_restore_history(client: AgentClient, home: &Path, source: &str) { - let paths = history_paths(client.id(), home).unwrap(); - let entries = versions(client.id(), &paths).unwrap().0; - let version = entries - .iter() - .rev() - .find(|v| v.source == source) - .expect("history source"); - let (_, before, after) = preview(client.id(), &paths, &version.id).unwrap(); - commit_history( - client.id(), - &paths, - &before, - &after, - "restore", - version.model.clone(), - ) - .unwrap(); -} -#[cfg(test)] -pub(crate) fn test_history_count(client: AgentClient, home: &Path) -> usize { - versions(client.id(), &history_paths(client.id(), home).unwrap()) - .unwrap() - .0 - .len() -} - -#[cfg(test)] -mod tests; diff --git a/src-tauri/src/agents/history/tests.rs b/src-tauri/src/agents/history/tests.rs deleted file mode 100644 index 776aa12..0000000 --- a/src-tauri/src/agents/history/tests.rs +++ /dev/null @@ -1,448 +0,0 @@ -use super::*; -struct Home(PathBuf); -impl Home { - fn new() -> Self { - let path = std::env::temp_dir().join(format!( - "cpa-history-{}-{}", - std::process::id(), - SEQUENCE.fetch_add(1, Ordering::Relaxed) - )); - fs::create_dir_all(&path).unwrap(); - Self(path) - } -} -impl Drop for Home { - fn drop(&mut self) { - let _ = fs::remove_dir_all(&self.0); - } -} -fn save(path: &Path, value: &str) { - fs::create_dir_all(path.parent().unwrap()).unwrap(); - fs::write(path, value).unwrap(); -} -fn restore_case(client: &str, originals: &[&str], currents: &[&str]) -> Vec { - let home = Home::new(); - let paths = history_paths(client, &home.0).unwrap(); - for (p, v) in paths.iter().zip(originals) { - save(p, v); - } - let version = make_version( - client, - &paths, - &history_images(&paths).unwrap(), - "update", - None, - ); - for (p, v) in paths.iter().zip(currents) { - save(p, v); - } - let after = - restored_images(client, &paths, &history_images(&paths).unwrap(), &version).unwrap(); - after - .iter() - .map(|(p, b)| parse(p, text(b.as_deref()).unwrap()).unwrap()) - .collect() -} -#[test] -fn codex_restore_keeps_new_custom_settings_and_does_not_resurrect_deleted_settings() { - let result = restore_case("codex", &[ - "model = 'old'\nremoved_custom = true\n[model_providers.cpa-gui]\nbase_url = 'http://old'\ncustom_deleted = true\n", - r#"{"models":[],"deleted_metadata":1}"#, - r#"{"tokens":{"access_token":"old-secret"},"deleted_custom":true}"#, - ], &[ - "# Keep comment\nmodel = 'new'\nnew_custom = 42\n[model_providers.cpa-gui]\nbase_url = 'http://new'\ncustom_added = true\n", - r#"{"models":[{"slug":"new"}],"new_metadata":2}"#, - r#"{"OPENAI_API_KEY":"new-secret","new_custom":42}"#, - ]); - assert_eq!(result[0]["model"], "old"); - assert_eq!(result[0]["new_custom"], 42); - assert!(result[0].get("removed_custom").is_none()); - assert!(result[0]["model_providers"]["cpa-gui"] - .get("custom_deleted") - .is_none()); - assert_eq!( - result[0]["model_providers"]["cpa-gui"]["custom_added"], - true - ); - assert_eq!(result[1]["new_metadata"], 2); - assert!(result[1].get("deleted_metadata").is_none()); - assert_eq!(result[2]["tokens"]["access_token"], "old-secret"); - assert!(result[2].get("OPENAI_API_KEY").is_none()); - assert_eq!(result[2]["new_custom"], 42); - assert!(result[2].get("deleted_custom").is_none()); -} -#[test] -fn claude_restore_removes_absent_managed_fields_and_preserves_custom_env() { - let result = restore_case( - "claude-code", - &[r#"{"env":{"ANTHROPIC_MODEL":"old","DELETED":"x"}}"#], - &[ - r#"{"env":{"ANTHROPIC_MODEL":"new","ANTHROPIC_DEFAULT_SONNET_MODEL":"new","CLAUDE_CODE_SUBAGENT_MODEL":"new","CUSTOM":"keep"}}"#, - ], - ); - assert_eq!(result[0]["env"]["ANTHROPIC_MODEL"], "old"); - assert_eq!(result[0]["env"]["CUSTOM"], "keep"); - for key in [ - "DELETED", - "ANTHROPIC_DEFAULT_SONNET_MODEL", - "CLAUDE_CODE_SUBAGENT_MODEL", - ] { - assert!(result[0]["env"].get(key).is_none()); - } -} -#[test] -fn hermes_restore_does_not_resurrect_deleted_unmanaged_provider() { - let result = restore_case("hermes", &["custom_providers:\n - name: personal\n api_key: custom\n - name: cpa-gui\n api_key: old\n custom_deleted: true\n"], &["theme: dark\n"]); - assert_eq!(result[0]["theme"], "dark"); - let providers = result[0]["custom_providers"].as_array().unwrap(); - assert_eq!(providers.len(), 1); - assert_eq!(providers[0]["name"], "cpa-gui"); - assert!(providers[0].get("custom_deleted").is_none()); -} -#[test] -fn pi_restore_leaves_packages_and_custom_values_untouched() { - let result = restore_case( - "pi", - &[ - r#"{"apiKey":"old"}"#, - r#"{"defaultModel":"old","packages":["old-package"]}"#, - ], - &[ - r#"{"apiKey":"new","custom":1}"#, - r#"{"defaultModel":"new","packages":["new-package"],"theme":"dark"}"#, - ], - ); - assert_eq!(result[0]["apiKey"], "old"); - assert_eq!(result[0]["custom"], 1); - assert_eq!(result[1]["defaultModel"], "old"); - assert_eq!(result[1]["packages"][0], "new-package"); -} -#[test] -fn missing_files_restore_and_invalid_current_file_is_rejected() { - let home = Home::new(); - let paths = history_paths("codex", &home.0).unwrap(); - let absent = history_images(&paths).unwrap(); - let missing = make_version("codex", &paths, &absent, "before-update", None); - save(&paths[0], "model = 'new'\n"); - let after = - restored_images("codex", &paths, &history_images(&paths).unwrap(), &missing).unwrap(); - assert!(after[0].1.is_none()); - let saved = make_version( - "codex", - &paths, - &history_images(&paths).unwrap(), - "update", - None, - ); - let after = restored_images("codex", &paths, &absent, &saved).unwrap(); - assert!(after[0].1.is_some()); - save(&paths[0], "{{invalid"); - assert!(restored_images("codex", &paths, &history_images(&paths).unwrap(), &saved).is_err()); -} -#[test] -fn backup_failure_and_external_conflict_leave_current_files_untouched() { - let home = Home::new(); - let paths = history_paths("codex", &home.0).unwrap(); - save(&paths[0], "model = 'old'\n"); - let before = history_images(&paths).unwrap(); - let mut after = before.clone(); - after[0].1 = Some(b"model = 'new'\n".to_vec()); - save(&paths[0], "model = 'external'\n"); - assert!( - commit_history("codex", &paths, &before, &after, "update", None) - .err() - .unwrap() - .contains("其他程序") - ); - assert_eq!( - fs::read_to_string(&paths[0]).unwrap(), - "model = 'external'\n" - ); - fs::write(&paths[0], before[0].1.as_ref().unwrap()).unwrap(); - let dir = history_directory("codex", &paths); - fs::create_dir_all(dir.parent().unwrap()).unwrap(); - fs::write(&dir, "blocked").unwrap(); - assert!(commit_history("codex", &paths, &before, &after, "update", None).is_err()); - assert_eq!(history_images(&paths).unwrap(), before); -} -#[test] -fn partial_multi_file_write_rolls_back_and_keeps_recovery_snapshot() { - let home = Home::new(); - let paths = history_paths("codex", &home.0).unwrap(); - save(&paths[0], "model = 'old'\n"); - let before = history_images(&paths).unwrap(); - let mut after = before.clone(); - after[0].1 = Some(b"model = 'new'\n".to_vec()); - after[1].1 = Some(b"{\"models\":[]}".to_vec()); - let mut first = true; - let result = commit_history_with_writer( - "codex", - &paths, - &before, - &after, - "update", - None, - None, - &mut |client, images| { - if first { - first = false; - history_write_images(client, &vec![images[0].clone()])?; - return Err("injected second-file failure".into()); - } - history_write_images(client, images) - }, - ); - assert!(result.is_err()); - assert_eq!(history_images(&paths).unwrap(), before); - assert_eq!(versions("codex", &paths).unwrap().0.len(), 1); -} -#[test] -fn preview_masks_auth_and_revision_tracks_external_changes_and_versions_are_validated() { - let home = Home::new(); - let paths = history_paths("codex", &home.0).unwrap(); - save(&paths[2], r#"{"OPENAI_API_KEY":"secret-old"}"#); - let version = make_version( - "codex", - &paths, - &history_images(&paths).unwrap(), - "update", - None, - ); - write_version(&paths, &version).unwrap(); - save(&paths[2], r#"{"OPENAI_API_KEY":"secret-new"}"#); - let first = preview("codex", &paths, &version.id).unwrap().0; - assert_eq!(first.differences[0].before, "••••••"); - assert_eq!(first.differences[0].after, "••••••"); - save(&paths[0], "custom = true\n"); - assert_ne!( - first.revision, - preview("codex", &paths, &version.id).unwrap().0.revision - ); - assert!(read_version("codex", &paths, "../escape").is_err()); - let mut tampered = version.clone(); - tampered.files[0].path = home.0.join("outside"); - fs::write( - version_path("codex", &paths, &version.id).unwrap(), - serde_json::to_vec(&tampered).unwrap(), - ) - .unwrap(); - assert!(read_version("codex", &paths, &version.id).is_err()); -} -#[test] -fn verified_legacy_import_is_deduplicated_and_keeps_original_backup() { - let home = Home::new(); - let paths = history_paths("opencode", &home.0).unwrap(); - save(&paths[0], r#"{"model":"current"}"#); - let backup = agent_backup_path(&paths[0]).unwrap(); - save(&backup, r#"{"model":"old"}"#); - let record = AgentModificationRecord { - version: AGENT_MODIFICATION_STATE_VERSION, - client: "opencode".into(), - phase: AGENT_PHASE_ACTIVE.into(), - model: "current".into(), - files: vec![AgentModificationFile { - path: paths[0].clone(), - backup_path: backup.clone(), - existed_before: true, - original_sha256: Some(sha256_bytes(&fs::read(&backup).unwrap())), - managed_sha256: String::new(), - }], - }; - save( - &agent_state_path(&paths).unwrap(), - &serde_json::to_string(&record).unwrap(), - ); - assert!(import_legacy_history("opencode", &home.0, &paths) - .unwrap() - .is_empty()); - assert!(import_legacy_history("opencode", &home.0, &paths) - .unwrap() - .is_empty()); - assert_eq!(versions("opencode", &paths).unwrap().0.len(), 1); - assert!(backup.exists()); - let mut bad = record; - bad.files[0].original_sha256 = Some("wrong".into()); - save( - &agent_state_path(&paths).unwrap(), - &serde_json::to_string(&bad).unwrap(), - ); - assert!(!import_legacy_history("opencode", &home.0, &paths) - .unwrap() - .is_empty()); - assert!(backup.exists()); -} -#[test] -fn semantic_noop_preserves_exact_comments_and_does_not_save_history() { - let home = Home::new(); - let paths = history_paths("opencode", &home.0).unwrap(); - save(&paths[0], "// Keep this comment\n{model: 'old'}\n"); - let before = history_images(&paths).unwrap(); - let result = history_updates( - "opencode", - &home.0, - &before, - &[AgentFileUpdate { - path: paths[0].clone(), - after: r#"{"model":"old"}"#.into(), - }], - "update", - None, - None, - ) - .unwrap(); - assert_eq!(result.outcome, "unchanged"); - assert_eq!(history_images(&paths).unwrap(), before); - assert_eq!(versions("opencode", &paths).unwrap().0.len(), 0); - let target = make_version("opencode", &paths, &before, "update", None); - save( - &paths[0], - "// Current comment\n{model: 'new',custom: true}\n", - ); - let after = restored_images( - "opencode", - &paths, - &history_images(&paths).unwrap(), - &target, - ) - .unwrap(); - assert!(text(after[0].1.as_deref()) - .unwrap() - .unwrap() - .contains("// Current comment")); -} -#[test] -fn catalog_update_preserves_custom_metadata_and_rejects_invalid_existing_catalog() { - let home = Home::new(); - let paths = history_paths("codex", &home.0).unwrap(); - save(&paths[1], r#"{"models":[],"custom":42}"#); - let updates = vec![AgentFileUpdate { - path: paths[1].clone(), - after: r#"{"models":[{"slug":"new"}]}"#.into(), - }]; - history_updates( - "codex", - &home.0, - &history_images(&paths).unwrap(), - &updates, - "sync", - None, - None, - ) - .unwrap(); - assert_eq!( - parse(&paths[1], Some(&fs::read_to_string(&paths[1]).unwrap())).unwrap()["custom"], - 42 - ); - save(&paths[1], "invalid"); - assert!(history_updates( - "codex", - &home.0, - &history_images(&paths).unwrap(), - &updates, - "update", - None, - None - ) - .is_err()); - assert_eq!(fs::read_to_string(&paths[1]).unwrap(), "invalid"); -} -#[test] -fn pi_package_changes_are_backed_up_and_failures_roll_back_only_config_files() { - let home = Home::new(); - let paths = history_paths("pi", &home.0).unwrap(); - save(&paths[1], r#"{"packages":[]}"#); - history_package_operation(&home.0, "plugin-install", || { - save(&paths[1], r#"{"packages":["installed"]}"#); - Ok(()) - }) - .unwrap(); - assert_eq!(versions("pi", &paths).unwrap().0.len(), 2); - let before = history_images(&paths).unwrap(); - let package = home.0.join("package-installed"); - assert!(history_package_operation(&home.0, "plugin-update", || { - save(&paths[1], "{}"); - save(&package, "package is outside config history"); - Err("package failure".into()) - }) - .is_err()); - assert_eq!(history_images(&paths).unwrap(), before); - assert!(package.exists()); -} -#[test] -fn desktop_profile_index_restores_only_managed_entry_fields() { - let home = Home::new(); - let paths = history_paths("claude-desktop", &home.0).unwrap(); - let old = serde_json::json!({"appliedId":CLAUDE_DESKTOP_PROFILE_ID,"entries":[{"id":CLAUDE_DESKTOP_PROFILE_ID,"name":"old","deleted_custom":true},{"id":"deleted-personal"}]}); - save(&paths[3], &old.to_string()); - let version = make_version( - "claude-desktop", - &paths, - &history_images(&paths).unwrap(), - "update", - None, - ); - let current = serde_json::json!({"entries":[{"id":CLAUDE_DESKTOP_PROFILE_ID,"name":"new","new_custom":42},{"id":"new-personal"}]}); - save(&paths[3], ¤t.to_string()); - let after = restored_images( - "claude-desktop", - &paths, - &history_images(&paths).unwrap(), - &version, - ) - .unwrap(); - let value = parse(&paths[3], text(after[3].1.as_deref()).unwrap()).unwrap(); - let entries = value["entries"].as_array().unwrap(); - assert_eq!(entries.len(), 2); - assert_eq!(entries[0]["id"], "new-personal"); - assert_eq!(entries[1]["name"], "old"); - assert_eq!(entries[1]["new_custom"], 42); - assert!(entries[1].get("deleted_custom").is_none()); -} -#[test] -fn directory_at_config_path_is_an_error() { - let home = Home::new(); - let paths = history_paths("codex", &home.0).unwrap(); - fs::create_dir_all(&paths[0]).unwrap(); - assert!(history_images(&paths).is_err()); -} - -fn desktop_test_models() -> Vec { - ["model-a", "model-b"].into_iter().map(|name| AgentModelOption { - name: name.into(), alias: None, is_alias: false, context_window: Some(200_000), - }).collect() -} - -fn apply_desktop_test_model(home: &Path, model: &str) -> AgentConfigActionResult { - let mappings = ClaudeDesktopModelMappings::all(model); - apply_agent_configuration_with_oauth( - AgentClient::ClaudeDesktop, home, 8317, "test-key", model, - AgentConfigurationOptions { - models: &desktop_test_models(), codex_catalog: None, oauth_configuration: false, - claude_code_model_mappings: None, claude_desktop_model_mappings: Some(&mappings), - }, - ).unwrap() -} - -#[test] -fn desktop_mapping_changes_are_saved_when_profile_bytes_are_identical() { - let home = Home::new(); - let paths = history_paths("claude-desktop", &home.0).unwrap(); - let first = apply_desktop_test_model(&home.0, "model-a"); - let images = history_images(&paths).unwrap(); - let first_id = first.history_version.unwrap(); - let original_preview = preview("claude-desktop", &paths, &first_id).unwrap().0; - let second = apply_desktop_test_model(&home.0, "model-b"); - assert_eq!(second.outcome, "updated"); - assert!(second.changed_files.is_empty()); - assert!(second.history_version.is_some()); - assert_eq!(images, history_images(&paths).unwrap()); - assert_eq!(versions("claude-desktop", &paths).unwrap().0.len(), 4); - assert_eq!(current_desktop_history_mappings(&home.0).unwrap().opus, "model-b"); - let next_preview = preview("claude-desktop", &paths, &first_id).unwrap().0; - assert_ne!(original_preview.revision, next_preview.revision); - assert!(next_preview.differences.iter().any(|diff| diff.field == "modelMappings.opus")); - let unchanged = apply_desktop_test_model(&home.0, "model-b"); - assert_eq!(unchanged.outcome, "unchanged"); - assert!(unchanged.history_version.is_none()); - assert_eq!(versions("claude-desktop", &paths).unwrap().0.len(), 4); -} diff --git a/src-tauri/src/agents/state.rs b/src-tauri/src/agents/state.rs index fb6a57f..0e2cc2a 100644 --- a/src-tauri/src/agents/state.rs +++ b/src-tauri/src/agents/state.rs @@ -1,5 +1,6 @@ use super::*; +#[cfg(test)] pub(crate) fn agent_backup_path(path: &Path) -> Result { let file_name = path .file_name() @@ -28,6 +29,7 @@ pub(crate) fn agent_managed_paths(client: AgentClient, home: &Path) -> Vec Result<(), String> { CODEX_APPLIED_STATES .lock() @@ -62,6 +65,7 @@ pub(crate) fn clear_codex_applied_state(path: &Path) -> Result<(), String> { Ok(()) } +#[cfg(test)] pub(crate) fn is_dated_agent_backup_name(file_name: &str, original_name: &str) -> bool { let Some(date) = file_name .strip_prefix(&format!("{original_name}.")) @@ -78,6 +82,7 @@ pub(crate) fn is_dated_agent_backup_name(file_name: &str, original_name: &str) - && bytes[6..8].iter().all(u8::is_ascii_digit) } +#[cfg(test)] pub(crate) fn latest_dated_agent_backup_path(path: &Path) -> Result, String> { let directory = path.parent().unwrap_or_else(|| Path::new(".")); if !directory.is_dir() { @@ -107,6 +112,7 @@ pub(crate) fn latest_dated_agent_backup_path(path: &Path) -> Result Result, String> { @@ -159,6 +165,7 @@ pub(crate) fn recover_codex_applied_state_from_backups( Ok(Some(state)) } +#[cfg(test)] pub(crate) fn validate_agent_applied_state( client: AgentClient, paths: &[PathBuf], @@ -212,6 +219,7 @@ pub(crate) fn validate_agent_applied_state( Ok(()) } +#[cfg(test)] pub(crate) fn load_agent_applied_state( client: AgentClient, home: &Path, @@ -292,12 +300,12 @@ pub(crate) fn load_agent_applied_state( } pub(crate) fn read_agent_bytes(path: &Path) -> Result>, String> { - if !path.exists() { - return Ok(None); + validate_config_path(path)?; + match fs::read(path) { + Ok(bytes) => Ok(Some(bytes)), + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None), + Err(_) => Err(format!("读取智能体配置失败: {},请检查文件权限或占用情况", path_to_string(path))), } - fs::read(path) - .map(Some) - .map_err(|error| format!("读取智能体配置失败 {}: {error}", path_to_string(path))) } #[cfg(test)] @@ -358,6 +366,7 @@ pub(crate) fn write_agent_state( write_yaml_if_changed(path, &content).map(|_| ()) } +#[cfg(test)] pub(crate) fn validate_agent_record( client: AgentClient, paths: &[PathBuf], @@ -1175,6 +1184,7 @@ pub(crate) fn apply_agent_updates( Ok(changed) } +#[cfg(test)] pub(crate) fn restore_agent_snapshots_direct(snapshots: &[FileSnapshot]) -> Result<(), String> { let mut errors = Vec::new(); for (path, content) in snapshots.iter().rev() { @@ -1195,6 +1205,7 @@ pub(crate) fn restore_agent_snapshots_direct(snapshots: &[FileSnapshot]) -> Resu } } +#[cfg(test)] pub(crate) fn dated_agent_backup_path(path: &Path) -> Result { let file_name = path .file_name() @@ -1204,6 +1215,7 @@ pub(crate) fn dated_agent_backup_path(path: &Path) -> Result { Ok(path.with_file_name(format!("{file_name}.{date}.bak"))) } +#[cfg(test)] pub(crate) fn restore_agent_session_configuration( client: AgentClient, home: &Path, @@ -1216,6 +1228,7 @@ pub(crate) fn restore_agent_session_configuration( restore_agent_applied_state_configuration(client, &paths, &state_path, &state) } +#[cfg(test)] pub(crate) fn restore_agent_applied_state_configuration( client: AgentClient, paths: &[PathBuf], @@ -1319,9 +1332,9 @@ pub(crate) fn commit_agent_configuration( outcome: &str, _claude_desktop_model_mappings: Option<&ClaudeDesktopModelMappings>, ) -> Result { - let paths = history_paths(client.id(), home)?; - let before = history_images(&paths)?; - history_updates(client.id(), home, &before, updates, outcome, Some(model.to_string()), _claude_desktop_model_mappings) + let paths = config_paths(client.id(), home)?; + let before = config_images(&paths)?; + config_updates(client.id(), home, &before, updates, outcome, Some(model.to_string()), _claude_desktop_model_mappings) } pub(crate) fn sync_codex_model_catalog_if_configured( @@ -1336,7 +1349,8 @@ pub(crate) fn sync_codex_model_catalog_if_configured( let _guard = AGENT_CONFIG_FILE_LOCK .lock() .map_err(|_| "智能体配置文件锁已损坏".to_string())?; - let before = history_images(&history_paths(client.id(), home)?)?; + let before = config_images(&config_paths(client.id(), home)?)?; + validate_config_images(&before)?; let (configured, current_model, _) = inspect_agent_managed_config(client, &paths, port, api_key)?; if !configured { @@ -1393,7 +1407,7 @@ pub(crate) fn sync_codex_model_catalog_if_configured( return Ok(false); } - let result = history_updates(client.id(), home, &before, &updates, "sync", next_model.map(str::to_string), None)?; + let result = config_updates(client.id(), home, &before, &updates, "sync", next_model.map(str::to_string), None)?; Ok(!result.changed_files.is_empty()) } @@ -1431,10 +1445,12 @@ pub(crate) fn apply_agent_configuration_with_oauth( model: &str, options: AgentConfigurationOptions<'_>, ) -> Result { - let before = history_images(&history_paths(client.id(), home)?)?; + let before = config_images(&config_paths(client.id(), home)?)?; + validate_config_images(&before)?; let mappings = options.claude_desktop_model_mappings; - let updates = build_agent_updates_with_oauth(client, home, port, api_key, model, options)?; - history_updates(client.id(), home, &before, &updates, "update", Some(model.to_string()), mappings) + let updates = build_agent_updates_with_oauth(client, home, port, api_key, model, options) + .map_err(|_| "配置构建失败,请检查配置结构或使用手动备份恢复、基础配置模板修复".to_string())?; + config_updates(client.id(), home, &before, &updates, "update", Some(model.to_string()), mappings) } #[cfg(test)] @@ -1478,9 +1494,20 @@ pub(crate) struct AgentDefaultConfiguration<'a> { pub(crate) claude_desktop_model_mappings: Option<&'a ClaudeDesktopModelMappings>, } -pub(crate) fn reset_agent_configuration_to_default_with_oauth( +#[cfg(test)] +pub(crate) fn reset_agent_configuration_to_default_with_oauth(request: AgentDefaultConfiguration<'_>) -> Result { + let client = request.client; + let home = request.home; + let model = request.model.to_string(); + let mappings = request.claude_desktop_model_mappings.cloned(); + let before = config_images(&config_paths(client.id(), home)?)?; + let updates = build_agent_template_updates(request)?; + config_updates(client.id(), home, &before, &updates, "template", Some(model), mappings.as_ref()) +} + +pub(crate) fn build_agent_template_updates( request: AgentDefaultConfiguration<'_>, -) -> Result { +) -> Result, String> { let AgentDefaultConfiguration { client, home, @@ -1493,24 +1520,6 @@ pub(crate) fn reset_agent_configuration_to_default_with_oauth( claude_code_model_mappings, claude_desktop_model_mappings, } = request; - let before = history_images(&history_paths(client.id(), home)?)?; - if client == AgentClient::DeepSeekHarness { - let updates = build_agent_updates_with_oauth( - client, - home, - port, - api_key, - model, - AgentConfigurationOptions { - models, - codex_catalog, - oauth_configuration, - claude_code_model_mappings, - claude_desktop_model_mappings, - }, - )?; - return history_updates(client.id(), home, &before, &updates, "default", Some(model.to_string()), claude_desktop_model_mappings); - } let paths = agent_config_paths(client, home); let contents = fresh_agent_contents_with_oauth( client, @@ -1526,7 +1535,7 @@ pub(crate) fn reset_agent_configuration_to_default_with_oauth( }, )?; if paths.len() != contents.len() { - return Err("智能体默认配置文件数量不匹配".to_string()); + return Err("基础配置模板文件数量不匹配".to_string()); } let mut updates = paths .into_iter() @@ -1540,11 +1549,11 @@ pub(crate) fn reset_agent_configuration_to_default_with_oauth( path: codex_model_catalog_path(home), after: catalog.to_string(), }); - updates.push(if oauth_configuration { build_codex_auth_update(home, api_key, true)? } else { + updates.push(if oauth_configuration { build_codex_template_auth(home)? } else { AgentFileUpdate { path: codex_configuration_directory(home).join("auth.json"), after: build_codex_api_auth(api_key)? } }); } - history_updates(client.id(), home, &before, &updates, "default", Some(model.to_string()), claude_desktop_model_mappings) + Ok(updates) } #[cfg(test)] @@ -1663,8 +1672,7 @@ pub(crate) fn action_result( model, changed_files, conflict_files, - history_version: None, - } + } } #[cfg(test)] diff --git a/src-tauri/src/agents/templates.rs b/src-tauri/src/agents/templates.rs new file mode 100644 index 0000000..98ed605 --- /dev/null +++ b/src-tauri/src/agents/templates.rs @@ -0,0 +1,301 @@ +//! Generate and validate the entire template before presenting any destructive confirmation. +use super::*; + +#[cfg(test)] +mod tests; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct TemplatePreview { + revision: String, + files: Vec, +} + +struct TemplatePlan { + client: String, + paths: Vec, + before: Images, + after: Images, + mappings: Option, + mapping_revision: String, + model: String, + core: Option<(String, String)>, + preview: TemplatePreview, +} + +pub(crate) fn build_codex_template_auth(home: &Path) -> Result { + let path = codex_configuration_directory(home).join("auth.json"); + validate_codex_oauth_login_at(&path)?; + let current = read_agent_bytes(&path)?.ok_or(CODEX_OAUTH_LOGIN_REQUIRED_ERROR)?; + let value = parse(&path, text(Some(¤t))?)?; + let mut target = serde_json::Map::new(); + for key in ["auth_mode", "tokens", "last_refresh"] { + if let Some(value) = value.get(key) { + target.insert(key.into(), value.clone()); + } + } + Ok(AgentFileUpdate { + path, + after: serde_json::to_string_pretty(&target).map_err(|_| "生成认证模板失败")?, + }) +} + +pub(crate) fn build_pi_template_updates( + home: &Path, + port: u16, + api_key: &str, + model: &str, +) -> Result, String> { + if port == 0 || api_key.trim().is_empty() { + return Err("CPA 地址或密钥无效".into()); + } + let settings = serde_json::json!({"packages": [PI_CLIPROXYAPI_PACKAGE]}).to_string(); + Ok(vec![ + AgentFileUpdate { + path: pi_provider_config_path(home), + after: build_pi_provider_config(None, &managed_core_loopback_origin(port), api_key)?, + }, + AgentFileUpdate { + path: pi_provider_settings_path(home), + after: build_pi_provider_settings(&settings, model)?, + }, + ]) +} + +pub(crate) async fn prepare_desktop_core_update( + config: &GuiConfigFile, + mappings: &ClaudeDesktopModelMappings, + models: &[AgentModelOption], +) -> Result<(String, String), String> { + let before = fetch_management_config_yaml(config) + .await + .map_err(agent_core_error)?; + let after = match ensure_claude_desktop_model_aliases_in_yaml(&before, mappings, models) { + Ok(after) => after, + Err(_) => { + let definitions = fetch_oauth_model_definitions(config).await; + ensure_claude_desktop_model_aliases_with_oauth_definitions_in_yaml( + &before, + mappings, + models, + &definitions, + ) + .map_err(agent_core_error)? + } + }; + Ok((before, after)) +} + +// Management API/parser errors may quote configuration contents. Keep actionable transaction +// outcomes without forwarding response bodies, YAML source lines or credentials to the UI. +pub(crate) fn agent_core_error(error: String) -> String { + if error.contains("自动恢复失败") || error.contains("回滚失败") { + "内核别名或智能体配置写入失败且回滚失败,请检查当前配置".into() + } else if error.contains("已恢复原配置") { + "内核别名或智能体配置写入失败,已恢复原配置,请检查连接和模型映射后重试".into() + } else if error.contains("配置已变化") { + "内核配置已变化,请重新预览后重试".into() + } else { + "内核模型别名同步失败,请检查内核连接和模型映射".into() + } +} + +pub(crate) async fn commit_agent_with_core( + config: &GuiConfigFile, + mappings: Option<&ClaudeDesktopModelMappings>, + models: &[AgentModelOption], + commit: impl FnOnce() -> Result, +) -> Result { + if let Some(mappings) = mappings { + let (before, after) = prepare_desktop_core_update(config, mappings, models).await?; + commit_management_alias_config_changes(config, &before, &after, commit) + .await + .map_err(agent_core_error) + } else { + commit() + } +} + +async fn prepare_template_plan( + config: &GuiConfigFile, + home: &Path, + client: &str, + model: &str, + oauth_configuration: bool, + claude_code_model_mappings: Option, + claude_desktop_model_mappings: Option, +) -> Result { + let paths = config_paths(client, home)?; + let api_key = effective_agent_api_key(config); + let (model, mappings, before, after) = if client == PI_AGENT_ID { + let model = resolve_pi_default_model(config, model).await?; + let _guard = AGENT_CONFIG_FILE_LOCK + .lock() + .map_err(|_| "配置文件锁已损坏")?; + let before = config_images(&paths)?; + let updates = build_pi_template_updates(home, config.port, api_key, &model)?; + let after = prepare_config_updates(client, &paths, &before, &updates, true)?; + (model, None, before, after) + } else { + let parsed = AgentClient::parse(client)?; + let prepared = fetch_prepared_agent_models(parsed, config).await?; + let model = resolve_available_agent_model(&prepared.models, &validate_agent_model(model)?)?; + let code_mappings = resolve_claude_code_model_mappings( + parsed, + &prepared.models, + &model, + claude_code_model_mappings, + )?; + let mappings = resolve_claude_desktop_model_mappings( + parsed, + &prepared.models, + &model, + claude_desktop_model_mappings, + )?; + let _guard = AGENT_CONFIG_FILE_LOCK + .lock() + .map_err(|_| "配置文件锁已损坏")?; + let before = config_images(&paths)?; + let updates = build_agent_template_updates(AgentDefaultConfiguration { + client: parsed, + home, + port: config.port, + api_key, + model: &model, + models: &prepared.models, + codex_catalog: prepared.codex_catalog.as_deref(), + oauth_configuration, + claude_code_model_mappings: code_mappings.as_ref(), + claude_desktop_model_mappings: mappings.as_ref(), + })?; + let after = prepare_config_updates(client, &paths, &before, &updates, true)?; + (model, mappings, before, after) + }; + let mapping_revision = mapping_revision(client, &paths)?; + let core = if let Some(mappings) = mappings.as_ref() { + let prepared = fetch_prepared_agent_models(AgentClient::ClaudeDesktop, config).await?; + Some(prepare_desktop_core_update(config, mappings, &prepared.models).await?) + } else { + None + }; + let core_revision = core + .as_ref() + .map(|(a, b)| { + Ok::<_, String>(( + model_alias_config_revision(a)?, + model_alias_config_revision(b)?, + )) + }) + .transpose()?; + let revision = sha256_bytes( + &serde_json::to_vec(&( + image_revision(&before), + image_revision(&after), + &mapping_revision, + &mappings, + core_revision, + )) + .map_err(|_| "生成模板预览失败")?, + ); + let preview = TemplatePreview { + revision, + files: paths.iter().map(|p| path_to_string(p)).collect(), + }; + Ok(TemplatePlan { + client: client.into(), + paths, + before, + after, + model, + mappings, + mapping_revision, + core, + preview, + }) +} + +async fn execute_template_plan( + config: &GuiConfigFile, + plan: TemplatePlan, + revision: &str, +) -> Result { + if revision != plan.preview.revision { + return Err("预览后配置或模板发生变化,请重新预览基础配置模板".into()); + } + let commit = || { + let _guard = AGENT_CONFIG_FILE_LOCK + .lock() + .map_err(|_| "配置文件锁已损坏")?; + if mapping_revision(&plan.client, &plan.paths)? != plan.mapping_revision { + return Err("模型映射已变化,请重新预览".into()); + } + commit_config_with_mappings( + &plan.client, + &plan.paths, + &plan.before, + &plan.after, + "template", + Some(plan.model.clone()), + plan.mappings.clone(), + ) + }; + match &plan.core { + Some((before, after)) => { + commit_management_alias_config_changes(config, before, after, commit) + .await + .map_err(agent_core_error) + } + None => commit(), + } +} + +#[tauri::command] +pub(crate) async fn preview_agent_config_template( + app: tauri::AppHandle, + client: String, + model: String, + oauth_configuration: bool, + claude_code_model_mappings: Option, + claude_desktop_model_mappings: Option, +) -> Result { + let home = app.path().home_dir().map_err(|_| "无法获取用户目录")?; + let config = app.state::().snapshot()?; + Ok(prepare_template_plan( + &config, + &home, + &client, + &model, + oauth_configuration, + claude_code_model_mappings, + claude_desktop_model_mappings, + ) + .await? + .preview) +} + +#[tauri::command] +pub(crate) async fn apply_agent_config_template( + app: tauri::AppHandle, + client: String, + model: String, + oauth_configuration: bool, + claude_code_model_mappings: Option, + claude_desktop_model_mappings: Option, + revision: String, +) -> Result { + let home = app.path().home_dir().map_err(|_| "无法获取用户目录")?; + let config = app.state::().snapshot()?; + let plan = prepare_template_plan( + &config, + &home, + &client, + &model, + oauth_configuration, + claude_code_model_mappings, + claude_desktop_model_mappings, + ) + .await?; + let result = execute_template_plan(&config, plan, &revision).await?; + app.state::().clear()?; + Ok(result) +} diff --git a/src-tauri/src/agents/templates/tests.rs b/src-tauri/src/agents/templates/tests.rs new file mode 100644 index 0000000..4d5d94f --- /dev/null +++ b/src-tauri/src/agents/templates/tests.rs @@ -0,0 +1,75 @@ +use super::*; + +#[test] +fn core_failure_messages_expose_outcomes_without_secret_source_text() { + for detail in [ + "YAML source: key: secret-token", + "已恢复原配置 secret-token", + "自动恢复失败 secret-token", + "配置已变化 secret-token", + ] { + let rendered = agent_core_error(detail.into()); + assert!(!rendered.contains("secret-token")); + if detail.contains("自动恢复失败") { + assert!(rendered.contains("回滚失败")); + } + } +} + +#[tokio::test] +async fn template_confirmation_is_bound_to_files_and_generated_content() { + let home = std::env::temp_dir().join(format!( + "cpa-template-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(&home).unwrap(); + let paths = config_paths("pi", &home).unwrap(); + let before = config_images(&paths).unwrap(); + let updates = build_pi_template_updates(&home, 8317, "secret", "model").unwrap(); + let after = prepare_config_updates("pi", &paths, &before, &updates, true).unwrap(); + let plan = || TemplatePlan { + client: "pi".into(), + paths: paths.clone(), + before: before.clone(), + after: after.clone(), + mappings: None, + mapping_revision: String::new(), + model: "model".into(), + core: None, + preview: TemplatePreview { + revision: "reviewed".into(), + files: Vec::new(), + }, + }; + assert!( + execute_template_plan(&GuiConfigFile::default(), plan(), "outdated") + .await + .is_err() + ); + assert_eq!(before, config_images(&paths).unwrap()); + fs::create_dir_all(paths[0].parent().unwrap()).unwrap(); + fs::write(&paths[0], "external edit").unwrap(); + assert!( + execute_template_plan(&GuiConfigFile::default(), plan(), "reviewed") + .await + .is_err() + ); + assert_eq!(fs::read_to_string(&paths[0]).unwrap(), "external edit"); + fs::remove_dir_all(home).unwrap(); +} + +#[test] +fn incomplete_or_invalid_template_never_writes_any_file() { + let home = std::env::temp_dir().join(format!("cpa-template-invalid-{}", std::process::id())); + let paths = config_paths("pi", &home).unwrap(); + let before = config_images(&paths).unwrap(); + let mut updates = build_pi_template_updates(&home, 8317, "secret", "model").unwrap(); + assert!(prepare_config_updates("pi", &paths, &before, &updates[..1], true).is_err()); + updates[1].after = "invalid".into(); + assert!(prepare_config_updates("pi", &paths, &before, &updates, true).is_err()); + assert_eq!(before, config_images(&paths).unwrap()); +} diff --git a/src-tauri/src/agents/transactions.rs b/src-tauri/src/agents/transactions.rs new file mode 100644 index 0000000..e07be8b --- /dev/null +++ b/src-tauri/src/agents/transactions.rs @@ -0,0 +1,756 @@ +//! Configuration transactions use only in-memory rollback images. No backup I/O. +use super::*; +use serde_json::Value; + +#[cfg(test)] +mod tests; + +pub(crate) type Images = Vec<(PathBuf, Option>)>; + +pub(crate) fn config_paths(client: &str, home: &Path) -> Result, String> { + if client == PI_AGENT_ID { + return Ok(vec![ + pi_provider_config_path(home), + pi_provider_settings_path(home), + ]); + } + let client = AgentClient::parse(client)?; + if !client.supported_platform() { + return Err("当前平台不支持此智能体配置".into()); + } + Ok(expected_agent_record_paths( + client, + &agent_config_paths(client, home), + )) +} + +// Reject links (including Windows junctions) in every existing path component. +pub(crate) fn validate_config_path(path: &Path) -> Result<(), String> { + if !path.is_absolute() + || path + .components() + .any(|c| matches!(c, std::path::Component::ParentDir)) + { + return Err("配置路径必须是绝对路径且不能越界".into()); + } + for ancestor in path.ancestors() { + match fs::symlink_metadata(ancestor) { + Ok(meta) => { + let linked = meta.file_type().is_symlink(); + #[cfg(windows)] + let linked = { + use std::os::windows::fs::MetadataExt; + linked || meta.file_attributes() & 0x400 != 0 + }; + if linked { + return Err(format!( + "配置路径不能包含符号链接: {}", + path_to_string(ancestor) + )); + } + if ancestor == path && !meta.is_file() { + return Err("配置路径不是普通文件".into()); + } + } + Err(e) if e.kind() == io::ErrorKind::NotFound => {} + Err(_) => return Err("无法检查配置路径".into()), + } + } + Ok(()) +} + +pub(crate) fn config_images(paths: &[PathBuf]) -> Result { + paths + .iter() + .map(|path| Ok((path.clone(), read_agent_bytes(path)?))) + .collect() +} + +pub(crate) fn image_hash(bytes: Option<&[u8]>) -> String { + bytes.map(sha256_bytes).unwrap_or_else(|| "missing".into()) +} + +pub(crate) fn image_revision(images: &Images) -> String { + sha256_bytes( + &serde_json::to_vec( + &images + .iter() + .map(|(path, bytes)| (path, image_hash(bytes.as_deref()))) + .collect::>(), + ) + .expect("image hashes serialize"), + ) +} + +pub(crate) fn write_config_images(client: &str, images: &Images) -> Result<(), String> { + for (path, bytes) in images { + if read_agent_bytes(path)? == *bytes { + continue; + } + if let Some(bytes) = bytes { + if client == PI_AGENT_ID { + write_bytes_directly(path, bytes)?; + } else { + write_agent_configuration_file(AgentClient::parse(client)?, path, bytes)?; + } + } else { + fs::remove_file(path).map_err(|_| "删除配置文件失败".to_string())?; + } + if read_agent_bytes(path)? != *bytes { + return Err("配置写后校验失败".into()); + } + } + Ok(()) +} + +pub(crate) fn commit_config( + client: &str, + paths: &[PathBuf], + before: &Images, + after: &Images, + _source: &str, + model: Option, +) -> Result { + commit_config_with_mappings(client, paths, before, after, _source, model, None) +} + +pub(crate) fn commit_config_with_mappings( + client: &str, + paths: &[PathBuf], + before: &Images, + after: &Images, + source: &str, + model: Option, + mappings: Option, +) -> Result { + commit_config_transaction( + client, + paths, + before, + after, + source, + model, + mappings, + &mut write_config_images, + true, + ) +} + +#[cfg(test)] +pub(crate) fn commit_config_with_writer( + client: &str, + paths: &[PathBuf], + before: &Images, + after: &Images, + source: &str, + model: Option, + mappings: Option, + writer: &mut impl FnMut(&str, &Images) -> Result<(), String>, +) -> Result { + commit_config_transaction( + client, paths, before, after, source, model, mappings, writer, false, + ) +} + +fn commit_config_transaction( + client: &str, + paths: &[PathBuf], + before: &Images, + after: &Images, + source: &str, + model: Option, + mappings: Option, + writer: &mut impl FnMut(&str, &Images) -> Result<(), String>, + checked: bool, +) -> Result { + if before.len() != paths.len() + || after.len() != paths.len() + || before + .iter() + .zip(paths) + .any(|((p, _), expected)| p != expected) + || after + .iter() + .zip(paths) + .any(|((p, _), expected)| p != expected) + { + return Err("配置更新路径不匹配".into()); + } + if config_images(paths)? != *before { + return Err("配置已被其他程序修改,请刷新后重试".into()); + } + let mut previous = before.clone(); + let mut target = after.clone(); + if client == "claude-desktop" { + let state_path = desktop_mapping_path(paths)?; + let state_before = read_agent_bytes(&state_path)?; + let next = if source == "restore" { + mappings + } else { + mappings.or_else(|| matching_desktop_mappings(paths, after)) + }; + let state_after = desktop_mapping_bytes(after, next.as_ref())?; + previous.push((state_path.clone(), state_before)); + target.push((state_path, state_after)); + } + let changed = previous + .iter() + .zip(&target) + .filter(|((_, a), (_, b))| a != b) + .map(|((p, _), _)| path_to_string(p)) + .collect::>(); + if changed.is_empty() { + return Ok(action_result( + "unchanged", + true, + model, + Vec::new(), + Vec::new(), + )); + } + let all_paths = previous.iter().map(|(p, _)| p.clone()).collect::>(); + if config_images(&all_paths)? != previous { + return Err("配置已被其他程序修改,请刷新后重试".into()); + } + // Check each file again immediately before writing. Track only files this operation touched + // so a conflict on a later file cannot roll back someone else's edit to that file. + let mut attempted = Vec::new(); + let result = (if checked { + (|| { + for (index, ((path, old), (_, next))) in previous.iter().zip(&target).enumerate() { + if read_agent_bytes(path)? != *old { + return Err("配置已被其他程序修改".into()); + } + if old == next { + continue; + } + let written = writer(client, &vec![target[index].clone()]); + attempted.push((index, read_agent_bytes(path))); + written?; + } + Ok(()) + })() + } else { + writer(client, &target) + }) + .and_then(|_| { + if config_images(&all_paths)? == target { + Ok(()) + } else { + Err("配置写后校验失败".into()) + } + }); + if result.is_err() { + // Do not expose arbitrary writer errors: parsers and external tools may include credentials. + let rollback = (if checked { + let mut failed = false; + for (index, observed) in attempted.into_iter().rev() { + let (path, _) = &previous[index]; + // A file changed after our write: leave that external edit intact. + if observed.is_err() || read_agent_bytes(path) != observed { + failed = true; + continue; + } + failed |= writer(client, &vec![previous[index].clone()]).is_err(); + } + if failed { + Err("回滚失败".into()) + } else { + Ok(()) + } + } else { + writer(client, &previous) + }) + .and_then(|_| { + if config_images(&all_paths)? == previous { + Ok(()) + } else { + Err("回滚校验失败".into()) + } + }); + return Err(if rollback.is_ok() { + "配置写入失败,已回滚本次修改" + } else { + "配置写入失败且回滚失败,请检查配置并使用手动备份恢复或基础配置模板修复" + } + .into()); + } + Ok(action_result("updated", true, model, changed, Vec::new())) +} + +pub(crate) fn validate_config_images(images: &Images) -> Result<(), String> { + for (path, bytes) in images { + parse(path, text(bytes.as_deref())?)?; + } + Ok(()) +} + +pub(crate) fn validate_client_config_images(client: &str, images: &Images) -> Result<(), String> { + validate_config_images(images)?; + if matches!(client, "opencode" | "openclaw") { + return Ok(()); + } + for (path, bytes) in images { + if matches!( + path.extension().and_then(|ext| ext.to_str()), + Some("toml" | "yaml" | "yml") + ) { + continue; + } + if let Some(content) = text(bytes.as_deref())? { + serde_json::from_str::(content.strip_prefix('\u{feff}').unwrap_or(content)) + .map_err(|_| { + "配置 JSON 格式错误,请使用手动备份恢复或基础配置模板修复".to_string() + })?; + } + } + Ok(()) +} + +pub(crate) fn prepare_config_updates( + client: &str, + paths: &[PathBuf], + before: &Images, + updates: &[AgentFileUpdate], + template: bool, +) -> Result { + if !template { + validate_client_config_images(client, before)?; + } + let mut after = before.clone(); + for update in updates { + let entry = after + .iter_mut() + .find(|(path, _)| path == &update.path) + .ok_or("配置更新路径不匹配")?; + let mut rendered = update.after.clone(); + let mut generated = parse(&update.path, Some(&rendered))?; + if !template { + let original = parse(&update.path, text(entry.1.as_deref())?)?; + if client == "codex" + && update.path.file_name().and_then(|v| v.to_str()) + == Some(CODEX_MODEL_CATALOG_FILE) + { + let mut merged = original.clone(); + set(&mut merged, &["models".into()], generated.get("models")); + if merged != generated { + generated = merged; + rendered = render(&update.path, &generated)?; + } + } + let built = generated.clone(); + preserve_model_extensions(client, &update.path, &original, &mut generated); + if generated != built { + rendered = render(&update.path, &generated)?; + } + validate_unmanaged_preserved(client, paths, &update.path, &original, &generated)?; + if entry.1.is_some() && original == generated { + continue; + } + } + entry.1 = Some(rendered.into_bytes()); + } + if template + && (updates.len() != paths.len() + || paths + .iter() + .any(|p| updates.iter().filter(|u| &u.path == p).count() != 1)) + { + return Err("基础配置模板必须覆盖整组文件".into()); + } + validate_client_config_images(client, &after)?; + Ok(after) +} + +// Model inventories are refreshed from CPA, but matching model entries may have client-specific +// nested options. Keep those options while allowing unavailable managed models to disappear. +fn preserve_model_extensions(client: &str, path: &Path, before: &Value, after: &mut Value) { + fn fill_missing(before: &Value, after: &mut Value) { + if let (Some(old), Some(new)) = (before.as_object(), after.as_object_mut()) { + for (key, value) in old { + if let Some(next) = new.get_mut(key) { + fill_missing(value, next); + } else { + new.insert(key.clone(), value.clone()); + } + } + } + } + fn merge_entry(client: &str, before: &Value, after: &mut Value) { + let mut extensions = before.clone(); + let owned: &[&str] = match client { + "claude-desktop" => &["name", "contextWindow", "supports1m", "prefer1m"], + "opencode" | "zcode" => &["name"], + "openclaw" => &["id", "name", "alias"], + "deepseek-harness" => &["id", "name", "contextWindow"], + "kimi-code" => &[ + "provider", + "model", + "display_name", + "max_context_size", + "capabilities", + ], + "grok-build" => &[ + "model", + "base_url", + "name", + "api_key", + "api_backend", + "context_window", + ], + _ => &[], + }; + if let Some(object) = extensions.as_object_mut() { + if client == "codex" { + object.retain(|key, _| !codex_catalog::is_managed_model_field(key)); + } + for key in owned { + object.remove(*key); + } + } + if client == "zcode" { + set(&mut extensions, &["limit".into(), "context".into()], None); + } + fill_missing(&extensions, after); + } + fn inventory(client: &str, before: &Value, after: &mut Value) { + if let (Some(old), Some(new)) = (before.as_array(), after.as_array_mut()) { + let identity = |v: &Value| { + ["id", "slug", "name"] + .into_iter() + .find_map(|key| v.get(key).and_then(Value::as_str).map(str::to_string)) + }; + for entry in new { + if let Some(id) = identity(entry) { + if let Some(old) = old.iter().find(|v| identity(v).as_deref() == Some(&id)) { + merge_entry(client, old, entry); + } + } + } + } else if let (Some(old), Some(new)) = (before.as_object(), after.as_object_mut()) { + for (key, entry) in new { + if let Some(old) = old.get(key) { + merge_entry(client, old, entry); + } + } + } + } + let pointers: &[&str] = match client { + "codex" if path.file_name().and_then(|v| v.to_str()) == Some(CODEX_MODEL_CATALOG_FILE) => { + &["/models"] + } + "claude-desktop" => &["/inferenceModels"], + "opencode" | "zcode" => &["/provider/cpa-gui/models"], + "openclaw" => &[ + "/models/providers/cpa-gui/models", + "/agents/defaults/models", + ], + "kimi-code" => &["/models"], + "grok-build" => &["/model"], + _ => &[], + }; + for pointer in pointers { + if let (Some(old), Some(new)) = (before.pointer(pointer), after.pointer_mut(pointer)) { + inventory(client, old, new); + } + } + if client == "deepseek-harness" { + let pointer = format!("/llm-pi-ai/providers/{DEEPSEEK_HARNESS_PROVIDER_ID}/models"); + if let (Some(old), Some(new)) = (before.pointer(&pointer), after.pointer_mut(&pointer)) { + inventory(client, old, new); + } + } + if client == "hermes" { + let old = before + .get("custom_providers") + .and_then(Value::as_array) + .and_then(|providers| { + providers.iter().find(|p| { + p.get("name").and_then(Value::as_str) == Some(MANAGED_AGENT_PROVIDER_ID) + }) + }) + .and_then(|p| p.get("models")); + let new = after + .get_mut("custom_providers") + .and_then(Value::as_array_mut) + .and_then(|providers| { + providers.iter_mut().find(|p| { + p.get("name").and_then(Value::as_str) == Some(MANAGED_AGENT_PROVIDER_ID) + }) + }) + .and_then(|p| p.get_mut("models")); + if let (Some(old), Some(new)) = (old, new) { + inventory(client, old, new); + } + } +} + +pub(crate) fn config_updates( + client: &str, + home: &Path, + before: &Images, + updates: &[AgentFileUpdate], + source: &str, + model: Option, + mappings: Option<&ClaudeDesktopModelMappings>, +) -> Result { + let paths = config_paths(client, home)?; + let after = prepare_config_updates(client, &paths, before, updates, source == "template")?; + commit_config_with_mappings( + client, + &paths, + before, + &after, + source, + model, + mappings.cloned(), + ) +} + +pub(crate) fn config_package_operation( + home: &Path, + _source: &str, + operation: impl FnOnce() -> Result<(), String>, +) -> Result<(), String> { + let _guard = AGENT_CONFIG_FILE_LOCK + .lock() + .map_err(|_| "配置文件锁已损坏")?; + let paths = config_paths(PI_AGENT_ID, home)?; + let before = config_images(&paths)?; + validate_config_images(&before)?; + if operation() + .and_then(|_| validate_config_images(&config_images(&paths)?)) + .is_err() + { + return match write_config_images(PI_AGENT_ID, &before) { + Ok(()) => Err("插件操作失败,已回滚本次配置修改".into()), + Err(_) => Err("插件操作失败且配置回滚失败,请检查配置".into()), + }; + } + Ok(()) +} + +fn validate_unmanaged_preserved( + client: &str, + paths: &[PathBuf], + path: &Path, + before: &Value, + after: &Value, +) -> Result<(), String> { + let project = |value: &Value| -> Result { + let mut value = value.clone(); + if client == "claude-code" { + if let Some(env) = value.get_mut("env").and_then(Value::as_object_mut) { + env.retain(|key, _| { + !matches!( + key.as_str(), + "CLAUDE_CODE_SUBAGENT_MODEL" | "CLAUDE_CODE_EFFORT_LEVEL" + ) && !key.starts_with("ANTHROPIC_DEFAULT_") + && !key.starts_with("ANTHROPIC_MODEL_") + && !key.starts_with("ANTHROPIC_CUSTOM_MODEL_OPTION") + }); + } + } + if client == "deepseek-harness" { + if path == &paths[0] { + for key in ["displayName", "apiKeyEnv", "api", "baseURL", "models"] { + set( + &mut value, + &[ + "llm-pi-ai".into(), + "providers".into(), + DEEPSEEK_HARNESS_PROVIDER_ID.into(), + key.into(), + ], + None, + ); + } + for key in ["provider", "model", "reasoningEffort"] { + set( + &mut value, + &["agent-default-model".into(), key.into()], + None, + ); + } + } else { + set( + &mut value, + &["refs".into(), DEEPSEEK_HARNESS_CREDENTIAL.into()], + None, + ); + set(&mut value, &[DEEPSEEK_HARNESS_CREDENTIAL.into()], None); + // The credentials format requires version 1 when initially created. + if value.get("version") == Some(&serde_json::json!(1)) { + set(&mut value, &["version".into()], None); + } + } + return Ok(value); + } + let rendered = render(path, &value)?; + let stripped = restore_text(client, paths, path, &rendered, None)?; + parse(path, stripped.as_deref()) + }; + if project(before)? != project(after)? { + return Err(format!( + "更新意外改变了自定义配置,已拒绝写入: {}", + path_to_string(path) + )); + } + Ok(()) +} + +pub(crate) fn text(bytes: Option<&[u8]>) -> Result, String> { + bytes + .map(|bytes| std::str::from_utf8(bytes).map_err(|_| "配置不是 UTF-8 文本".to_string())) + .transpose() +} + +pub(crate) fn parse(path: &Path, content: Option<&str>) -> Result { + let Some(content) = content else { + return Ok(serde_json::json!({})); + }; + let content = content.strip_prefix('\u{feff}').unwrap_or(content); + let result = match path.extension().and_then(|v| v.to_str()) { + Some("toml") => toml::from_str::(content) + .map_err(|e| e.to_string()) + .and_then(|v| serde_json::to_value(v).map_err(|e| e.to_string())), + Some("yaml" | "yml") => serde_norway::from_str::(content).map_err(|e| e.to_string()), + _ => json5::from_str::(content).map_err(|e| e.to_string()), + }; + let value = result.map_err(|_| { + format!( + "{} 配置格式错误,请使用手动备份恢复或基础配置模板修复", + path_to_string(path) + ) + })?; + if !value.is_object() { + return Err(format!("{} 配置根节点必须是对象", path_to_string(path))); + } + Ok(value) +} + +pub(crate) fn render(path: &Path, value: &Value) -> Result { + match path.extension().and_then(|v| v.to_str()) { + Some("toml") => toml::to_string_pretty(value).map_err(|e| e.to_string()), + Some("yaml" | "yml") => serde_norway::to_string(value).map_err(|e| e.to_string()), + _ => serde_json::to_string_pretty(value) + .map(|v| format!("{v}\n")) + .map_err(|e| e.to_string()), + } +} + +fn set(value: &mut Value, path: &[String], item: Option<&Value>) { + if path.is_empty() { + return; + } + if !value.is_object() { + *value = serde_json::json!({}); + } + let object = value.as_object_mut().unwrap(); + if path.len() == 1 { + if let Some(item) = item { + object.insert(path[0].clone(), item.clone()); + } else { + object.remove(&path[0]); + } + } else if item.is_some() || object.contains_key(&path[0]) { + let child = object + .entry(path[0].clone()) + .or_insert_with(|| serde_json::json!({})); + set(child, &path[1..], item); + if child.as_object().is_some_and(|v| v.is_empty()) { + object.remove(&path[0]); + } + } +} + +fn special_keys(client: &str, path: &Path) -> Option<&'static [&'static str]> { + let name = path + .file_name() + .and_then(|v| v.to_str()) + .unwrap_or_default(); + match (client, name) { + ("codex", "auth.json") => Some(&["auth_mode", "OPENAI_API_KEY", "tokens", "last_refresh"]), + ("codex", CODEX_MODEL_CATALOG_FILE) => Some(&["models"]), + ("pi", PI_AGENT_CONFIG_FILE) => Some(&["baseUrl", "apiKey"]), + ("pi", PI_AGENT_SETTINGS_FILE) => Some(&["defaultProvider", "defaultModel"]), + _ => None, + } +} + +fn restore_text( + client: &str, + paths: &[PathBuf], + path: &Path, + current: &str, + target: Option<&str>, +) -> Result, String> { + if let Some(keys) = special_keys(client, path) { + let mut root = parse(path, Some(current))?; + let target_value = parse(path, target)?; + for key in keys { + set(&mut root, &[key.to_string()], target_value.get(key)); + } + return if root.as_object().unwrap().is_empty() && target.is_none() { + Ok(None) + } else { + render(path, &root).map(Some) + }; + } + if client == "claude-desktop" && paths.get(3).is_some_and(|p| p == path) { + let mut root = parse(path, Some(current))?; + let target = parse(path, target)?; + set(&mut root, &["appliedId".into()], target.get("appliedId")); + let mut entries = root + .get("entries") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + let original = target + .get("entries") + .and_then(Value::as_array) + .and_then(|entries| { + entries.iter().find(|e| { + e.get("id").and_then(Value::as_str) == Some(CLAUDE_DESKTOP_PROFILE_ID) + }) + }); + let mut managed = entries + .iter() + .find(|e| e.get("id").and_then(Value::as_str) == Some(CLAUDE_DESKTOP_PROFILE_ID)) + .cloned() + .unwrap_or_else(|| serde_json::json!({})); + entries.retain(|e| e.get("id").and_then(Value::as_str) != Some(CLAUDE_DESKTOP_PROFILE_ID)); + for key in ["id", "name"] { + set( + &mut managed, + &[key.into()], + original.and_then(|v| v.get(key)), + ); + } + if managed.as_object().is_some_and(|o| !o.is_empty()) { + managed["id"] = serde_json::json!(CLAUDE_DESKTOP_PROFILE_ID); + entries.push(managed); + } + if entries.is_empty() { + root.as_object_mut().unwrap().remove("entries"); + } else { + root["entries"] = Value::Array(entries); + } + return render(path, &root).map(Some); + } + let parsed = AgentClient::parse(client)?; + let base_paths = if parsed == AgentClient::Codex { + &paths[..1] + } else { + paths + }; + build_agent_session_restored_bytes( + parsed, + base_paths, + path, + Some(current.as_bytes()), + target.map(str::as_bytes), + )? + .map(|v| String::from_utf8(v).map_err(|e| e.to_string())) + .transpose() +} diff --git a/src-tauri/src/agents/transactions/tests.rs b/src-tauri/src/agents/transactions/tests.rs new file mode 100644 index 0000000..888fa5e --- /dev/null +++ b/src-tauri/src/agents/transactions/tests.rs @@ -0,0 +1,65 @@ +use super::*; + +#[test] +fn codex_model_merge_does_not_resurrect_removed_schema_fields() { + let before = serde_json::json!({"models": [{ + "slug": "third-party-model", + "minimal_client_version": "999.0.0", + "model_messages": {"instructions_template": "old", "retired": "old"}, + "extensions": {"nested": [1, 2]} + }]}); + let mut after = serde_json::json!({"models": [{ + "slug": "third-party-model", + "model_messages": {"instructions_template": "new"} + }]}); + let mut expected = after.clone(); + expected["models"][0]["extensions"] = before["models"][0]["extensions"].clone(); + preserve_model_extensions( + "codex", + Path::new(CODEX_MODEL_CATALOG_FILE), + &before, + &mut after, + ); + assert_eq!(after, expected); +} + +#[test] +fn mid_transaction_external_edit_is_preserved_while_earlier_writes_are_rolled_back() { + let home = std::env::temp_dir().join(format!("cpa-transaction-race-{}", std::process::id())); + fs::create_dir_all(&home).unwrap(); + let paths = vec![home.join("first.json"), home.join("second.json")]; + for path in &paths { + fs::write(path, "{}").unwrap(); + } + let before = config_images(&paths).unwrap(); + let after = paths + .iter() + .map(|p| (p.clone(), Some(b"{\"next\":true}".to_vec()))) + .collect(); + let mut writes = 0; + let result = commit_config_transaction( + "pi", + &paths, + &before, + &after, + "update", + None, + None, + &mut |client, images| { + write_config_images(client, images)?; + writes += 1; + if writes == 1 { + fs::write(&paths[1], "{\"external\":true}").unwrap(); + } + Ok(()) + }, + true, + ); + assert!(result.is_err()); + assert_eq!(fs::read_to_string(&paths[0]).unwrap(), "{}"); + assert_eq!( + fs::read_to_string(&paths[1]).unwrap(), + "{\"external\":true}" + ); + fs::remove_dir_all(home).unwrap(); +} diff --git a/src-tauri/src/codex_catalog.rs b/src-tauri/src/codex_catalog.rs index 43dc7ed..de7e832 100644 --- a/src-tauri/src/codex_catalog.rs +++ b/src-tauri/src/codex_catalog.rs @@ -77,6 +77,31 @@ pub(crate) fn validate_embedded_catalog() -> Result<(), String> { catalog_state().map(|_| ()) } +// An omitted managed field means "use the Codex default", not "keep the disk value". +// Derive ownership from the bundled schemas and editor so optional fields stay in sync. +pub(crate) fn is_managed_model_field(field: &str) -> bool { + static FIELDS: OnceLock> = OnceLock::new(); + FIELDS + .get_or_init(|| { + let sources = parse_sources(MODEL_CATALOG_JSON) + .expect("embedded Codex model catalog must be valid"); + sources + .fallback + .keys() + .chain( + sources + .templates + .values() + .flat_map(|template| template.value.keys()), + ) + .map(String::as_str) + .chain(customizations::EDITABLE_FIELDS) + .map(str::to_string) + .collect() + }) + .contains(field) +} + pub(crate) fn activate_catalog_json(catalog_json: &str) -> Result { let parsed = parse_sources(catalog_json)?; let mut state = catalog_state()? diff --git a/src-tauri/src/codex_catalog/customizations.rs b/src-tauri/src/codex_catalog/customizations.rs index 7fbac06..e56d5f0 100644 --- a/src-tauri/src/codex_catalog/customizations.rs +++ b/src-tauri/src/codex_catalog/customizations.rs @@ -6,7 +6,7 @@ use std::path::Path; pub(super) type ModelCustomizations = BTreeMap>; -const EDITABLE_FIELDS: [&str; 11] = [ +pub(super) const EDITABLE_FIELDS: [&str; 11] = [ "display_name", "description", "context_window", diff --git a/src-tauri/src/core_config/aliases.rs b/src-tauri/src/core_config/aliases.rs index 22936e3..5f49f2e 100644 --- a/src-tauri/src/core_config/aliases.rs +++ b/src-tauri/src/core_config/aliases.rs @@ -291,27 +291,6 @@ pub(crate) fn management_alias_config_changes( }) } -pub(crate) async fn ensure_claude_desktop_model_aliases( - config: &GuiConfigFile, - mappings: &ClaudeDesktopModelMappings, - models: &[AgentModelOption], -) -> Result<(), String> { - let content = fetch_management_config_yaml(config).await?; - let updated = match ensure_claude_desktop_model_aliases_in_yaml(&content, mappings, models) { - Ok(updated) => updated, - Err(_) => { - let definitions = fetch_oauth_model_definitions(config).await; - ensure_claude_desktop_model_aliases_with_oauth_definitions_in_yaml( - &content, - mappings, - models, - &definitions, - )? - } - }; - put_management_alias_config_changes(config, &content, &updated).await -} - pub(crate) fn ensure_claude_desktop_model_aliases_in_yaml( content: &str, mappings: &ClaudeDesktopModelMappings, @@ -353,6 +332,9 @@ pub(crate) fn ensure_claude_desktop_model_aliases_with_oauth_definitions_in_yaml .as_mapping_mut() .ok_or_else(|| "内核配置顶层必须是 YAML 映射".to_string())?; + // All roles resolve against the same input. Replacing one old role must not erase + // the only source record needed to configure another role. + let sources = root.clone(); for (alias, source_model) in [ (CLAUDE_DESKTOP_OPUS_MODEL_ID, mappings.opus.as_str()), (CLAUDE_DESKTOP_SONNET_MODEL_ID, mappings.sonnet.as_str()), @@ -366,7 +348,13 @@ pub(crate) fn ensure_claude_desktop_model_aliases_with_oauth_definitions_in_yaml remove_managed_claude_model_alias(root, alias)?; } } else { - ensure_claude_desktop_model_alias(root, source_model, alias, oauth_model_definitions)?; + ensure_claude_desktop_model_alias( + root, + &sources, + source_model, + alias, + oauth_model_definitions, + )?; } } render_updated_core_yaml(&mut document, updated) @@ -564,6 +552,7 @@ pub(crate) fn configured_managed_claude_alias_matches( pub(crate) fn ensure_claude_desktop_model_alias( root: &mut serde_norway::Mapping, + sources: &serde_norway::Mapping, source_model: &str, alias: &str, oauth_model_definitions: &[OAuthModelDefinitions], @@ -571,8 +560,10 @@ pub(crate) fn ensure_claude_desktop_model_alias( if configured_managed_claude_alias_matches(root, alias, source_model) { return Ok(()); } - remove_existing_claude_model_alias(root, alias)?; - if append_claude_desktop_model_alias(root, source_model, alias)? { + let source = resolve_claude_desktop_alias_source(sources, source_model)?; + if let Some(source) = source { + remove_existing_claude_model_alias(root, alias)?; + append_claude_desktop_model_alias(root, source, alias)?; return Ok(()); } if let Some(definition) = oauth_model_definitions.iter().find(|definition| { @@ -581,6 +572,7 @@ pub(crate) fn ensure_claude_desktop_model_alias( .iter() .any(|model| model.id.eq_ignore_ascii_case(source_model)) }) { + remove_existing_claude_model_alias(root, alias)?; append_managed_oauth_model_alias( root, definition.channel.key, @@ -646,67 +638,133 @@ pub(crate) fn configured_model_client_identity( None } -pub(crate) fn append_claude_desktop_model_alias( - root: &mut serde_norway::Mapping, +enum ClaudeDesktopAliasSource { + Provider { + section: &'static str, + provider_index: usize, + model: serde_norway::Mapping, + }, + OAuth { + channel: String, + model: serde_norway::Mapping, + }, +} + +fn resolve_claude_desktop_alias_source( + root: &serde_norway::Mapping, source_model: &str, - alias: &str, -) -> Result { - for section in MODEL_ALIAS_CONFIG_SECTIONS { - let Some(providers) = yaml_mapping_value_mut(root, section) else { - continue; - }; - let providers = providers - .as_sequence_mut() - .ok_or_else(|| format!("{section} 必须是数组"))?; - for provider in providers { - let Some(provider) = provider.as_mapping_mut() else { - continue; - }; - if yaml_mapping_value(provider, "disabled") == Some(&serde_norway::Value::Bool(true)) { - continue; +) -> Result, String> { + // Prefer an exact client-visible model. Legacy configurations may only retain + // an aliased entry, whose upstream name still identifies its configured source. + for upstream in [false, true] { + let matching_model = |model: &serde_norway::Value| { + let (name, client_model, _) = configured_model_identity(model)?; + let matches = if upstream { &name } else { &client_model }; + if !matches.eq_ignore_ascii_case(source_model) { + return None; } - let Some(models) = yaml_mapping_value_mut(provider, "models") else { + let mut model = model.as_mapping().cloned().unwrap_or_default(); + model.insert(yaml_key("name"), serde_norway::Value::String(name)); + Some(model) + }; + for section in MODEL_ALIAS_CONFIG_SECTIONS { + let Some(providers) = yaml_mapping_value(root, section) else { continue; }; - let models = models - .as_sequence_mut() - .ok_or_else(|| format!("{section}.models 必须是数组"))?; - let Some(source) = models.iter().find_map(|model| { - let (upstream_model, client_model, _) = configured_model_identity(model)?; - client_model - .eq_ignore_ascii_case(source_model) - .then(|| (model.clone(), upstream_model)) - }) else { - continue; - }; - let (source, upstream_model) = source; - let mut alias_model = source.as_mapping().cloned().unwrap_or_else(|| { - let mut mapping = serde_norway::Mapping::new(); - mapping.insert( - yaml_key("name"), - serde_norway::Value::String(upstream_model.clone()), - ); - mapping - }); - alias_model.insert( - yaml_key("name"), - serde_norway::Value::String(upstream_model), - ); - alias_model.insert( - yaml_key("alias"), - serde_norway::Value::String(alias.to_string()), - ); - let display_name = managed_claude_alias_display_name(alias) - .ok_or_else(|| format!("不支持的 Claude 托管别名: {alias}"))?; - alias_model.insert( - yaml_key("display-name"), - serde_norway::Value::String(display_name.to_string()), - ); - models.push(serde_norway::Value::Mapping(alias_model)); - return Ok(true); + let providers = providers + .as_sequence() + .ok_or_else(|| format!("{section} 必须是数组"))?; + for (provider_index, provider) in providers.iter().enumerate() { + let Some(provider) = provider.as_mapping() else { + continue; + }; + if yaml_mapping_value(provider, "disabled") == Some(&serde_norway::Value::Bool(true)) { + continue; + } + let Some(models) = yaml_mapping_value(provider, "models") else { + continue; + }; + let models = models + .as_sequence() + .ok_or_else(|| format!("{section}.models 必须是数组"))?; + if let Some(model) = models.iter().find_map(matching_model) { + return Ok(Some(ClaudeDesktopAliasSource::Provider { + section, + provider_index, + model, + })); + } + } + } + if let Some(channels) = + yaml_mapping_value(root, "oauth-model-alias").and_then(serde_norway::Value::as_mapping) + { + for (channel, models) in channels { + let (Some(channel), Some(models)) = (channel.as_str(), models.as_sequence()) else { + continue; + }; + if let Some(model) = models.iter().find_map(matching_model) { + return Ok(Some(ClaudeDesktopAliasSource::OAuth { + channel: channel.to_string(), + model, + })); + } + } } } - Ok(false) + Ok(None) +} + +fn append_claude_desktop_model_alias( + root: &mut serde_norway::Mapping, + source: ClaudeDesktopAliasSource, + alias: &str, +) -> Result<(), String> { + let (models, mut model) = match source { + ClaudeDesktopAliasSource::Provider { + section, + provider_index, + model, + } => { + let models = yaml_mapping_value_mut(root, section) + .and_then(serde_norway::Value::as_sequence_mut) + .and_then(|providers| providers.get_mut(provider_index)) + .and_then(serde_norway::Value::as_mapping_mut) + .and_then(|provider| yaml_mapping_value_mut(provider, "models")) + .and_then(serde_norway::Value::as_sequence_mut) + .ok_or("模型来源配置已变化,请刷新后重试")?; + (models, model) + } + ClaudeDesktopAliasSource::OAuth { channel, mut model } => { + let aliases = root + .entry(yaml_key("oauth-model-alias")) + .or_insert_with(|| serde_norway::Value::Mapping(serde_norway::Mapping::new())) + .as_mapping_mut() + .ok_or("oauth-model-alias 必须是 YAML 映射")?; + let models = aliases + .entry(yaml_key(&channel)) + .or_insert_with(|| serde_norway::Value::Sequence(Vec::new())) + .as_sequence_mut() + .ok_or_else(|| format!("oauth-model-alias.{channel} 必须是数组"))?; + model.insert(yaml_key("fork"), serde_norway::Value::Bool(true)); + if oauth_alias_channel(&channel).is_some_and(|channel| channel.force_mapping) { + model.insert(yaml_key("force-mapping"), serde_norway::Value::Bool(true)); + } + (models, model) + } + }; + model.insert( + yaml_key("alias"), + serde_norway::Value::String(alias.to_string()), + ); + let display_name = managed_claude_alias_display_name(alias) + .ok_or_else(|| format!("不支持的 Claude 托管别名: {alias}"))?; + model.insert( + yaml_key("display-name"), + serde_norway::Value::String(display_name.to_string()), + ); + models.push(serde_norway::Value::Mapping(model)); + Ok(()) } pub(crate) fn append_managed_oauth_model_alias( diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 9b74c34..e2185a0 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -186,13 +186,21 @@ const MODEL_ALIAS_CONFIG_SECTIONS: &[&str] = &[ "claude-api-key", "gemini-api-key", ]; +#[cfg(test)] const LEGACY_AGENT_MODIFICATION_STATE_VERSION: u8 = 1; +#[cfg(test)] const AGENT_MODIFICATION_STATE_VERSION: u8 = 2; +#[cfg(test)] const AGENT_APPLIED_STATE_VERSION: u8 = 4; +#[cfg(test)] const AGENT_CONFIGURATION_REVISION: u8 = 1; +#[cfg(test)] const AGENT_PHASE_APPLYING: &str = "applying"; +#[cfg(test)] const AGENT_PHASE_ACTIVE: &str = "active"; +#[cfg(test)] const AGENT_PHASE_RESTORING: &str = "restoring"; +#[cfg(test)] const AGENT_PHASE_RECOVERY: &str = "recovery"; #[cfg(test)] const AGENT_MODIFICATION_STATE_CONFLICT: &str = "conflict"; @@ -208,6 +216,7 @@ const APP_USER_AGENT: &str = concat!( ); static CORE_CONFIG_FILE_LOCK: Mutex<()> = Mutex::new(()); static AGENT_CONFIG_FILE_LOCK: Mutex<()> = Mutex::new(()); +#[cfg(test)] static CODEX_APPLIED_STATES: LazyLock< Mutex>, > = LazyLock::new(|| Mutex::new(std::collections::HashMap::new())); @@ -1078,7 +1087,6 @@ struct AgentConfigActionResult { model: Option, changed_files: Vec, conflict_files: Vec, - history_version: Option, } #[derive(Clone, Debug, Serialize)] @@ -1213,6 +1221,7 @@ struct ResolvedThinkingAliasSource { location: ThinkingAliasSourceLocation, } +#[cfg(test)] #[derive(Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct AgentModificationRecord { @@ -1223,6 +1232,7 @@ struct AgentModificationRecord { files: Vec, } +#[cfg(test)] #[derive(Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct AgentModificationFile { @@ -1241,6 +1251,7 @@ struct AgentModificationInspection { warnings: Vec, } +#[cfg(test)] #[derive(Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct AgentAppliedState { @@ -1256,6 +1267,7 @@ struct AgentAppliedState { updated_at_unix: u64, } +#[cfg(test)] #[derive(Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct AgentAppliedBackupFile { @@ -1376,6 +1388,7 @@ struct PreparedAgentModels { codex_catalog: Option, } +#[cfg(test)] type FileSnapshot = (PathBuf, Option>); #[cfg(test)] type AgentRecordExtension = (AgentModificationRecord, Vec); @@ -2484,9 +2497,13 @@ fn main() { get_software_settings, save_software_settings, get_agent_config_statuses, - list_agent_config_history, - preview_agent_config_history, - restore_agent_config_history, + create_agent_config_backup, + list_agent_config_backups, + preview_agent_config_backup, + restore_agent_config_backup, + delete_agent_config_backup, + preview_agent_config_template, + apply_agent_config_template, refresh_agent_config_statuses, get_agent_models, check_pi_provider_update, @@ -2509,8 +2526,6 @@ fn main() { create_speed_alias, delete_speed_alias, apply_agent_config, - close_agent_config_modification, - reset_agent_config_to_default, clear_codex_config, set_agent_config_enabled, update_agent_config, diff --git a/src-tauri/src/tests/agent_paths.rs b/src-tauri/src/tests/agent_paths.rs index 747da6d..094d6b5 100644 --- a/src-tauri/src/tests/agent_paths.rs +++ b/src-tauri/src/tests/agent_paths.rs @@ -17,7 +17,7 @@ fn configuration_paths_ignore_inherited_environment() { "grok-build", "pi", ] { - let paths = history_paths(client, &home).unwrap(); + let paths = config_paths(client, &home).unwrap(); assert!(!paths.is_empty(), "{client}"); assert!( paths.iter().all(|path| path.starts_with(&home)), diff --git a/src-tauri/src/tests/agent_state.rs b/src-tauri/src/tests/agent_state.rs index 76cd084..fd4ede9 100644 --- a/src-tauri/src/tests/agent_state.rs +++ b/src-tauri/src/tests/agent_state.rs @@ -166,319 +166,10 @@ fn codex_oauth_requires_auth_json_with_tokens() { fs::remove_dir_all(home).unwrap(); } -#[test] -fn successive_updates_and_noop_have_independent_history() { - let home = agent_test_home("history-first-update"); - let models = test_agent_models(&["gpt-one", "gpt-two"]); - for model in ["gpt-one", "gpt-two"] { - let result = apply_agent_configuration(AgentClient::OpenCode, &home, 8317, DEFAULT_API_KEY, model, &models, None).unwrap(); - assert_eq!(result.outcome, "updated"); - assert!(result.history_version.is_some()); - } - assert_eq!(test_history_count(AgentClient::OpenCode, &home), 4); - let result = apply_agent_configuration(AgentClient::OpenCode, &home, 8317, DEFAULT_API_KEY, "gpt-two", &models, None).unwrap(); - assert_eq!(result.outcome, "unchanged"); - assert_eq!(test_history_count(AgentClient::OpenCode, &home), 4); - test_restore_history(AgentClient::OpenCode, &home, "update"); - let path = agent_config_paths(AgentClient::OpenCode, &home).remove(0); - assert!(fs::read_to_string(path).unwrap().contains("cpa-gui/gpt-one")); - fs::remove_dir_all(home).unwrap(); -} -#[test] -fn deepseek_harness_history_restore_keeps_concurrent_unmanaged_yaml_changes() { - let home = agent_test_home("deepseek-harness-session-restore"); - let paths = agent_config_paths(AgentClient::DeepSeekHarness, &home); - fs::create_dir_all(paths[0].parent().unwrap()).unwrap(); - let original_settings = r#"# original settings -llm-pi-ai: - providers: - existing: - api: openai-completions - baseURL: https://example.invalid/v1 -agent-default-model: - provider: existing - model: old-model -"#; - let original_credentials = "version: 1\nrefs:\n OTHER_API_KEY: old-secret\n"; - fs::write(&paths[0], original_settings).unwrap(); - fs::write(&paths[1], original_credentials).unwrap(); - let models = test_agent_models(&["gpt-test", "gpt-other"]); - apply_agent_configuration( - AgentClient::DeepSeekHarness, - &home, - 8317, - "agent-key", - "gpt-test", - &models, - None, - ) - .unwrap(); - let (configured, model) = inspect_deepseek_harness_config(&paths, 8317, "agent-key").unwrap(); - assert!(configured); - assert_eq!(model.as_deref(), Some("gpt-test")); - let settings = fs::read_to_string(&paths[0]).unwrap(); - let settings = - render_agent_yaml_mapping_update(Some(&settings), "test Harness settings", |root| { - root.insert( - yaml_key("added-after-apply"), - serde_norway::Value::Bool(true), - ); - Ok(()) - }) - .unwrap(); - fs::write(&paths[0], settings).unwrap(); - let credentials = fs::read_to_string(&paths[1]).unwrap(); - let credentials = - render_agent_yaml_mapping_update(Some(&credentials), "test Harness credentials", |root| { - let refs = root - .get_mut(yaml_key("refs")) - .and_then(serde_norway::Value::as_mapping_mut) - .unwrap(); - refs.insert( - yaml_key("NEW_API_KEY"), - serde_norway::Value::String("new-secret".to_string()), - ); - Ok(()) - }) - .unwrap(); - fs::write(&paths[1], credentials).unwrap(); - test_restore_history(AgentClient::DeepSeekHarness, &home, "before-update"); - let settings: serde_norway::Value = - serde_norway::from_str(&fs::read_to_string(&paths[0]).unwrap()).unwrap(); - let credentials: serde_norway::Value = - serde_norway::from_str(&fs::read_to_string(&paths[1]).unwrap()).unwrap(); - assert_eq!( - settings["agent-default-model"]["provider"].as_str(), - Some("existing") - ); - assert!(settings["llm-pi-ai"]["providers"] - .get(DEEPSEEK_HARNESS_PROVIDER_ID) - .is_none()); - assert_eq!(settings["added-after-apply"].as_bool(), Some(true)); - assert_eq!(credentials["version"].as_u64(), Some(1)); - assert_eq!( - credentials["refs"]["OTHER_API_KEY"].as_str(), - Some("old-secret") - ); - assert_eq!( - credentials["refs"]["NEW_API_KEY"].as_str(), - Some("new-secret") - ); - assert!(credentials["refs"] - .get(DEEPSEEK_HARNESS_CREDENTIAL) - .is_none()); - assert!(!dated_agent_backup_path(&paths[0]).unwrap().exists()); - assert!(!dated_agent_backup_path(&paths[1]).unwrap().exists()); - assert!(!agent_state_path(&paths).unwrap().exists()); - fs::remove_dir_all(home).unwrap(); -} - -#[test] -fn opencode_runtime_edits_survive_history_restore_and_next_apply_owns_conflicts() { - let home = agent_test_home("opencode-runtime-edit-merge"); - let path = home.join(".config/opencode/opencode.json"); - fs::create_dir_all(path.parent().unwrap()).unwrap(); - fs::write( - &path, - r#"{"provider":{"other":{"keep":"original"}},"keep":"root"}"#, - ) - .unwrap(); - let models = test_agent_models(&["gpt-one", "gpt-two"]); - apply_agent_configuration( - AgentClient::OpenCode, - &home, - 8317, - DEFAULT_API_KEY, - "gpt-one", - &models, - None, - ) - .unwrap(); - - let mut runtime: serde_json::Value = - serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); - runtime["agentAdded"] = serde_json::json!({"enabled": true}); - runtime["provider"]["other"]["runtimeAdded"] = serde_json::json!(42); - runtime["provider"][MANAGED_AGENT_PROVIDER_ID]["customAfterApply"] = - serde_json::json!("keep-me"); - runtime["provider"][MANAGED_AGENT_PROVIDER_ID]["options"]["baseURL"] = - serde_json::json!("https://agent-overwrite.invalid/v1"); - runtime["provider"][MANAGED_AGENT_PROVIDER_ID]["options"]["apiKey"] = - serde_json::json!("agent-overwrite"); - runtime["model"] = serde_json::json!("cpa-gui/agent-overwrite"); - fs::write(&path, serde_json::to_string_pretty(&runtime).unwrap()).unwrap(); - - test_restore_history(AgentClient::OpenCode, &home, "before-update"); - let closed: serde_json::Value = - serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); - assert_eq!(closed["keep"], "root"); - assert_eq!(closed["agentAdded"]["enabled"], true); - assert_eq!(closed["provider"]["other"]["keep"], "original"); - assert_eq!(closed["provider"]["other"]["runtimeAdded"], 42); - assert_eq!( - closed["provider"][MANAGED_AGENT_PROVIDER_ID]["customAfterApply"], - "keep-me" - ); - assert!(closed.get("model").is_none()); - assert!(closed.get("$schema").is_none()); - assert!(closed["provider"][MANAGED_AGENT_PROVIDER_ID] - .get("options") - .is_none()); - - apply_agent_configuration( - AgentClient::OpenCode, - &home, - 8317, - DEFAULT_API_KEY, - "gpt-two", - &models, - None, - ) - .unwrap(); - let reapplied: serde_json::Value = - serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); - assert_eq!(reapplied["model"], "cpa-gui/gpt-two"); - assert_eq!( - reapplied["provider"][MANAGED_AGENT_PROVIDER_ID]["options"]["baseURL"], - "http://127.0.0.1:8317/v1" - ); - assert_eq!( - reapplied["provider"][MANAGED_AGENT_PROVIDER_ID]["options"]["apiKey"], - DEFAULT_API_KEY - ); - assert_eq!( - reapplied["provider"][MANAGED_AGENT_PROVIDER_ID]["customAfterApply"], - "keep-me" - ); - assert_eq!(reapplied["agentAdded"]["enabled"], true); - assert_eq!(reapplied["provider"]["other"]["runtimeAdded"], 42); - - test_restore_history(AgentClient::OpenCode, &home, "before-update"); - fs::remove_dir_all(home).unwrap(); -} - -#[test] -fn zcode_runtime_edits_survive_history_restore_and_original_provider_is_restored() { - let home = agent_test_home("zcode-runtime-edit-merge"); - let path = home.join(".zcode/v2/config.json"); - fs::create_dir_all(path.parent().unwrap()).unwrap(); - fs::write( - &path, - r#"{"model":"other/original","provider":{"other":{"keep":"original"},"cpa-gui":{"name":"Original CPA","custom":"original"}},"keep":"root"}"#, - ) - .unwrap(); - let models = test_agent_models(&["gpt-one", "gpt-two"]); - apply_agent_configuration( - AgentClient::ZCode, - &home, - 8317, - DEFAULT_API_KEY, - "gpt-one", - &models, - None, - ) - .unwrap(); - - let mut runtime: serde_json::Value = - serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); - runtime["agentAdded"] = serde_json::json!({"enabled": true}); - runtime["provider"]["other"]["runtimeAdded"] = serde_json::json!(42); - runtime["provider"][MANAGED_AGENT_PROVIDER_ID]["customAfterApply"] = - serde_json::json!("keep-me"); - runtime["provider"][MANAGED_AGENT_PROVIDER_ID]["options"]["baseURL"] = - serde_json::json!("https://agent-overwrite.invalid"); - fs::write(&path, serde_json::to_string_pretty(&runtime).unwrap()).unwrap(); - - test_restore_history(AgentClient::ZCode, &home, "before-update"); - let closed: serde_json::Value = - serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); - assert_eq!(closed["keep"], "root"); - assert_eq!(closed["model"], "other/original"); - assert_eq!(closed["agentAdded"]["enabled"], true); - assert_eq!(closed["provider"]["other"]["runtimeAdded"], 42); - assert_eq!( - closed["provider"][MANAGED_AGENT_PROVIDER_ID]["name"], - "Original CPA" - ); - assert_eq!( - closed["provider"][MANAGED_AGENT_PROVIDER_ID]["custom"], - "original" - ); - assert_eq!( - closed["provider"][MANAGED_AGENT_PROVIDER_ID]["customAfterApply"], - "keep-me" - ); - assert!(closed["provider"][MANAGED_AGENT_PROVIDER_ID] - .get("options") - .is_none()); - fs::remove_dir_all(home).unwrap(); -} - -#[test] -fn zcode_incomplete_legacy_backup_is_preserved_and_warned() { - let home = agent_test_home("zcode-legacy-state-upgrade"); - let paths = agent_config_paths(AgentClient::ZCode, &home); - fs::create_dir_all(paths[0].parent().unwrap()).unwrap(); - fs::create_dir_all(paths[1].parent().unwrap()).unwrap(); - let original_app = br#"{"model":"other/original","provider":{"other":{"keep":true}}}"#; - let original_cli = - br#"{"model":{"main":"other/original","lite":"other/lite"},"plugins":{"keep":true}}"#; - let models = test_agent_models(&["gpt-one", "gpt-two"]); - let managed_app = build_zcode_agent_config( - Some(std::str::from_utf8(original_app).unwrap()), - "http://127.0.0.1:8317", - DEFAULT_API_KEY, - "gpt-one", - &models, - ) - .unwrap(); - fs::write(&paths[0], managed_app).unwrap(); - fs::write(&paths[1], original_cli).unwrap(); - let app_backup = dated_agent_backup_path(&paths[0]).unwrap(); - fs::write(&app_backup, original_app).unwrap(); - let state_path = agent_state_path(&paths).unwrap(); - write_agent_applied_state( - &state_path, - &AgentAppliedState { - version: AGENT_APPLIED_STATE_VERSION, - client: AgentClient::ZCode.id().to_string(), - model: "gpt-one".to_string(), - configuration_revision: 0, - claude_desktop_model_mappings: None, - backup_files: vec![AgentAppliedBackupFile { - path: paths[0].clone(), - backup_path: app_backup, - existed_before: true, - }], - updated_at_unix: 1, - }, - ) - .unwrap(); - - apply_agent_configuration( - AgentClient::ZCode, - &home, - 8317, - DEFAULT_API_KEY, - "gpt-two", - &models, - None, - ) - .unwrap(); - assert!(!import_legacy_history("zcode", &home, &history_paths("zcode", &home).unwrap()).unwrap().is_empty()); - let unchanged = load_agent_applied_state(AgentClient::ZCode, &home).unwrap().unwrap(); - assert_eq!(unchanged.backup_files.len(), 1); - assert!(state_path.exists()); - assert_eq!(fs::read(&unchanged.backup_files[0].backup_path).unwrap(), original_app); - test_restore_history(AgentClient::ZCode, &home, "before-update"); - let cli: serde_json::Value = serde_json::from_slice(&fs::read(&paths[1]).unwrap()).unwrap(); - assert_eq!(cli["model"]["main"], "other/original"); - fs::remove_dir_all(home).unwrap(); -} #[test] fn kimi_and_grok_status_does_not_depend_on_legacy_state() { @@ -1226,6 +917,7 @@ fn codex_session_restore_does_not_create_a_state_json_file() { fs::create_dir_all(config_path.parent().unwrap()).unwrap(); fs::write(&config_path, original).unwrap(); + let manual_backup = create_backup("codex", &home).unwrap(); commit_agent_configuration( AgentClient::Codex, &home, @@ -1233,7 +925,7 @@ fn codex_session_restore_does_not_create_a_state_json_file() { &[ AgentFileUpdate { path: config_path.clone(), - after: "model = \"gpt-test\"\n".to_string(), + after: "approval_policy = \"never\"\nmodel = \"gpt-test\"\n".to_string(), }, AgentFileUpdate { path: catalog_path.clone(), @@ -1248,10 +940,10 @@ fn codex_session_restore_does_not_create_a_state_json_file() { let backup = dated_agent_backup_path(&config_path).unwrap(); assert!(!backup.exists()); assert!(!state_path.exists()); - assert_eq!(test_history_count(AgentClient::Codex, &home), 2); + assert_eq!(test_backup_count(AgentClient::Codex, &home), 1); - test_restore_history(AgentClient::Codex, &home, "before-applied"); - assert!(!fs::read_to_string(&config_path).unwrap().contains("approval_policy")); + test_restore_backup(AgentClient::Codex, &home, &manual_backup.id); + assert!(fs::read_to_string(&config_path).unwrap().contains("approval_policy")); assert!(!catalog_path.exists()); assert!(!backup.exists()); assert!(!state_path.exists()); @@ -1267,6 +959,7 @@ fn codex_applied_configuration_remains_detectable_after_process_restart() { fs::create_dir_all(config_path.parent().unwrap()).unwrap(); fs::write(&config_path, "approval_policy = \"never\"\n").unwrap(); + let manual_backup = create_backup("codex", &home).unwrap(); commit_agent_configuration( AgentClient::Codex, &home, @@ -1274,7 +967,7 @@ fn codex_applied_configuration_remains_detectable_after_process_restart() { &[ AgentFileUpdate { path: config_path.clone(), - after: "model = \"gpt-test\"\n".to_string(), + after: "approval_policy = \"never\"\nmodel = \"gpt-test\"\n".to_string(), }, AgentFileUpdate { path: catalog_path, @@ -1288,9 +981,9 @@ fn codex_applied_configuration_remains_detectable_after_process_restart() { clear_codex_applied_state(&state_path).unwrap(); - assert_eq!(test_history_count(AgentClient::Codex, &home), 2); + assert_eq!(test_backup_count(AgentClient::Codex, &home), 1); assert!(fs::read_to_string(&config_path).unwrap().contains("gpt-test")); - test_restore_history(AgentClient::Codex, &home, "before-applied"); + test_restore_backup(AgentClient::Codex, &home, &manual_backup.id); fs::remove_dir_all(home).unwrap(); } @@ -1299,6 +992,7 @@ fn codex_catalog_sync_removes_models_missing_from_runtime() { let home = agent_test_home("codex-runtime-catalog-sync"); let original_models = test_agent_models(&["gpt-active", "gpt-stale"]); let original_catalog = test_codex_models(&["gpt-active", "gpt-stale"]); + let manual_backup = create_backup("codex", &home).unwrap(); apply_agent_configuration( AgentClient::Codex, &home, @@ -1325,7 +1019,7 @@ fn codex_catalog_sync_removes_models_missing_from_runtime() { let written = fs::read_to_string(&catalog_path).unwrap(); assert!(written.contains("gpt-active")); assert!(!written.contains("gpt-stale")); - test_restore_history(AgentClient::Codex, &home, "before-update"); + test_restore_backup(AgentClient::Codex, &home, &manual_backup.id); assert!(!catalog_path.exists()); fs::remove_dir_all(home).unwrap(); } @@ -1340,6 +1034,7 @@ fn codex_catalog_sync_replaces_missing_default_and_preserves_user_configuration( fs::write(&config_path, original_config).unwrap(); let original_models = test_agent_models(&["gpt-stale"]); let original_catalog = test_codex_models(&["gpt-stale"]); + let manual_backup = create_backup("codex", &home).unwrap(); apply_agent_configuration( AgentClient::Codex, &home, @@ -1371,7 +1066,7 @@ fn codex_catalog_sync_replaces_missing_default_and_preserves_user_configuration( assert_eq!(document["model"].as_str(), Some("gpt-active")); assert_eq!(document["approval_policy"].as_str(), Some("on-request")); assert!(updated.contains("# Keep my settings")); - assert_eq!(test_history_count(AgentClient::Codex, &home), 4); + assert_eq!(test_backup_count(AgentClient::Codex, &home), 1); assert!(!sync_codex_model_catalog_if_configured( &home, 8317, @@ -1380,7 +1075,7 @@ fn codex_catalog_sync_replaces_missing_default_and_preserves_user_configuration( ¤t_catalog, ) .unwrap()); - test_restore_history(AgentClient::Codex, &home, "before-update"); + test_restore_backup(AgentClient::Codex, &home, &manual_backup.id); assert_eq!(fs::read_to_string(&config_path).unwrap(), original_config); fs::remove_dir_all(home).unwrap(); } @@ -1390,6 +1085,7 @@ fn codex_catalog_sync_clears_empty_list_and_recovers_when_models_return() { let home = agent_test_home("codex-runtime-catalog-empty-and-recovery"); let config_path = home.join(".codex/config.toml"); let catalog_path = codex_model_catalog_path(&home); + let manual_backup = create_backup("codex", &home).unwrap(); apply_agent_configuration( AgentClient::Codex, &home, @@ -1444,14 +1140,14 @@ fn codex_catalog_sync_clears_empty_list_and_recovers_when_models_return() { assert_eq!(document["model"].as_str(), Some("gpt-returned")); assert_eq!(fs::read_to_string(&catalog_path).unwrap(), returned_catalog); assert_eq!(fs::read(&auth_path).unwrap(), original_auth); - test_restore_history(AgentClient::Codex, &home, "before-update"); + test_restore_backup(AgentClient::Codex, &home, &manual_backup.id); assert!(!config_path.exists()); assert!(!catalog_path.exists()); fs::remove_dir_all(home).unwrap(); } #[test] -fn codex_catalog_sync_after_restart_backs_up_managed_files_without_memory_state() { +fn codex_catalog_sync_after_restart_does_not_create_automatic_backups() { let home = agent_test_home("codex-runtime-catalog-after-restart"); let config_path = home.join(".codex/config.toml"); let catalog_path = codex_model_catalog_path(&home); @@ -1466,6 +1162,7 @@ fn codex_catalog_sync_after_restart_backs_up_managed_files_without_memory_state( Some(&original_catalog), ) .unwrap(); + let manual_backup = create_backup("codex", &home).unwrap(); let state_path = agent_state_path(std::slice::from_ref(&config_path)).unwrap(); clear_codex_applied_state(&state_path).unwrap(); assert!(load_agent_applied_state(AgentClient::Codex, &home) @@ -1481,14 +1178,14 @@ fn codex_catalog_sync_after_restart_backs_up_managed_files_without_memory_state( ) .unwrap()); assert_eq!(fs::read_to_string(&catalog_path).unwrap(), current_catalog); - assert_eq!(test_history_count(AgentClient::Codex, &home), 4); - test_restore_history(AgentClient::Codex, &home, "before-sync"); + assert_eq!(test_backup_count(AgentClient::Codex, &home), 1); + test_restore_backup(AgentClient::Codex, &home, &manual_backup.id); assert_eq!(fs::read_to_string(&catalog_path).unwrap(), original_catalog); fs::remove_dir_all(home).unwrap(); } #[test] -fn codex_catalog_only_sync_after_restart_preserves_complete_backups() { +fn codex_catalog_only_sync_after_restart_preserves_manual_backup() { let home = agent_test_home("codex-runtime-catalog-only-after-restart"); let config_path = home.join(".codex/config.toml"); let catalog_path = codex_model_catalog_path(&home); @@ -1504,6 +1201,7 @@ fn codex_catalog_only_sync_after_restart_preserves_complete_backups() { ) .unwrap(); let original_config = fs::read(&config_path).unwrap(); + let manual_backup = create_backup("codex", &home).unwrap(); let state_path = agent_state_path(std::slice::from_ref(&config_path)).unwrap(); clear_codex_applied_state(&state_path).unwrap(); assert!(sync_codex_model_catalog_if_configured( @@ -1515,7 +1213,7 @@ fn codex_catalog_only_sync_after_restart_preserves_complete_backups() { ) .unwrap()); assert_eq!(fs::read(&config_path).unwrap(), original_config); - test_restore_history(AgentClient::Codex, &home, "before-sync"); + test_restore_backup(AgentClient::Codex, &home, &manual_backup.id); assert_eq!(fs::read(&config_path).unwrap(), original_config); assert_eq!(fs::read_to_string(&catalog_path).unwrap(), original_catalog); fs::remove_dir_all(home).unwrap(); @@ -1549,6 +1247,7 @@ fn codex_catalog_sync_rejects_invalid_snapshots_without_changing_files() { let home = agent_test_home("codex-runtime-catalog-invalid-snapshot"); let config_path = home.join(".codex/config.toml"); let catalog_path = codex_model_catalog_path(&home); + let manual_backup = create_backup("codex", &home).unwrap(); apply_agent_configuration( AgentClient::Codex, &home, @@ -1588,7 +1287,7 @@ fn codex_catalog_sync_rejects_invalid_snapshots_without_changing_files() { serde_json::to_value(load_agent_applied_state(AgentClient::Codex, &home).unwrap()).unwrap(), state_before ); - test_restore_history(AgentClient::Codex, &home, "before-update"); + test_restore_backup(AgentClient::Codex, &home, &manual_backup.id); fs::remove_dir_all(home).unwrap(); } @@ -1654,16 +1353,16 @@ fn direct_agent_apply_preserves_unrelated_fields_and_default_reset_removes_them( assert!(!agent_backup_path(&codex_model_catalog_path(&home)) .unwrap() .exists()); - assert!(test_history_count(AgentClient::Codex, &home) >= 2); + assert_eq!(test_backup_count(AgentClient::Codex, &home), 0); let mut document = applied.parse::().unwrap(); document["user_setting"] = toml_edit::value("changed-externally"); fs::write(&path, document.to_string()).unwrap(); - assert!(test_history_count(AgentClient::Codex, &home) >= 2); + assert_eq!(test_backup_count(AgentClient::Codex, &home), 0); document["model"] = toml_edit::value("external-model"); fs::write(&path, document.to_string()).unwrap(); - assert!(test_history_count(AgentClient::Codex, &home) >= 2); + assert_eq!(test_backup_count(AgentClient::Codex, &home), 0); apply_agent_configuration( AgentClient::Codex, @@ -1715,7 +1414,7 @@ fn clear_codex_config_removes_only_requested_config_files_and_preserves_legacy_s assert!(!auth_path.exists()); assert!(!config_path.exists()); assert!(state_path.exists()); - assert_eq!(test_history_count(AgentClient::Codex, &home), 2); + assert_eq!(test_backup_count(AgentClient::Codex, &home), 0); assert_eq!(fs::read_to_string(&preserved_path).unwrap(), "keep"); assert!(clear_codex_config_files(&home).unwrap().is_empty()); @@ -1740,7 +1439,7 @@ fn clear_codex_config_removes_only_requested_config_files_and_preserves_legacy_s } #[test] -fn ordinary_update_rejects_invalid_file_and_explicit_default_backs_it_up() { +fn ordinary_update_rejects_invalid_file_and_template_repairs_without_backup() { let home = agent_test_home("invalid-apply"); let path = home.join(".config/opencode/opencode.json"); fs::create_dir_all(path.parent().unwrap()).unwrap(); @@ -1749,9 +1448,9 @@ fn ordinary_update_rejects_invalid_file_and_explicit_default_backs_it_up() { assert!(apply_agent_configuration(AgentClient::OpenCode, &home, 8317, DEFAULT_API_KEY, "model-a", &models, None).is_err()); assert_eq!(fs::read_to_string(&path).unwrap(), "{ invalid json"); - assert_eq!(test_history_count(AgentClient::OpenCode, &home), 0); + assert_eq!(test_backup_count(AgentClient::OpenCode, &home), 0); reset_agent_configuration_to_default(AgentClient::OpenCode, &home, 8317, DEFAULT_API_KEY, "model-a", None).unwrap(); - assert_eq!(test_history_count(AgentClient::OpenCode, &home), 2); + assert_eq!(test_backup_count(AgentClient::OpenCode, &home), 0); assert!(serde_json::from_slice::(&fs::read(path).unwrap()).is_ok()); fs::remove_dir_all(home).unwrap(); } diff --git a/src-tauri/src/tests/alias_save.rs b/src-tauri/src/tests/alias_save.rs index 2bcf1f2..484284a 100644 --- a/src-tauri/src/tests/alias_save.rs +++ b/src-tauri/src/tests/alias_save.rs @@ -350,7 +350,7 @@ impl DesktopRestoreFixture { core_versions.push( ensure_claude_desktop_model_aliases_in_yaml(initial, &mappings, &models).unwrap(), ); - let result = apply_agent_configuration_with_oauth( + let _result = apply_agent_configuration_with_oauth( AgentClient::ClaudeDesktop, &home, 8317, @@ -366,7 +366,7 @@ impl DesktopRestoreFixture { ) .unwrap(); if name == "model-a" { - id = result.history_version; + id = Some(create_backup("claude-desktop", &home).unwrap().id); } } Self { @@ -385,7 +385,7 @@ impl Drop for DesktopRestoreFixture { } #[tokio::test(flavor = "current_thread")] -async fn desktop_history_restore_updates_core_routes_and_mapping_metadata() { +async fn desktop_backup_restore_updates_core_routes_and_mapping_metadata() { let fixture = DesktopRestoreFixture::new(); let core = MockCore::new(&fixture.core_b, Failure::None); let plan = prepare_restore_plan(&core.config, "claude-desktop", &fixture.home, &fixture.id) @@ -397,7 +397,7 @@ async fn desktop_history_restore_updates_core_routes_and_mapping_metadata() { .await .unwrap(); assert_eq!( - current_desktop_history_mappings(&fixture.home) + current_desktop_mappings(&fixture.home) .unwrap() .opus, "model-a" @@ -407,7 +407,7 @@ async fn desktop_history_restore_updates_core_routes_and_mapping_metadata() { } #[tokio::test(flavor = "current_thread")] -async fn desktop_history_restore_rejects_core_changes_since_preview() { +async fn desktop_backup_restore_rejects_core_changes_since_preview() { let fixture = DesktopRestoreFixture::new(); let core = MockCore::new(&fixture.core_b, Failure::None); let preview = prepare_restore_plan(&core.config, "claude-desktop", &fixture.home, &fixture.id) @@ -427,7 +427,7 @@ async fn desktop_history_restore_rejects_core_changes_since_preview() { .contains("预览后") ); assert_eq!( - current_desktop_history_mappings(&fixture.home) + current_desktop_mappings(&fixture.home) .unwrap() .opus, "model-b" @@ -436,23 +436,23 @@ async fn desktop_history_restore_rejects_core_changes_since_preview() { } #[tokio::test(flavor = "current_thread")] -async fn desktop_history_restore_rolls_back_core_when_local_files_change() { +async fn desktop_backup_restore_rolls_back_core_when_local_files_change() { let fixture = DesktopRestoreFixture::new(); let core = MockCore::new(&fixture.core_b, Failure::None); let plan = prepare_restore_plan(&core.config, "claude-desktop", &fixture.home, &fixture.id) .await .unwrap(); let revision = plan.preview.revision.clone(); - let paths = history_paths("claude-desktop", &fixture.home).unwrap(); + let paths = config_paths("claude-desktop", &fixture.home).unwrap(); fs::write(&paths[0], r#"{"deploymentMode":"3p","external":true}"#).unwrap(); - let before = history_images(&paths).unwrap(); + let before = config_images(&paths).unwrap(); assert!(execute_restore_plan(&core.config, plan, &revision) .await .unwrap_err() .contains("已恢复原配置")); - assert_eq!(history_images(&paths).unwrap(), before); + assert_eq!(config_images(&paths).unwrap(), before); assert_eq!( - current_desktop_history_mappings(&fixture.home) + current_desktop_mappings(&fixture.home) .unwrap() .opus, "model-b" @@ -461,21 +461,21 @@ async fn desktop_history_restore_rolls_back_core_when_local_files_change() { } #[tokio::test(flavor = "current_thread")] -async fn desktop_history_restore_does_not_write_local_files_when_core_save_fails() { +async fn desktop_backup_restore_does_not_write_local_files_when_core_save_fails() { let fixture = DesktopRestoreFixture::new(); let core = MockCore::new(&fixture.core_b, Failure::YamlAfterWrite); let plan = prepare_restore_plan(&core.config, "claude-desktop", &fixture.home, &fixture.id) .await .unwrap(); let revision = plan.preview.revision.clone(); - let paths = history_paths("claude-desktop", &fixture.home).unwrap(); - let before = history_images(&paths).unwrap(); + let paths = config_paths("claude-desktop", &fixture.home).unwrap(); + let before = config_images(&paths).unwrap(); assert!(execute_restore_plan(&core.config, plan, &revision) .await .is_err()); - assert_eq!(history_images(&paths).unwrap(), before); + assert_eq!(config_images(&paths).unwrap(), before); assert_eq!( - current_desktop_history_mappings(&fixture.home) + current_desktop_mappings(&fixture.home) .unwrap() .opus, "model-b" @@ -495,6 +495,88 @@ async fn alias_transaction_rolls_back_when_followup_commit_fails() { assert_eq!(core.finish().0, yaml_json(CURRENT)); } +#[tokio::test(flavor = "current_thread")] +async fn desktop_update_and_template_preserve_core_access_and_rollback_failed_local_changes() { + let initial = "debug: true\napi-keys: [client-key]\nopenai-compatibility:\n - name: provider\n base-url: https://provider.test/v1\n api-key-entries: [{api-key: provider-secret}]\n custom: {nested: keep}\n models: [{name: model-a}]\ncodex-api-key:\n - api-key: unrelated-secret\n base-url: https://codex.test\n models: [{name: unrelated-model}]\n"; + let legacy = initial.replace( + "{name: model-a}", + &format!("{{name: model-a, alias: {CLAUDE_DESKTOP_OPUS_MODEL_ID}}}"), + ); + let models = super::support::test_agent_models(&["model-a"]); + let mappings = ClaudeDesktopModelMappings::all("model-a"); + for initial in [initial, legacy.as_str()] { + for template in [false, true] { + for fail in [false, true] { + let home = super::support::agent_test_home("desktop-protected-update"); + let paths = config_paths("claude-desktop", &home).unwrap(); + if template { + fs::create_dir_all(paths[0].parent().unwrap()).unwrap(); + fs::write(&paths[0], "broken-client-config").unwrap(); + } + let before = config_images(&paths).unwrap(); + let core = MockCore::new(initial, Failure::None); + let result = commit_agent_with_core(&core.config, Some(&mappings), &models, || { + if fail { + return Err("local write rejected".into()); + } + if template { + reset_agent_configuration_to_default_with_oauth(AgentDefaultConfiguration { + client: AgentClient::ClaudeDesktop, + home: &home, + port: 8317, + api_key: "client-key", + model: "model-a", + models: &models, + codex_catalog: None, + oauth_configuration: false, + claude_code_model_mappings: None, + claude_desktop_model_mappings: Some(&mappings), + }) + } else { + apply_agent_configuration_with_oauth( + AgentClient::ClaudeDesktop, + &home, + 8317, + "client-key", + "model-a", + AgentConfigurationOptions { + models: &models, + codex_catalog: None, + oauth_configuration: false, + claude_code_model_mappings: None, + claude_desktop_model_mappings: Some(&mappings), + }, + ) + } + }) + .await; + let (persisted, _) = core.finish(); + if fail { + assert!(result.is_err()); + assert_eq!(persisted, yaml_json(initial)); + assert_eq!(before, config_images(&paths).unwrap()); + } else { + result.unwrap(); + assert_eq!(persisted["api-keys"], yaml_json(initial)["api-keys"]); + assert_eq!( + persisted["codex-api-key"], + yaml_json(initial)["codex-api-key"] + ); + let mut provider = persisted["openai-compatibility"][0].clone(); + provider.as_object_mut().unwrap().remove("models"); + let mut original = yaml_json(initial)["openai-compatibility"][0].clone(); + original.as_object_mut().unwrap().remove("models"); + assert_eq!(provider, original); + assert_eq!(persisted["debug"], true); + assert_eq!(current_desktop_mappings(&home).unwrap().opus, "model-a"); + } + assert_eq!(test_backup_count(AgentClient::ClaudeDesktop, &home), 0); + fs::remove_dir_all(home).unwrap(); + } + } + } +} + #[tokio::test(flavor = "current_thread")] async fn alias_transaction_checks_core_before_a_local_only_commit() { let current = format!("{CURRENT}debug: true\n"); diff --git a/src-tauri/src/tests/desktop_alias_routing.rs b/src-tauri/src/tests/desktop_alias_routing.rs index c77c3f7..1c40596 100644 --- a/src-tauri/src/tests/desktop_alias_routing.rs +++ b/src-tauri/src/tests/desktop_alias_routing.rs @@ -95,13 +95,18 @@ fn desktop_routes_skip_disabled_sources_during_creation() { #[test] fn desktop_routes_reject_sources_available_only_in_disabled_providers() { - let input = "openai-compatibility:\n - name: disabled-provider\n disabled: true\n models: [{name: model-a}]\n"; - let result = ensure_claude_desktop_model_aliases_in_yaml( - input, - &ClaudeDesktopModelMappings::all("model-a"), - &test_agent_models(&["model-a"]), - ); - assert!(result.is_err()); + for entry in [ + "{name: model-a}".to_string(), + format!("{{name: model-a, alias: {CLAUDE_DESKTOP_OPUS_MODEL_ID}}}"), + ] { + let input = format!("openai-compatibility:\n - name: disabled-provider\n disabled: true\n models: [{entry}]\n"); + let result = ensure_claude_desktop_model_aliases_in_yaml( + &input, + &ClaudeDesktopModelMappings::all("model-a"), + &test_agent_models(&["model-a"]), + ); + assert!(result.is_err()); + } } #[test] @@ -111,3 +116,142 @@ fn disabled_provider_alias_does_not_mark_an_active_real_model_as_an_alias() { mark_configured_agent_model_aliases(&mut models, input).unwrap(); assert!(!models[0].is_alias); } + +#[test] +fn legacy_desktop_alias_only_provider_retains_its_source_during_reconfiguration() { + let input = format!( + "codex-api-key:\n - api-key: preserved-key\n base-url: https://example.test\n models:\n - name: model-a\n alias: {opus}\n context-length: 123456\n", + opus = CLAUDE_DESKTOP_OPUS_MODEL_ID, + ); + // Older configurations can retain only the aliased entry for the upstream model. + let result = ensure_claude_desktop_model_aliases_in_yaml( + &input, + &ClaudeDesktopModelMappings::all("model-a"), + &test_agent_models(&["model-a"]), + ); + let updated = result.unwrap(); + let after = json(&updated); + let mut provider = after["codex-api-key"][0].clone(); + let entries = provider.as_object_mut().unwrap().remove("models").unwrap(); + let mut original_provider = json(&input)["codex-api-key"][0].clone(); + original_provider.as_object_mut().unwrap().remove("models"); + assert_eq!(provider, original_provider); + for route in [ + CLAUDE_DESKTOP_OPUS_MODEL_ID, + CLAUDE_DESKTOP_SONNET_MODEL_ID, + CLAUDE_DESKTOP_HAIKU_MODEL_ID, + ] { + let entry = entries + .as_array() + .unwrap() + .iter() + .find(|m| m["alias"] == route) + .unwrap(); + assert_eq!(entry["name"], "model-a"); + assert_eq!(entry["context-length"], 123456); + } + assert_eq!( + json( + &ensure_claude_desktop_model_aliases_in_yaml( + &updated, + &ClaudeDesktopModelMappings::all("model-a"), + &test_agent_models(&["model-a"]), + ) + .unwrap() + ), + after, + ); +} + +#[test] +fn legacy_desktop_alias_source_is_resolved_before_removing_its_route() { + let input = format!( + "codex-api-key:\n - api-key: preserved-key\n models: [{{name: model-a, alias: {opus}}}]\n", + opus = CLAUDE_DESKTOP_OPUS_MODEL_ID, + ); + // Legacy callers and restore previews may have model IDs without is_alias metadata. + let selected = CLAUDE_DESKTOP_OPUS_MODEL_ID; + let updated = ensure_claude_desktop_model_aliases_in_yaml( + &input, + &ClaudeDesktopModelMappings::all(selected), + &test_agent_models(&[selected]), + ) + .unwrap(); + let entries = json(&updated)["codex-api-key"][0]["models"] + .as_array() + .unwrap() + .clone(); + assert!(entries.iter().all(|m| m["name"] == "model-a")); + assert!(entries.iter().any(|m| m["alias"] == selected)); +} + +#[test] +fn legacy_desktop_oauth_alias_supplies_its_existing_channel_without_model_definitions() { + for channel in ["codex", "antigravity"] { + let input = format!( + "oauth-model-alias:\n {channel}:\n - name: old-model\n alias: {opus}\n fork: true\n custom: {{retained: true}}\n", + opus = CLAUDE_DESKTOP_OPUS_MODEL_ID, + ); + let updated = ensure_claude_desktop_model_aliases_in_yaml( + &input, + &ClaudeDesktopModelMappings::all("old-model"), + &test_agent_models(&["old-model"]), + ) + .unwrap(); + let after = json(&updated); + let entries = after["oauth-model-alias"][channel].as_array().unwrap(); + assert_eq!(entries.len(), 3); + assert!(entries.iter().all(|entry| entry["name"] == "old-model" + && entry["fork"] == true + && entry["custom"]["retained"] == true)); + if channel == "antigravity" { + assert!(entries.iter().all(|entry| entry["force-mapping"] == true)); + } + } +} + +#[test] +fn legacy_desktop_roles_resolve_from_the_same_original_configuration() { + let input = format!( + "codex-api-key:\n - models: [{{name: old-model, alias: {opus}}}, {{name: new-model}}]\n", + opus = CLAUDE_DESKTOP_OPUS_MODEL_ID, + ); + let updated = ensure_claude_desktop_model_aliases_in_yaml( + &input, + &ClaudeDesktopModelMappings { + sonnet: "old-model".into(), + ..ClaudeDesktopModelMappings::all("new-model") + }, + &test_agent_models(&["old-model", "new-model"]), + ) + .unwrap(); + let after = json(&updated); + let entries = after["codex-api-key"][0]["models"].as_array().unwrap(); + for (alias, source) in [ + (CLAUDE_DESKTOP_OPUS_MODEL_ID, "new-model"), + (CLAUDE_DESKTOP_SONNET_MODEL_ID, "old-model"), + (CLAUDE_DESKTOP_HAIKU_MODEL_ID, "new-model"), + ] { + assert!(entries + .iter() + .any(|entry| entry["alias"] == alias && entry["name"] == source)); + } +} + +#[test] +fn legacy_desktop_upstream_fallback_does_not_shadow_an_exact_oauth_alias() { + let input = "codex-api-key:\n - models: [{name: chosen-model, alias: unrelated-alias}]\noauth-model-alias:\n codex:\n - name: actual-upstream\n alias: chosen-model\n fork: true\n"; + let updated = ensure_claude_desktop_model_aliases_in_yaml( + input, + &ClaudeDesktopModelMappings::all("chosen-model"), + &test_agent_models(&["chosen-model"]), + ) + .unwrap(); + let after = json(&updated); + assert_eq!(after["codex-api-key"], json(input)["codex-api-key"]); + let entries = after["oauth-model-alias"]["codex"].as_array().unwrap(); + assert_eq!(entries.len(), 4); + assert!(entries + .iter() + .all(|entry| entry["name"] == "actual-upstream")); +} diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index fb10a95..0c271d7 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -810,28 +810,34 @@ export const jaOverrides = { 'agents.status.invalid': '設定が無効', 'agents.status.applied': '適用済み', 'agents.status.unconfigured': '未設定', - "agents.history.button": "設定履歴", - "agents.history.description": "ソフトウェア管理の項目のみ復元し、他の設定を保持します。復元前に現在の設定を保存し、フォームを更新します。", - "agents.history.versions": "履歴", - "agents.history.empty": "履歴はありません。設定更新時に自動保存されます。", - "agents.history.files": "{count} ファイル", - "agents.history.preview": "復元プレビュー", - "agents.history.loading": "読み込み中…", - "agents.history.select": "履歴を選択して復元する項目を確認してください。", - "agents.history.unchanged": "設定は最新です。", - "agents.history.updated": "設定を更新し、履歴を保存しました。", - "agents.history.restored": "設定を復元しました。他の設定は保持されています。", - "agents.history.field": "項目", - "agents.history.current": "現在", - "agents.history.target": "履歴値", - "agents.history.restore": "この履歴を復元", - "agents.history.before": "{source}前のバックアップ", - "agents.history.source.update": "設定更新", - "agents.history.source.default": "設定の再構築", - "agents.history.source.clear": "設定消去", - "agents.history.source.restore": "復元", - "agents.history.source.sync": "自動同期", - "agents.history.source.legacy": "旧バージョンのバックアップ", + "agents.backup.create": "手動バックアップ", + "agents.backup.button": "バックアップを復元", + "agents.backup.description": "管理対象の全ファイルを、独自設定を含む元の内容と存在状態に復元します。接続先とキーも保存時の値になります。自動バックアップは作成しません。", + "agents.backup.versions": "バックアップ一覧", + "agents.backup.empty": "手動バックアップはありません。「手動バックアップ」でディスク上の現在の設定を保存できます。", + "agents.backup.files": "{count} ファイル", + "agents.backup.preview": "復元プレビュー", + "agents.backup.loading": "読み込み中…", + "agents.backup.select": "バックアップを選択してファイルを確認してください。", + "agents.backup.unchanged": "設定は最新です。書き込みは不要です。", + "agents.backup.updated": "設定を更新しました。", + "agents.backup.restored": "バックアップを復元し、設定状態とフォームを更新しました。", + "agents.backup.created": "ディスク上の現在の設定を手動バックアップしました。未保存のフォーム編集は含まれません。", + "agents.backup.location": "バックアップの保存先", + "agents.backup.invalid": "復元不可", + "agents.backup.present": "保存時に存在", + "agents.backup.missing": "保存時に存在しないため、復元時に削除", + "agents.backup.overwrite": "バックアップの原文で全ファイルを上書きします。", + "agents.backup.restore": "このバージョンを復元", + "agents.backup.delete": "このバージョンを削除", + "agents.backup.restoreConfirm": "バックアップ {version} を復元しますか?", + "agents.backup.deleteConfirm": "バックアップ {version} を削除しますか?", + "agents.backup.restoreWarning": "一覧のファイル内の独自設定も上書きします。保存時に存在しなかったファイルは削除します。自動バックアップは作成しません。", + "agents.backup.deleteWarning": "このバックアップだけを削除します。現在の設定や他のバックアップには影響しません。", + "agents.backup.confirmRestore": "復元を確定", + "agents.backup.confirmDelete": "削除を確定", + "agents.backup.mappingRequired": "信頼できる Claude Desktop のモデル割り当てがありません。モデルを選び直してください。", + "agents.backup.mappingRestore": "保存したモデル割り当てを復元:", 'agents.title': 'エージェント設定', 'agents.embedded.title': 'クライアントとモデルを選んで起動', 'agents.embedded.subtitle': 'ない場合は先にインストールしてください', @@ -917,7 +923,7 @@ export const jaOverrides = { "agents.modify.disconnected": "現在の接続設定:CPA 未設定", "agents.modify.connectionChecking": "現在の接続設定:確認中…", "agents.modify.connectionUnknown": "現在の接続設定:判定できません。設定を確認してください", - "agents.modify.description": "選択したモデルと認証方式をローカル設定に書き込みます。書き込み前に自動バックアップします。", + "agents.modify.description": "選択したモデルと認証方式を書き込み、無関係な設定を保持します。", "agents.modify.authApiKeyHint": "CPA の API を使用して接続し、より安定した接続状態を維持します。", "agents.modify.authOAuthHint": "CPA 経由で接続し、アカウントのログイン状態を保持して Codex の全サービスを利用できます。事前に Codex でアカウントにログインしてください。", "agents.modify.authHelpLabel": "説明", @@ -966,7 +972,7 @@ export const jaOverrides = { 'agents.modify.apply': '設定変更を適用', 'agents.modify.update': '設定を更新', 'agents.modify.close': '設定変更を終了', - "agents.modify.default": "設定を再構築", + "agents.modify.default": "基本設定テンプレート", 'agents.modify.clear': '設定を消去', "agents.modify.invalid": "通常の更新は停止しています。設定ファイルを修正するか、「設定を再構築」で再生成してください。", "agents.modify.zcodeRestart": "設定更新後、ZCode を完全終了して再起動してください。デフォルトモデルとコンテキスト長は新しいセッションで有効になります。", @@ -981,11 +987,11 @@ export const jaOverrides = { 'agents.pi.repairing': '設定を適用中', 'agents.pi.uninstall': 'Pi プラグインをアンインストール', 'agents.pi.uninstalling': 'プラグインをアンインストール中', - "agents.default.title": "設定を再構築", - 'agents.default.description': '選択中のモデルを使って {name} の関連設定ファイルをすべて書き直し、その他のユーザー内容を削除します。操作前に履歴を保存します。履歴から復元できるのは管理対象フィールドのみで、削除された独自設定は復元されません。', - "agents.default.confirm": "再構築を確認", + "agents.default.title": "基本設定テンプレートを適用", + "agents.default.description": "現在の CPA 接続先、キー、モデルと認証方式で {name} の下記ファイルをすべて上書きします。独自設定は消去され、自動バックアップは作成しません。Pi は設定と必要なプラグイン参照のみを書き込み、パッケージのインストールや削除は行いません。", + "agents.default.confirm": "上書きを確定", 'agents.clear.title': 'Codex 設定を消去', - 'agents.clear.description': 'Codex 設定ディレクトリの auth.json と config.toml を完全に削除します。操作前に履歴を保存します。履歴から復元できるのは管理対象フィールドのみで、削除された独自設定は復元されません。', + "agents.clear.description": "Codex の auth.json と config.toml を削除します。自動バックアップは作成しません。既存の手動バックアップから全ファイルと独自設定を復元できます。", 'agents.clear.confirm': '設定を消去', 'agents.clear.success': 'Codex 設定を消去しました。', 'apiAccess.compatibleName': 'OpenAI 互換 {number}', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 89c182a..71fad5d 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -810,28 +810,34 @@ export const en: Record = { 'agents.status.invalid': 'Invalid Configuration', 'agents.status.applied': 'Applied', 'agents.status.unconfigured': 'Not Configured', - "agents.history.button": "Configuration History", - "agents.history.description": "Restore only software-managed fields and preserve other settings. Current configuration is backed up first; form drafts refresh after restoring.", - "agents.history.versions": "Versions", - "agents.history.empty": "No history yet. Updating configuration saves versions automatically.", - "agents.history.files": "{count} files", - "agents.history.preview": "Restore preview", - "agents.history.loading": "Loading…", - "agents.history.select": "Select a version to preview the fields to restore.", - "agents.history.unchanged": "Configuration is already up to date.", - "agents.history.updated": "Configuration updated and history saved.", - "agents.history.restored": "Configuration restored. Other custom settings were preserved.", - "agents.history.field": "Field", - "agents.history.current": "Current", - "agents.history.target": "Historical", - "agents.history.restore": "Restore this version", - "agents.history.before": "Before {source}", - "agents.history.source.update": "Update", - "agents.history.source.default": "Rebuild configuration", - "agents.history.source.clear": "Clear configuration", - "agents.history.source.restore": "Restore", - "agents.history.source.sync": "Automatic sync", - "agents.history.source.legacy": "Legacy backup", + "agents.backup.create": "Manual Backup", + "agents.backup.button": "Restore Backup", + "agents.backup.description": "Restore the original contents and existence of the entire managed file set, including custom settings. Addresses and keys remain exactly as backed up. No automatic backup is created.", + "agents.backup.versions": "Backup versions", + "agents.backup.empty": "No manual backups yet. Use Manual Backup to save the current files on disk.", + "agents.backup.files": "{count} files", + "agents.backup.preview": "Restore preview", + "agents.backup.loading": "Loading…", + "agents.backup.select": "Select a backup to inspect its files.", + "agents.backup.unchanged": "Configuration is already up to date.", + "agents.backup.updated": "Configuration updated.", + "agents.backup.restored": "Backup restored. Configuration and form values have been refreshed.", + "agents.backup.created": "Current files on disk saved as a manual backup. Unsaved form changes were not included.", + "agents.backup.location": "Backup location", + "agents.backup.invalid": "Not restorable", + "agents.backup.present": "Present in backup", + "agents.backup.missing": "Absent in backup; removed on restore", + "agents.backup.overwrite": "Restoring replaces the entire file set with the original backup contents.", + "agents.backup.restore": "Restore this version", + "agents.backup.delete": "Delete this version", + "agents.backup.restoreConfirm": "Restore backup {version}?", + "agents.backup.deleteConfirm": "Delete backup {version}?", + "agents.backup.restoreWarning": "Custom contents in the listed files will be replaced. Files absent in the backup will be removed. No automatic backup is created.", + "agents.backup.deleteWarning": "This deletes only this backup version. Current configuration and other backups are unchanged.", + "agents.backup.confirmRestore": "Confirm Restore", + "agents.backup.confirmDelete": "Confirm Delete", + "agents.backup.mappingRequired": "No reliable Claude Desktop model mapping is available. Select the models again.", + "agents.backup.mappingRestore": "Restore the saved model mapping:", 'agents.title': 'Agent Configuration', 'agents.embedded.title': 'Select a client and model, then start', 'agents.embedded.subtitle': 'Install it first if it is not available', @@ -935,7 +941,7 @@ export const en: Record = { "agents.modify.disconnected": "Current integration: CPA not configured", "agents.modify.connectionChecking": "Current integration: checking…", "agents.modify.connectionUnknown": "Current integration: unknown; check configuration", - "agents.modify.description": "Write the selected model and authentication settings to local configuration. A backup is saved first.", + "agents.modify.description": "Write the selected model and authentication settings while preserving unrelated settings.", "agents.modify.authApiKeyHint": "Connect using the CPA API for a more stable connection.", "agents.modify.authOAuthHint": "Connect through CPA while retaining your account sign-in state and access to the full Codex service. Sign in to your account in Codex first.", "agents.modify.authHelpLabel": "Details", @@ -966,7 +972,7 @@ export const en: Record = { 'agents.modify.apply': 'Apply Configuration Changes', 'agents.modify.update': 'Update Configuration', 'agents.modify.close': 'Close Configuration Changes', - "agents.modify.default": "Rebuild Configuration", + "agents.modify.default": "Basic Configuration Template", 'agents.modify.clear': 'Clear Configuration', "agents.modify.invalid": "Normal updates are stopped. Fix the configuration file, or regenerate it using Rebuild Configuration.", "agents.modify.zcodeRestart": "Fully quit and restart ZCode after updating configuration; the default model and context length take effect in new sessions.", @@ -981,11 +987,11 @@ export const en: Record = { 'agents.pi.repairing': 'Applying configuration', 'agents.pi.uninstall': 'Uninstall Pi Plugin', 'agents.pi.uninstalling': 'Uninstalling plugin', - "agents.default.title": "Rebuild Configuration", - 'agents.default.description': 'This rewrites every related {name} configuration file using the selected model and deletes all other user content in those files. A history snapshot is saved first. History recovery restores only managed fields, not deleted custom settings.', - "agents.default.confirm": "Confirm Rebuild", + "agents.default.title": "Apply Basic Configuration Template", + "agents.default.description": "Overwrite all listed {name} configuration files using the current CPA address, key, selected models and authentication options. Custom contents will be cleared and no automatic backup is created. For Pi, this writes configuration and required plugin references without installing or uninstalling packages.", + "agents.default.confirm": "Confirm Overwrite", 'agents.clear.title': 'Clear Codex Configuration', - 'agents.clear.description': 'This permanently deletes auth.json and config.toml from the Codex configuration directory. A history snapshot is saved first. History recovery restores only managed fields, not deleted custom settings.', + "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.', 'apiAccess.compatibleName': 'OpenAI Compatible {number}', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index c7a9041..1e336d3 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -809,28 +809,34 @@ export const zhCN = { 'agents.status.invalid': '配置无效', 'agents.status.applied': '已应用', 'agents.status.unconfigured': '尚未配置', - "agents.history.button": "配置历史", - "agents.history.description": "仅恢复软件管理的配置字段,保留其他自定义设置。恢复前会自动备份当前配置;当前表单草稿将按恢复结果刷新。", - "agents.history.versions": "历史版本", - "agents.history.empty": "暂无配置历史。更新配置后会自动保留版本。", - "agents.history.files": "{count} 个文件", - "agents.history.preview": "恢复变更预览", - "agents.history.loading": "正在读取…", - "agents.history.select": "选择一个历史版本,查看将恢复的字段。", - "agents.history.unchanged": "配置已是最新,无需更新。", - "agents.history.updated": "配置已更新,历史备份已保存。", - "agents.history.restored": "已恢复历史配置,其他自定义设置已保留。", - "agents.history.field": "配置字段", - "agents.history.current": "当前值", - "agents.history.target": "历史值", - "agents.history.restore": "恢复此版本", - "agents.history.before": "{source}前备份", - "agents.history.source.update": "更新配置", - "agents.history.source.default": "重建配置", - "agents.history.source.clear": "清空配置", - "agents.history.source.restore": "恢复配置", - "agents.history.source.sync": "自动同步", - "agents.history.source.legacy": "旧版备份", + "agents.backup.create": "手动备份", + "agents.backup.button": "恢复备份", + "agents.backup.description": "按原始内容和存在状态恢复整组受管理文件,包括自定义设置。地址和密钥保持备份中的原值。恢复不会自动生成备份。", + "agents.backup.versions": "备份版本", + "agents.backup.empty": "暂无手动备份。点击“手动备份”保存当前磁盘配置。", + "agents.backup.files": "{count} 个文件", + "agents.backup.preview": "恢复预览", + "agents.backup.loading": "加载中…", + "agents.backup.select": "选择备份以查看文件清单。", + "agents.backup.unchanged": "配置已是最新,无需写入。", + "agents.backup.updated": "配置已更新。", + "agents.backup.restored": "备份已恢复,配置状态和表单已刷新。", + "agents.backup.created": "已手动备份当前磁盘配置,未包含未保存的表单修改。", + "agents.backup.location": "备份位置", + "agents.backup.invalid": "不可恢复", + "agents.backup.present": "备份时存在", + "agents.backup.missing": "备份时不存在;恢复时删除", + "agents.backup.overwrite": "恢复将以备份原文覆盖整组文件。", + "agents.backup.restore": "恢复此版本", + "agents.backup.delete": "删除此版本", + "agents.backup.restoreConfirm": "恢复备份 {version}?", + "agents.backup.deleteConfirm": "删除备份 {version}?", + "agents.backup.restoreWarning": "所列文件中的自定义内容将被覆盖;备份时不存在的文件将被删除。本次操作不会自动备份。", + "agents.backup.deleteWarning": "仅删除此备份版本,不影响当前配置和其他备份。", + "agents.backup.confirmRestore": "确认恢复", + "agents.backup.confirmDelete": "确认删除", + "agents.backup.mappingRequired": "没有可靠的 Claude Desktop 模型映射,请重新选择模型。", + "agents.backup.mappingRestore": "恢复已保存的模型映射:", 'agents.title': '智能体配置', 'agents.embedded.title': '选择客户端和模型后启动', 'agents.embedded.subtitle': '如果没有客户端请自行安装', @@ -934,7 +940,7 @@ export const zhCN = { "agents.modify.disconnected": "当前接入:未接入 CPA", "agents.modify.connectionChecking": "当前接入:检测中…", "agents.modify.connectionUnknown": "当前接入:无法判断,请检查配置", - "agents.modify.description": "将所选模型和认证方式写入本机配置,写入前自动备份。", + "agents.modify.description": "将所选模型和认证方式写入本机配置,保留无关设置。", "agents.modify.authApiKeyHint": "使用 CPA 的API接入,状态更稳定。", "agents.modify.authOAuthHint": "仍通过 CPA 接入,保留账号登录状态,能使用完整的Codex服务,需在Codex中提前登录账号。", "agents.modify.authHelpLabel": "说明", @@ -965,7 +971,7 @@ export const zhCN = { 'agents.modify.apply': '应用配置修改', 'agents.modify.update': '更新配置', 'agents.modify.close': '关闭配置修改', - "agents.modify.default": "重建配置", + "agents.modify.default": "基础配置模板", 'agents.modify.clear': '清空配置', "agents.modify.invalid": "普通更新已停止。请修复配置文件,或通过“重建配置”重新生成。", "agents.modify.zcodeRestart": "更新配置后,请完全退出并重新启动 ZCode;默认模型和上下文长度会在新会话中生效。", @@ -980,11 +986,11 @@ export const zhCN = { 'agents.pi.repairing': '正在应用配置', 'agents.pi.uninstall': '卸载 Pi 插件', 'agents.pi.uninstalling': '正在卸载插件', - "agents.default.title": "重建配置", - 'agents.default.description': '这会使用当前选中模型重写 {name} 的全部相关配置文件,并删除其中其他用户内容。操作前会保存历史备份;历史恢复仅恢复软件管理的字段,不能恢复被清除的其他自定义内容。', - "agents.default.confirm": "确认重建配置", + "agents.default.title": "应用基础配置模板", + "agents.default.description": "使用当前 CPA 地址、密钥、所选模型及认证选项覆盖 {name} 的下列全部配置文件。自定义内容会被清除,且不会自动备份。Pi 仅写入配置和必要插件引用,不安装或卸载插件包。", + "agents.default.confirm": "确认覆盖", 'agents.clear.title': '清空 Codex 配置', - 'agents.clear.description': '这会永久删除 Codex 配置目录中的 auth.json 和 config.toml。操作前会保存历史备份;历史恢复仅恢复软件管理的字段,不能恢复被清除的其他自定义内容。', + "agents.clear.description": "这会删除 Codex 配置目录中的 auth.json 和 config.toml,不会自动备份。已有手动备份可恢复整组原始文件及自定义内容。", 'agents.clear.confirm': '确认清空配置', 'agents.clear.success': 'Codex 配置已清空。', 'apiAccess.compatibleName': 'OpenAI 兼容 {number}', diff --git a/src/pages/AgentConfigBackupDialog.css b/src/pages/AgentConfigBackupDialog.css new file mode 100644 index 0000000..b8e1fa7 --- /dev/null +++ b/src/pages/AgentConfigBackupDialog.css @@ -0,0 +1,23 @@ +.agent-backup-modal { width: min(1000px, calc(100vw - 40px)); max-height: calc(100vh - 40px); overflow: auto; box-sizing: border-box; padding: 22px; border: 1px solid var(--theme-e3e1db); border-radius: 16px; background: var(--theme-fffdf8); color: var(--theme-2d2a26); box-shadow: 0 24px 80px #0005; } +.agent-backup-modal::backdrop { background: #0007; } +.agent-backup-modal header, .agent-backup-modal footer { display: flex; gap: 12px; align-items: center; justify-content: space-between; } +.agent-backup-modal h2 { display: flex; align-items: center; gap: 8px; font-size: 18px; margin: 0; } +.agent-backup-modal p { font-size: 13px; line-height: 1.6; } +.agent-backup-modal footer { justify-content: flex-end; margin-top: 16px; } +.agent-backup-columns { display: grid; grid-template-columns: 240px minmax(0, 1fr); gap: 16px; margin-top: 16px; } +.agent-backup-columns nav { max-height: 52vh; overflow: auto; display: flex; flex-direction: column; gap: 8px; } +.agent-backup-columns nav button { display: grid; gap: 5px; text-align: left; border: 1px solid var(--theme-e3e1db); border-radius: 8px; padding: 12px; color: inherit; background: transparent; cursor: pointer; } +.agent-backup-columns nav button.active { border-color: var(--theme-4f825c); background: var(--theme-eef7f0); } +.agent-backup-columns nav span, .agent-backup-columns nav small { font-size: 12px; color: var(--theme-7a746c); } +.agent-backup-changes { min-width: 0; max-height: 52vh; overflow: auto; } +.agent-backup-changes table { width: 100%; table-layout: fixed; border-collapse: collapse; font-size: 12px; } +.agent-backup-changes td, .agent-backup-changes th { text-align: left; vertical-align: top; padding: 10px 8px; border-bottom: 1px solid var(--theme-e3e1db); overflow-wrap: anywhere; white-space: pre-wrap; } +.agent-backup-changes td small, .agent-backup-changes td code { display: block; } +.agent-backup-changes td small { color: var(--theme-7a746c); margin-bottom: 5px; } +@media (max-width: 760px) { .agent-backup-columns { grid-template-columns: 1fr; } .agent-backup-columns nav { max-height: 180px; } } + +.agent-backup-path, .agent-backup-files code, .agent-template-files code { overflow-wrap: anywhere; } +.agent-backup-files { padding-left: 20px; } +.agent-backup-files li { margin-bottom: 12px; } +.agent-backup-files small { display: block; margin-top: 4px; } +.agent-backup-confirmation { margin-top: 16px; padding: 16px; border: 1px solid currentColor; border-radius: 8px; } diff --git a/src/pages/AgentConfigBackupDialog.tsx b/src/pages/AgentConfigBackupDialog.tsx new file mode 100644 index 0000000..e7ccbea --- /dev/null +++ b/src/pages/AgentConfigBackupDialog.tsx @@ -0,0 +1,96 @@ +import { useEffect, useRef, useState } from 'react'; +import { invoke } from '@tauri-apps/api/core'; +import { ArchiveRestore, LoaderCircle, X } from 'lucide-react'; +import { useI18n } from '../i18n'; +import './AgentConfigBackupDialog.css'; + +type FileSummary = { path: string; exists: boolean | null; size: number | null }; +type Version = { id: string; createdAt: string | null; fileCount: number; location: string; files: FileSummary[]; restorable: boolean; error: string | null }; +type Listing = { versions: Version[] }; +type Preview = { revision: string; files: FileSummary[]; differences: { file: string; field: string; before: string; after: string }[] }; + +export function AgentConfigBackupDialog({ client, onClose, onRestored }: { + client: string; onClose: () => void; onRestored: () => Promise; +}) { + const { t, formatDate } = useI18n(); + const dialog = useRef(null); + const [listing, setListing] = useState({ versions: [] }); + const [selected, setSelected] = useState(null); + const [preview, setPreview] = useState(null); + const [confirmation, setConfirmation] = useState<'restore' | 'delete' | null>(null); + const [busy, setBusy] = useState(true); + const [error, setError] = useState(''); + useEffect(() => { + let disposed = false; + dialog.current?.showModal(); + void invoke('list_agent_config_backups', { client }) + .then((value) => { if (!disposed) setListing(value); }) + .catch((cause) => { if (!disposed) setError(String(cause)); }) + .finally(() => { if (!disposed) setBusy(false); }); + return () => { disposed = true; }; + }, [client]); + const choose = async (version: Version) => { + setBusy(true); setError(''); setSelected(version); setPreview(null); setConfirmation(null); + try { + if (version.restorable) setPreview(await invoke('preview_agent_config_backup', { client, id: version.id })); + } catch (cause) { setError(String(cause)); } + finally { setBusy(false); } + }; + const confirm = async () => { + if (!selected || !confirmation || (confirmation === 'restore' && !preview)) return; + setBusy(true); setError(''); + try { + if (confirmation === 'restore') { + await invoke('restore_agent_config_backup', { client, id: selected.id, revision: preview!.revision }); + await onRestored(); onClose(); + } else { + await invoke('delete_agent_config_backup', { client, id: selected.id }); + setSelected(null); setPreview(null); setConfirmation(null); + setListing(await invoke('list_agent_config_backups', { client })); + } + } catch (cause) { setError(String(cause)); setPreview(null); setConfirmation(null); } + finally { setBusy(false); } + }; + const date = (version: Version) => version.createdAt + ? formatDate(version.createdAt, { dateStyle: 'short', timeStyle: 'medium' }) : version.id; + return { event.preventDefault(); if (!busy) onClose(); }}> +

{t('agents.backup.button')}

+
+

{t('agents.backup.description')}

+
+ +
+ {busy &&

{t('agents.backup.loading')}

} + {!selected && !busy &&

{t('agents.backup.select')}

} + {selected && <> + {t('agents.backup.location')}

{selected.location}

+ {selected.error &&

{selected.error}

} +
    {selected.files.map((file) =>
  • + {file.path}{file.exists === null ? t('agents.backup.invalid') : file.exists ? `${t('agents.backup.present')} · ${file.size ?? 0} B` : t('agents.backup.missing')} +
  • )}
+ {preview &&

{t(preview.differences.length ? 'agents.backup.overwrite' : 'agents.backup.unchanged')}

} + {preview?.differences.filter((diff) => diff.field.startsWith('modelMappings')).map((diff) =>

{t('agents.backup.mappingRestore')} {diff.field}

)} + } +
+
+ {confirmation && selected &&
+ {t(confirmation === 'restore' ? 'agents.backup.restoreConfirm' : 'agents.backup.deleteConfirm', { version: date(selected) })} +

{t(confirmation === 'restore' ? 'agents.backup.restoreWarning' : 'agents.backup.deleteWarning')}

+
} + {error &&

{error}

} +
+ + {confirmation ? : <> + + + } +
+
; +} diff --git a/src/pages/AgentConfigHistoryDialog.css b/src/pages/AgentConfigHistoryDialog.css deleted file mode 100644 index 3c592f0..0000000 --- a/src/pages/AgentConfigHistoryDialog.css +++ /dev/null @@ -1,17 +0,0 @@ -.agent-history-modal { width: min(1000px, calc(100vw - 40px)); max-height: calc(100vh - 40px); overflow: auto; box-sizing: border-box; padding: 22px; border: 1px solid var(--theme-e3e1db); border-radius: 16px; background: var(--theme-fffdf8); color: var(--theme-2d2a26); box-shadow: 0 24px 80px #0005; } -.agent-history-modal::backdrop { background: #0007; } -.agent-history-modal header, .agent-history-modal footer { display: flex; gap: 12px; align-items: center; justify-content: space-between; } -.agent-history-modal h2 { display: flex; align-items: center; gap: 8px; font-size: 18px; margin: 0; } -.agent-history-modal p { font-size: 13px; line-height: 1.6; } -.agent-history-modal footer { justify-content: flex-end; margin-top: 16px; } -.agent-history-columns { display: grid; grid-template-columns: 240px minmax(0, 1fr); gap: 16px; margin-top: 16px; } -.agent-history-columns nav { max-height: 52vh; overflow: auto; display: flex; flex-direction: column; gap: 8px; } -.agent-history-columns nav button { display: grid; gap: 5px; text-align: left; border: 1px solid var(--theme-e3e1db); border-radius: 8px; padding: 12px; color: inherit; background: transparent; cursor: pointer; } -.agent-history-columns nav button.active { border-color: var(--theme-4f825c); background: var(--theme-eef7f0); } -.agent-history-columns nav span, .agent-history-columns nav small { font-size: 12px; color: var(--theme-7a746c); } -.agent-history-changes { min-width: 0; max-height: 52vh; overflow: auto; } -.agent-history-changes table { width: 100%; table-layout: fixed; border-collapse: collapse; font-size: 12px; } -.agent-history-changes td, .agent-history-changes th { text-align: left; vertical-align: top; padding: 10px 8px; border-bottom: 1px solid var(--theme-e3e1db); overflow-wrap: anywhere; white-space: pre-wrap; } -.agent-history-changes td small, .agent-history-changes td code { display: block; } -.agent-history-changes td small { color: var(--theme-7a746c); margin-bottom: 5px; } -@media (max-width: 760px) { .agent-history-columns { grid-template-columns: 1fr; } .agent-history-columns nav { max-height: 180px; } } diff --git a/src/pages/AgentConfigHistoryDialog.tsx b/src/pages/AgentConfigHistoryDialog.tsx deleted file mode 100644 index dfd7e35..0000000 --- a/src/pages/AgentConfigHistoryDialog.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { useEffect, useRef, useState } from 'react'; -import { invoke } from '@tauri-apps/api/core'; -import { History, LoaderCircle, X } from 'lucide-react'; -import { useI18n } from '../i18n'; -import type { MessageKey } from '../i18n/resources'; -import './AgentConfigHistoryDialog.css'; - -type Version = { id: string; createdAt: string; source: string; model: string | null; fileCount: number }; -type Listing = { versions: Version[]; warnings: string[] }; -type Preview = { revision: string; differences: { file: string; field: string; before: string; after: string }[] }; -const sourceKeys: Record = { - 'plugin-install': 'agents.pi.install', 'plugin-update': 'agents.pi.update', 'plugin-remove': 'agents.pi.uninstall', - update: 'agents.history.source.update', default: 'agents.history.source.default', - clear: 'agents.history.source.clear', restore: 'agents.history.source.restore', - sync: 'agents.history.source.sync', legacy: 'agents.history.source.legacy', -}; - -export function AgentConfigHistoryDialog({ client, onClose, onRestored }: { - client: string; onClose: () => void; onRestored: () => Promise; -}) { - const { t, formatDate } = useI18n(); - const dialog = useRef(null); - const [listing, setListing] = useState({ versions: [], warnings: [] }); - const [selected, setSelected] = useState(null); - const [preview, setPreview] = useState(null); - const [busy, setBusy] = useState(true); - const [error, setError] = useState(''); - useEffect(() => { - let disposed = false; - dialog.current?.showModal(); - void invoke('list_agent_config_history', { client }) - .then((value) => { if (!disposed) setListing(value); }) - .catch((cause) => { if (!disposed) setError(String(cause)); }) - .finally(() => { if (!disposed) setBusy(false); }); - return () => { disposed = true; }; - }, [client]); - const choose = async (version: Version) => { - setBusy(true); setError(''); setSelected(version); setPreview(null); - try { setPreview(await invoke('preview_agent_config_history', { client, id: version.id })); } - catch (cause) { setError(String(cause)); } - finally { setBusy(false); } - }; - const restore = async () => { - if (!selected || !preview) return; - setBusy(true); setError(''); - try { - await invoke('restore_agent_config_history', { client, id: selected.id, revision: preview.revision }); - await onRestored(); onClose(); - } catch (cause) { setError(String(cause)); setPreview(null); } - finally { setBusy(false); } - }; - const source = (value: string) => { - const before = value.startsWith('before-'); - const label = t(sourceKeys[before ? value.slice(7) : value] ?? 'agents.history.source.update'); - return before ? t('agents.history.before', { source: label }) : label; - }; - return { event.preventDefault(); if (!busy) onClose(); }}> -

{t('agents.history.button')}

-
-

{t('agents.history.description')}

- {listing.warnings.map((warning) =>

{warning}

)} -
- -
- {busy ?

{t('agents.history.loading')}

: !preview ?

{t('agents.history.select')}

: preview.differences.length === 0 ?

{t('agents.history.unchanged')}

: - - {preview.differences.map((diff) => )}
{t('agents.history.field')}{t('agents.history.current')}{t('agents.history.target')}
{diff.file.split(/[\\/]/).pop()}{diff.field}{diff.before}{diff.after}
} -
-
- {error &&

{error}

} -
-
-
; -} diff --git a/src/pages/AgentsPage.tsx b/src/pages/AgentsPage.tsx index 614a766..47ec95b 100644 --- a/src/pages/AgentsPage.tsx +++ b/src/pages/AgentsPage.tsx @@ -68,7 +68,7 @@ import type { ModelOption } from '../services/modelService'; import { getCurrentLocale, translate, useI18n } from '../i18n'; import { CodexSessionsPanel } from './CodexSessionsPanel'; import { CodexModelCatalogDialog } from './CodexModelCatalogDialog'; -import { AgentConfigHistoryDialog } from './AgentConfigHistoryDialog'; +import { AgentConfigBackupDialog } from './AgentConfigBackupDialog'; type AgentClientId = | 'claude-code' @@ -128,7 +128,6 @@ type DeepSeekHarnessProcessStatus = { type AgentConfigActionResult = { outcome: 'applied' | 'default' | 'updated' | 'unchanged'; - historyVersion: string | null; enabled: boolean; model: string | null; changedFiles: string[]; @@ -674,7 +673,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP const [loading, setLoading] = useState(true); const [modelLoading, setModelLoading] = useState(false); const [busyAction, setBusyAction] = useState< - 'apply' | 'default' | 'clear' | 'install-pi' | 'update-pi' | 'repair-pi' | 'uninstall-pi' | 'oauth-check' | 'directory' | 'launch' | 'launch-cli' | 'launch-app' | 'restart-app' | 'stop-deepseek' | null + 'backup' | 'apply' | 'default' | 'clear' | 'install-pi' | 'update-pi' | 'repair-pi' | 'uninstall-pi' | 'oauth-check' | 'directory' | 'launch' | 'launch-cli' | 'launch-app' | 'restart-app' | 'stop-deepseek' | null >(null); const busy = busyAction !== null; const [detectionError, setDetectionError] = useState(''); @@ -682,9 +681,10 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP const [modelSelectionError, setModelSelectionError] = useState(''); const [configurationError, setConfigurationError] = useState(''); const [configurationNotice, setConfigurationNotice] = useState(''); - const [historyOpen, setHistoryOpen] = useState(false); + const [backupsOpen, setBackupsOpen] = useState(false); const [connectionHelpOpen, setConnectionHelpOpen] = useState(false); const [defaultError, setDefaultError] = useState(''); + const [templatePreview, setTemplatePreview] = useState<{ revision: string; files: string[] } | null>(null); const [defaultConfirmOpen, setDefaultConfirmOpen] = useState(false); const [clearError, setClearError] = useState(''); const [clearNotice, setClearNotice] = useState(''); @@ -854,7 +854,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP setModelSelectionError(''); setConfigurationError(''); setConfigurationNotice(''); - setHistoryOpen(false); + setBackupsOpen(false); setConnectionHelpOpen(false); setDefaultError(''); setDefaultConfirmOpen(false); @@ -947,13 +947,13 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP current, selected, appliedMappings, - createClaudeModelMappings(selectedModel), + createClaudeModelMappings(selected === 'claude-desktop' ? '' : selectedModel), dirty, ); const next: ClaudeModelMappings = { - opus: findAgentModel(models, source.opus)?.name ?? selectedModel, - sonnet: findAgentModel(models, source.sonnet)?.name ?? selectedModel, - haiku: findAgentModel(models, source.haiku)?.name ?? selectedModel, + opus: findAgentModel(models, source.opus)?.name ?? (selected === 'claude-desktop' ? '' : selectedModel), + sonnet: findAgentModel(models, source.sonnet)?.name ?? (selected === 'claude-desktop' ? '' : selectedModel), + haiku: findAgentModel(models, source.haiku)?.name ?? (selected === 'claude-desktop' ? '' : selectedModel), opus1m: Boolean(source.opus1m), sonnet1m: Boolean(source.sonnet1m), haiku1m: Boolean(source.haiku1m), @@ -1278,7 +1278,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP } await reloadStatusesAfterAction(); setOauthConfigurationDraft(null); - setConfigurationNotice(t(result.outcome === 'unchanged' ? 'agents.history.unchanged' : 'agents.history.updated')); + setConfigurationNotice(t(result.outcome === 'unchanged' ? 'agents.backup.unchanged' : 'agents.backup.updated')); onConfigurationApplied?.(); } catch (requestError) { if (!handleOAuthLoginError(requestError, 'apply')) { @@ -1330,7 +1330,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP try { const result = await invoke('repair_pi_provider', { model }); await reloadStatusesAfterAction(); - setConfigurationNotice(t(result.outcome === 'unchanged' ? 'agents.history.unchanged' : 'agents.history.updated')); + setConfigurationNotice(t(result.outcome === 'unchanged' ? 'agents.backup.unchanged' : 'agents.backup.updated')); onConfigurationApplied?.(); } catch (requestError) { setConfigurationError(String(requestError)); @@ -1354,6 +1354,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP }; const resetConfigurationToDefault = async () => { + if (!templatePreview) return; const claudeModelMappings = requireClaudeModelMappings(); if (isClaudeModelMappingClient && !claudeModelMappings) return; const model = isClaudeModelMappingClient @@ -1363,7 +1364,8 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP setBusyAction('default'); setDefaultError(''); try { - await invoke('reset_agent_config_to_default', { + await invoke('apply_agent_config_template', { + revision: templatePreview.revision, client: selected, model, oauthConfiguration, @@ -1374,11 +1376,17 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP if (isClaudeModelMappingClient) { claudeModelMappingsDirtyRef.current[selected] = false; } - await reloadStatusesAfterAction(); + const refreshed = await invoke('refresh_agent_config_statuses'); + setStatuses(refreshed); + const current = refreshed.find((status) => status.id === selected)?.currentModel; + setModelByClient((values) => { const next = { ...values, [selected]: current ?? '' }; writeAgentModelSelections(next); return next; }); + setConfigurationNotice(t('agents.backup.updated')); + onConfigurationApplied?.(); setOauthConfigurationDraft(null); } catch (requestError) { if (!handleOAuthLoginError(requestError, 'apply')) { setDefaultError(String(requestError)); + setTemplatePreview(null); } } finally { setBusyAction(null); @@ -1539,9 +1547,30 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP await invokeAgentLaunch(launchDirectoryTarget, workingDirectory, deepSeekHarnessOptions); }; - const openDefaultConfirmation = () => { - setDefaultError(''); - setDefaultConfirmOpen(true); + const createManualBackup = async () => { + setBusyAction('backup'); setConfigurationError(''); setConfigurationNotice(''); + try { + await invoke('create_agent_config_backup', { client: selected }); + setConfigurationNotice(t('agents.backup.created')); + } catch (cause) { setConfigurationError(String(cause)); } + finally { setBusyAction(null); } + }; + + const openDefaultConfirmation = async () => { + const mappings = requireClaudeModelMappings(); + if (isClaudeModelMappingClient && !mappings) return; + const model = isClaudeModelMappingClient ? mappings?.sonnet : requireSelectedModel(); + if (!model) return; + setDefaultError(''); setConfigurationError(''); setTemplatePreview(null); setBusyAction('default'); + try { + const preview = await invoke<{ revision: string; files: string[] }>('preview_agent_config_template', { + client: selected, model, oauthConfiguration, + claudeCodeModelMappings: selected === 'claude-code' ? mappings : null, + claudeDesktopModelMappings: selected === 'claude-desktop' ? mappings : null, + }); + setTemplatePreview(preview); setDefaultConfirmOpen(true); + } catch (cause) { setConfigurationError(String(cause)); } + finally { setBusyAction(null); } }; const closeDefaultConfirmation = () => { @@ -1656,6 +1685,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP ) : null} + {selected === 'claude-desktop' && !activeStatus?.claudeDesktopModelMappings ?

{t('agents.backup.mappingRequired')}

: null}
- + + + + + + {t('agents.pi.installDescription')}
@@ -2065,6 +2099,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP ) : (
+ {selected === 'claude-desktop' && !activeStatus?.claudeDesktopModelMappings ?

{t('agents.backup.mappingRequired')}

: null}
{configurationError ? {configurationError} : {configurationNotice || clearNotice || (modelLoading || !activeStatus @@ -2074,7 +2109,8 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP {modificationDescription ? {modificationDescription} : null}
- + +
- {historyOpen ? setHistoryOpen(false)} onRestored={async () => { + {backupsOpen ? setBackupsOpen(false)} onRestored={async () => { if (selected === 'claude-code' || selected === 'claude-desktop') claudeModelMappingsDirtyRef.current[selected] = false; setOauthConfigurationDraft(null); const refreshed = await invoke('refresh_agent_config_statuses'); setStatuses(refreshed); const current = refreshed.find((status) => status.id === selected)?.currentModel; - if (current) setModelByClient((values) => { const next = { ...values, [selected]: current }; writeAgentModelSelections(next); return next; }); - setConfigurationNotice(t('agents.history.restored')); + setModelByClient((values) => { const next = { ...values, [selected]: current ?? '' }; writeAgentModelSelections(next); return next; }); + setConfigurationNotice(t('agents.backup.restored')); }} /> : null} {configurationNotice && (embedded || isPiClient) ?

{configurationNotice}

: null} @@ -2479,6 +2515,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP

{t('agents.default.description', { name: activeDefinition.name })}

+ {templatePreview ?
    {templatePreview.files.map((file) =>
  • {file}
  • )}
: null} {defaultError ? ( {defaultError} @@ -2486,7 +2523,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP ) : null}
- diff --git a/tests/agent-backups-ui.cjs b/tests/agent-backups-ui.cjs new file mode 100644 index 0000000..330c0ad --- /dev/null +++ b/tests/agent-backups-ui.cjs @@ -0,0 +1,73 @@ +// Run Vite on port 1421, then node tests/agent-backups-ui.cjs. +// PLAYWRIGHT_MODULE may point to an existing Playwright installation. +const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright'); +const assert = require('node:assert/strict'); +(async () => { + const browser = await chromium.launch({ channel: 'msedge', headless: true }); + try { + const page = await browser.newPage({ viewport: { width: 1280, height: 900 } }); + page.setDefaultTimeout(10000); + const errors = []; page.on('pageerror', e => errors.push(String(e))); + const calls = cmd => page.evaluate(cmd => window.fixtureCalls.filter(c => c.cmd === cmd), cmd); + const open = async query => { await page.goto('http://localhost:1421/tests/fixtures/agent-backups.html?' + query); await page.getByRole('button', { name: '手动备份', exact: true }).waitFor(); }; + const backup = async () => { await page.getByRole('button', { name: '手动备份', exact: true }).click(); await page.getByText('已手动备份当前磁盘配置,未包含未保存的表单修改。', { exact: true }).waitFor(); }; + const choose = async () => { await page.getByRole('button', { name: '恢复备份', exact: true }).click(); await page.locator('.agent-backup-columns nav button').first().click(); }; + for (const query of ['fresh', 'embedded&fresh', 'client=pi']) { + await open(query); + if (!query.includes('pi')) { + await page.getByRole('button', { name: '一键接入', exact: true }).click(); + await page.getByText('配置已更新。', { exact: true }).waitFor(); + await page.getByRole('button', { name: '更新配置', exact: true }).click(); + await page.getByText('配置已是最新,无需写入。', { exact: true }).waitFor(); + } + assert.equal((await calls('create_agent_config_backup')).length, 0); + await backup(); + assert.deepEqual((await calls('create_agent_config_backup'))[0].args, { client: query.includes('pi') ? 'pi' : 'codex' }); + await page.getByRole('button', { name: '基础配置模板', exact: true }).click(); + await page.getByRole('button', { name: '确认覆盖', exact: true }).waitFor(); + assert.equal(await page.locator('.agent-template-files li').count(), 3); + assert.equal((await calls('apply_agent_config_template')).length, 0); + await page.getByRole('button', { name: '确认覆盖', exact: true }).click(); + await page.getByRole('button', { name: '确认覆盖', exact: true }).waitFor({ state: 'detached' }); + assert.equal((await calls('apply_agent_config_template'))[0].args.revision, 'template1'); + assert.equal((await calls('create_agent_config_backup')).length, 1); + await choose(); + await page.getByText('备份时不存在;恢复时删除', { exact: true }).waitFor(); + assert.equal(await page.locator('.agent-backup-files li').count(), 3); + const updateCount = (await calls('update_agent_config')).length; + await page.getByRole('button', { name: '恢复此版本', exact: true }).click(); + assert.equal((await calls('restore_agent_config_backup')).length, 0); + await page.getByRole('button', { name: '确认恢复', exact: true }).click(); + await page.locator('.agent-backup-modal').waitFor({ state: 'detached' }); + assert.equal((await calls('restore_agent_config_backup'))[0].args.revision, 'rev1'); + assert.equal((await calls('update_agent_config')).length, updateCount); + await choose(); + await page.getByRole('button', { name: '删除此版本', exact: true }).click(); + assert.equal((await calls('delete_agent_config_backup')).length, 0); + await page.getByRole('button', { name: '确认删除', exact: true }).click(); + await page.getByText('暂无手动备份。点击“手动备份”保存当前磁盘配置。', { exact: true }).waitFor(); + assert.equal((await calls('restore_agent_config_backup')).length, 1); + assert.equal((await calls('create_agent_config_backup')).length, 1); + } + await open('state=invalid'); + assert.ok(await page.getByRole('button', { name: '更新配置', exact: true }).isDisabled()); + assert.ok(await page.getByRole('button', { name: '基础配置模板', exact: true }).isEnabled()); + await backup(); await choose(); + assert.ok(await page.getByRole('button', { name: '恢复此版本', exact: true }).isDisabled()); + assert.ok(await page.getByRole('button', { name: '删除此版本', exact: true }).isEnabled()); + await open('conflict'); await backup(); await choose(); + await page.getByRole('button', { name: '恢复此版本', exact: true }).click(); + await page.getByRole('button', { name: '确认恢复', exact: true }).click(); + await page.getByText(/预览后配置或备份发生变化,请重新预览/).waitFor(); + assert.ok(await page.getByRole('button', { name: '恢复此版本', exact: true }).isDisabled()); + await page.setViewportSize({ width: 540, height: 820 }); + assert.ok(await page.locator('.agent-backup-modal').isVisible()); + assert.ok(await page.locator('.agent-backup-modal').evaluate(el => el.scrollWidth <= el.clientWidth)); + await page.screenshot({ path: 'misc/agent-backups-narrow.png', fullPage: true }); + await open('client=claude-desktop'); + await page.getByText('没有可靠的 Claude Desktop 模型映射,请重新选择模型。', { exact: true }).waitFor(); + assert.ok(await page.getByRole('button', { name: '更新配置', exact: true }).isDisabled()); + assert.deepEqual(errors, []); + console.log('PASS: full/compact/Pi backup, disk-only payload, template preview, restore/delete confirmations, corrupt backups, conflict invalidation, narrow layout, missing Desktop mapping'); + } finally { await browser.close(); } +})().catch(e => { console.error(e); process.exit(1); }); diff --git a/tests/agent-history-ui.cjs b/tests/agent-history-ui.cjs deleted file mode 100644 index 500492b..0000000 --- a/tests/agent-history-ui.cjs +++ /dev/null @@ -1,75 +0,0 @@ -// Run Vite on port 1421, then: node tests/agent-history-ui.cjs -// Set PLAYWRIGHT_MODULE to an installed Playwright module path when needed. -const {chromium}=require(process.env.PLAYWRIGHT_MODULE || 'playwright'); -const assert=require('node:assert/strict'); -(async()=>{const browser=await chromium.launch({channel:'msedge',headless:true}); const page=await browser.newPage({viewport:{width:1280,height:900}}); -page.setDefaultTimeout(10000); const errors=[];page.on('pageerror',e=>errors.push(String(e))); -await page.goto('http://localhost:1421/tests/fixtures/agent-history.html?fresh'); -await page.getByRole('button',{name:'一键接入',exact:true}).waitFor(); -await page.waitForFunction(()=>!document.querySelector('.agent-save-actions .primary-button')?.disabled); -await page.locator('.agent-settings-title strong').filter({hasText:'Codex'}).waitFor(); -await page.getByRole('button',{name:'一键接入',exact:true}).click(); -await page.getByText('配置已更新,历史备份已保存。',{exact:true}).waitFor(); -await page.getByRole('button',{name:'更新配置',exact:true}).click(); -await page.getByText('配置已是最新,无需更新。',{exact:true}).waitFor(); -assert.equal(await page.getByRole('button',{name:'关闭修改',exact:true}).count(),0); -assert.equal(await page.locator('textarea').count(),0); -await page.locator('.agent-save-feedback').waitFor({state:'attached'}); -assert.equal(await page.getByText('已保存',{exact:true}).count(),0); -const help = page.getByRole('button',{name:'如何选择',exact:true}); -await help.click(); -assert.ok(await page.locator('#agent-signin-hint').isVisible()); -assert.equal(await page.locator('#agent-signin-hint dt').count(),2); -const positions = await page.locator('#agent-signin-hint').evaluate(el => ({top:el.getBoundingClientRect().top,bottom:document.getElementById('agent-connection-method').getBoundingClientRect().bottom})); -assert.ok(positions.top >= positions.bottom); -await page.getByRole('button',{name:'收起说明',exact:true}).click(); -assert.ok(await page.locator('#agent-signin-hint').isHidden()); -await page.getByLabel('接入方式',{exact:true}).selectOption('oauth'); -await page.getByText('未保存',{exact:true}).waitFor(); -await page.getByLabel('接入方式',{exact:true}).selectOption('apikey'); -await page.locator('.agent-save-feedback').waitFor({state:'attached'}); -assert.equal(await page.getByText('已保存',{exact:true}).count(),0); -assert.equal(await page.getByRole('button',{name:'重建配置',exact:true}).isVisible(),true); -await page.getByRole('button',{name:'重建配置',exact:true}).click(); -await page.getByRole('button',{name:'确认重建配置',exact:true}).waitFor(); -await page.getByRole('button',{name:'取消',exact:true}).click(); -assert.ok(await page.getByRole('button',{name:'清空配置',exact:true}).isVisible()); - -await page.screenshot({path:'misc/agent-history-main.png',fullPage:true}); -await page.getByRole('button',{name:'配置历史',exact:true}).click(); -await page.locator('.agent-history-columns nav button').first().click(); -await page.locator('.agent-history-changes table').waitFor(); -await page.screenshot({path:'misc/agent-history-dialog.png',fullPage:true}); -const before=await page.evaluate(()=>window.fixtureCalls.filter(c=>c.cmd==='update_agent_config').length); -await page.locator('.agent-history-modal footer .primary-button').click(); -await page.waitForFunction(()=>!document.querySelector('.agent-history-modal')); -const after=await page.evaluate(()=>window.fixtureCalls.filter(c=>c.cmd==='update_agent_config').length); -assert.equal(before,after); -assert.ok(await page.getByRole('button',{name:'更新配置',exact:true}).isEnabled()); -await page.goto('http://localhost:1421/tests/fixtures/agent-history.html?client=claude-code'); -await page.getByText('Claude Opus 5',{exact:true}).first().waitFor(); -assert.equal(await page.locator('textarea').count(),0); -await page.screenshot({path:'misc/agent-history-claude.png',fullPage:true}); -await page.goto('http://localhost:1421/tests/fixtures/agent-history.html?embedded&fresh'); -await page.getByRole('button',{name:'一键接入',exact:true}).waitFor(); -await page.getByRole('button',{name:'一键接入',exact:true}).click(); -await page.getByText('配置已更新,历史备份已保存。',{exact:true}).waitFor(); -assert.ok(await page.getByRole('button',{name:'更新配置',exact:true}).isEnabled()); -await page.screenshot({path:'misc/agent-history-onboarding.png',fullPage:true}); -await page.setViewportSize({width:540,height:820}); -await page.getByRole('button',{name:'配置历史',exact:true}).click(); -await page.locator('.agent-history-columns nav button').first().click(); -await page.locator('.agent-history-changes table').waitFor(); -await page.screenshot({path:'misc/agent-history-narrow.png',fullPage:true}); -await page.goto('http://localhost:1421/tests/fixtures/agent-history.html?state=needs-update'); -await page.getByRole('button',{name:'修复接入',exact:true}).waitFor(); -assert.equal(await page.getByRole('button',{name:'一键接入',exact:true}).count(),0); -await page.getByText('当前接入:CPA 配置需要修复',{exact:true}).waitFor(); -await page.goto('http://localhost:1421/tests/fixtures/agent-history.html?state=invalid'); -await page.getByRole('button',{name:'更新配置',exact:true}).waitFor(); -assert.ok(await page.getByRole('button',{name:'更新配置',exact:true}).isDisabled()); -assert.equal(await page.getByRole('button',{name:'一键接入',exact:true}).count(),0); -assert.ok(await page.getByRole('button',{name:'重建配置',exact:true}).isEnabled()); -assert.deepEqual(errors,[]); -console.log('PASS: first/repeated/no-op update, fixed button, original Claude form, history preview/restore, read-only refresh, onboarding, narrow layout'); -await browser.close();})().catch(e=>{console.error(e);process.exit(1)}); diff --git a/tests/fixtures/agent-history.html b/tests/fixtures/agent-backups.html similarity index 54% rename from tests/fixtures/agent-history.html rename to tests/fixtures/agent-backups.html index 7d1906e..9ba0cb6 100644 --- a/tests/fixtures/agent-history.html +++ b/tests/fixtures/agent-backups.html @@ -1 +1 @@ -
\ No newline at end of file +
\ No newline at end of file diff --git a/tests/fixtures/agent-history.tsx b/tests/fixtures/agent-backups.tsx similarity index 57% rename from tests/fixtures/agent-history.tsx rename to tests/fixtures/agent-backups.tsx index 978499a..36a6f8e 100644 --- a/tests/fixtures/agent-history.tsx +++ b/tests/fixtures/agent-backups.tsx @@ -8,7 +8,7 @@ const params = new URLSearchParams(location.search); localStorage.setItem('easy-cli-proxy-api.locale','zh-CN'); localStorage.setItem('cpa-gui.agent-selected-client.v1', params.get('client') || 'codex'); const ids = ['claude-code','claude-desktop','codex','opencode','openclaw','hermes','deepseek-harness','zcode','kimi-code','grok-build','pi']; -let count=0; let currentModel=params.has('fresh')?null:'gpt-one'; +let count=0; let backupCount=0; const backups:any[]=[]; let currentModel=params.has('fresh')?null:'gpt-one'; const calls:any[]=[];(window as any).fixtureCalls=calls; mockIPC(async (cmd,args:any) => { calls.push({cmd,args}); @@ -17,10 +17,19 @@ mockIPC(async (cmd,args:any) => { if(cmd==='get_agent_config_statuses'||cmd==='refresh_agent_config_statuses') return ids.map(id=>({id,name:id,supportedPlatform:true,installed:true,pluginInstalled:true,launchTargets:[{id:'cli',label:'CLI',detail:'test'}],version:'1.0',cliVersion:'1.0',appVersion:null,pluginVersion:'1.0',configValid:params.get('state')!=='invalid',connectionState:params.get('state') || (currentModel?'configured':'not-configured'),configured:!!currentModel,configurationSynchronized:!!currentModel,currentModel,oauthConfiguration:false,modificationEnabled:!!currentModel,modificationState:currentModel?'applied':'unconfigured',backupAvailable:false,appliedModel:currentModel,claudeCodeModelMappings:null,claudeDesktopModelMappings:null,warnings:[],error:null})); if(cmd==='get_agent_models') return [{name:'gpt-one'},{name:'gpt-two'}]; if(cmd==='get_deepseek_harness_process_status') return {running:false,pid:null,mode:null}; - if(cmd==='update_agent_config') {count++;currentModel=args.model;return {outcome:count>1?'unchanged':'updated',historyVersion:'2',model:currentModel,enabled:true,changedFiles:[],conflictFiles:[]};} - if(cmd==='list_agent_config_history') return {versions:[{id:'1',createdAt:'2026-09-09T12:00:00Z',source:'before-update',model:'gpt-two',fileCount:3}],warnings:[]}; - if(cmd==='preview_agent_config_history') return {revision:'rev1',differences:[{file:'C:/test/.codex/config.toml',field:'model',before:'gpt-one',after:'gpt-two'},{file:'C:/test/.codex/auth.json',field:'OPENAI_API_KEY',before:'••••••',after:'••••••'}]}; - if(cmd==='restore_agent_config_history') {currentModel='gpt-two';return {outcome:'updated'};} + if(cmd==='update_agent_config') {count++;currentModel=args.model;return {outcome:count>1?'unchanged':'updated',model:currentModel,enabled:true,changedFiles:[],conflictFiles:[]};} + if(cmd==='create_agent_config_backup') { + const id=String(++backupCount); + const files=[{path:'C:/test/.codex/config.toml',exists:true,size:120},{path:'C:/test/.codex/models.json',exists:true,size:60},{path:'C:/test/.codex/auth.json',exists:false,size:null}]; + const backup={id,createdAt:'2026-09-10T12:00:00Z',fileCount:3,location:'C:/CPA/backups/agents/'+args.client+'/'+id+'.json',files,restorable:params.get('state')!=='invalid',error:params.get('state')==='invalid'?'备份含有无法解析的配置':null,savedModel:currentModel}; + backups.unshift(backup);return backup; + } + if(cmd==='list_agent_config_backups') return {versions:backups}; + if(cmd==='preview_agent_config_backup') return {revision:'rev1',files:backups.find(b=>b.id===args.id).files,differences:[{file:'C:/test/.codex/config.toml',field:'file',before:'present',after:'replace'}]}; + if(cmd==='restore_agent_config_backup') {if(params.has('conflict'))throw new Error('预览后配置或备份发生变化,请重新预览');currentModel=backups.find(b=>b.id===args.id).savedModel;return {outcome:'updated'};} + if(cmd==='delete_agent_config_backup') {backups.splice(backups.findIndex(b=>b.id===args.id),1);return null;} + if(cmd==='preview_agent_config_template') return {revision:'template1',files:['C:/test/.codex/config.toml','C:/test/.codex/auth.json','C:/test/.codex/models.json']}; + if(cmd==='apply_agent_config_template') {currentModel=args.model;return {outcome:'updated'};} if(cmd==='check_pi_provider_update') return {installedVersion:'1.0',latestVersion:'1.0',updateAvailable:false}; if(cmd==='get_codex_model_catalog_editor') return {models:[],hiddenModels:[],customizations:{}}; if(cmd==='check_codex_oauth_login')return null;