1285 lines
48 KiB
Rust
1285 lines
48 KiB
Rust
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<AgentModelOption> {
|
|
["gpt-one", "gpt-two"]
|
|
.into_iter()
|
|
.map(|name| AgentModelOption {
|
|
input_modalities: None,
|
|
harness_metadata: None,
|
|
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<AgentConfigActionResult, String> {
|
|
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<AgentConfigActionResult, String> {
|
|
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<AgentClient> {
|
|
[
|
|
"claude-code",
|
|
"claude-desktop",
|
|
"codex",
|
|
"opencode",
|
|
"openclaw",
|
|
"hermes",
|
|
"deepseek-harness",
|
|
"zcode",
|
|
"workbuddy",
|
|
"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();
|
|
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 workbuddy_native_array_backup_restores_exact_bytes_after_template() {
|
|
let home = Home::new();
|
|
let client = AgentClient::WorkBuddy;
|
|
let paths = config_paths(client.id(), &home.0).unwrap();
|
|
let original = b"[\n {\"id\":\"user-model\",\"apiKey\":\"private-key\"}\n]\n";
|
|
save(&paths[0], original);
|
|
let backup = create_backup(client.id(), &home.0).unwrap();
|
|
assert!(backup.restorable);
|
|
apply(&home.0, client, "gpt-one").unwrap();
|
|
template(&home.0, client).unwrap();
|
|
test_restore_backup(client, &home.0, &backup.id);
|
|
assert_eq!(fs::read(&paths[0]).unwrap(), original);
|
|
}
|
|
|
|
#[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 clear_integration_needs_no_backup_and_removes_state_for_each_client() {
|
|
for client in [
|
|
AgentClient::ClaudeCode,
|
|
AgentClient::ClaudeDesktop,
|
|
AgentClient::OpenCode,
|
|
AgentClient::OpenClaw,
|
|
AgentClient::Hermes,
|
|
AgentClient::DeepSeekHarness,
|
|
AgentClient::ZCode,
|
|
AgentClient::WorkBuddy,
|
|
AgentClient::KimiCode,
|
|
AgentClient::GrokBuild,
|
|
]
|
|
.into_iter()
|
|
.filter(|client| client.supported_platform())
|
|
{
|
|
let home = Home::new();
|
|
let paths = config_paths(client.id(), &home.0).unwrap();
|
|
apply(&home.0, client, "gpt-one").unwrap();
|
|
let mut expected = Vec::new();
|
|
for (path, bytes) in config_images(&paths).unwrap() {
|
|
let mut value = parse(&path, text(bytes.as_deref()).unwrap()).unwrap();
|
|
value["user_setting"] = serde_json::json!({"keep": true});
|
|
save(&path, render(&path, &value).unwrap());
|
|
expected.push((path, serde_json::json!({"user_setting": {"keep": true}})));
|
|
}
|
|
let state = agent_state_path(&paths).unwrap();
|
|
save(&state, "old state without a usable backup");
|
|
let extra_state = match client {
|
|
AgentClient::ClaudeDesktop => Some(desktop_mapping_path(&paths).unwrap()),
|
|
AgentClient::DeepSeekHarness => {
|
|
Some(deepseek_harness_catalog_state_path(&paths).unwrap())
|
|
}
|
|
_ => None,
|
|
};
|
|
if let Some(path) = &extra_state {
|
|
save(path, "stale state");
|
|
}
|
|
let backup = paths[0].with_extension("manual.bak");
|
|
save(&backup, "keep manual backup");
|
|
let result = clear_agent_managed_configuration(client, &home.0, 8317).unwrap();
|
|
assert!(!result.enabled, "{}", client.id());
|
|
assert!(!state.exists());
|
|
assert!(extra_state.is_none_or(|path| !path.exists()));
|
|
assert_eq!(fs::read_to_string(backup).unwrap(), "keep manual backup");
|
|
assert!(!agent_has_managed_marker(client, &paths).unwrap());
|
|
for (path, expected) in expected {
|
|
let value = parse(
|
|
&path,
|
|
text(read_agent_bytes(&path).unwrap().as_deref()).unwrap(),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
value["user_setting"],
|
|
expected["user_setting"],
|
|
"{}",
|
|
client.id()
|
|
);
|
|
}
|
|
let result = clear_agent_managed_configuration(client, &home.0, 8317).unwrap();
|
|
assert!(!result.enabled);
|
|
assert!(result.changed_files.is_empty(), "{}", client.id());
|
|
assert!(list_backups(client.id(), &home.0)
|
|
.unwrap()
|
|
.versions
|
|
.is_empty());
|
|
apply(&home.0, client, "gpt-two").unwrap();
|
|
assert!(agent_has_managed_marker(client, &paths).unwrap());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn clear_integration_removes_claude_model_overrides_but_keeps_custom_settings() {
|
|
let home = Home::new();
|
|
let paths = config_paths("claude-code", &home.0).unwrap();
|
|
let original = serde_json::json!({
|
|
"permissions": {"allow": ["Read"]},
|
|
"env": {"KEEP": "yes", "ANTHROPIC_CUSTOM_MODEL_OPTION_2": "user-model"}
|
|
});
|
|
save(&paths[0], original.to_string());
|
|
apply(&home.0, AgentClient::ClaudeCode, "gpt-one").unwrap();
|
|
clear_agent_managed_configuration(AgentClient::ClaudeCode, &home.0, 8317).unwrap();
|
|
let result = parse(&paths[0], Some(&fs::read_to_string(&paths[0]).unwrap())).unwrap();
|
|
assert_eq!(result, original);
|
|
|
|
apply(&home.0, AgentClient::ClaudeCode, "gpt-one").unwrap();
|
|
let mut edited = parse(&paths[0], Some(&fs::read_to_string(&paths[0]).unwrap())).unwrap();
|
|
edited["model"] = serde_json::json!("user-model");
|
|
save(&paths[0], edited.to_string());
|
|
clear_agent_managed_configuration(AgentClient::ClaudeCode, &home.0, 8317).unwrap();
|
|
assert_eq!(
|
|
parse(&paths[0], Some(&fs::read_to_string(&paths[0]).unwrap())).unwrap()["model"],
|
|
"user-model"
|
|
);
|
|
|
|
let external = r#"{"env":{"ANTHROPIC_BASE_URL":"https://api.example.com","ANTHROPIC_AUTH_TOKEN":"other"},"model":"external"}"#;
|
|
save(&paths[0], external);
|
|
clear_agent_managed_configuration(AgentClient::ClaudeCode, &home.0, 8317).unwrap();
|
|
assert_eq!(fs::read_to_string(&paths[0]).unwrap(), external);
|
|
}
|
|
|
|
#[test]
|
|
fn claude_code_subagent_model_survives_update_close_and_reconnect() {
|
|
let home = Home::new();
|
|
let paths = config_paths("claude-code", &home.0).unwrap();
|
|
apply(&home.0, AgentClient::ClaudeCode, "gpt-one").unwrap();
|
|
|
|
let mut connected = parse(
|
|
&paths[0],
|
|
Some(&fs::read_to_string(&paths[0]).unwrap()),
|
|
)
|
|
.unwrap();
|
|
connected["env"]["CLAUDE_CODE_SUBAGENT_MODEL"] =
|
|
serde_json::json!("user-subagent-model");
|
|
save(&paths[0], render(&paths[0], &connected).unwrap());
|
|
|
|
apply(&home.0, AgentClient::ClaudeCode, "gpt-two").unwrap();
|
|
let updated = parse(
|
|
&paths[0],
|
|
Some(&fs::read_to_string(&paths[0]).unwrap()),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
updated["env"]["CLAUDE_CODE_SUBAGENT_MODEL"],
|
|
"user-subagent-model"
|
|
);
|
|
|
|
clear_agent_managed_configuration(AgentClient::ClaudeCode, &home.0, 8317).unwrap();
|
|
let closed = parse(
|
|
&paths[0],
|
|
Some(&fs::read_to_string(&paths[0]).unwrap()),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
closed["env"]["CLAUDE_CODE_SUBAGENT_MODEL"],
|
|
"user-subagent-model"
|
|
);
|
|
assert!(closed.get("model").is_none());
|
|
assert!(closed["env"].get("ANTHROPIC_BASE_URL").is_none());
|
|
|
|
apply(&home.0, AgentClient::ClaudeCode, "gpt-one").unwrap();
|
|
let reconnected = parse(
|
|
&paths[0],
|
|
Some(&fs::read_to_string(&paths[0]).unwrap()),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
reconnected["env"]["CLAUDE_CODE_SUBAGENT_MODEL"],
|
|
"user-subagent-model"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn claude_desktop_cowork_hosts_survive_update_close_and_reconnect() {
|
|
if !AgentClient::ClaudeDesktop.supported_platform() {
|
|
return;
|
|
}
|
|
let home = Home::new();
|
|
let paths = config_paths("claude-desktop", &home.0).unwrap();
|
|
apply(&home.0, AgentClient::ClaudeDesktop, "gpt-one").unwrap();
|
|
|
|
let mut connected: Value = serde_json::from_slice(&fs::read(&paths[2]).unwrap()).unwrap();
|
|
connected["coworkEgressAllowedHosts"] = serde_json::json!(["*"]);
|
|
save(&paths[2], connected.to_string());
|
|
|
|
apply(&home.0, AgentClient::ClaudeDesktop, "gpt-two").unwrap();
|
|
let updated: Value = serde_json::from_slice(&fs::read(&paths[2]).unwrap()).unwrap();
|
|
assert_eq!(updated["coworkEgressAllowedHosts"], serde_json::json!(["*"]));
|
|
|
|
clear_agent_managed_configuration(AgentClient::ClaudeDesktop, &home.0, 8317).unwrap();
|
|
let closed: Value = serde_json::from_slice(&fs::read(&paths[2]).unwrap()).unwrap();
|
|
assert_eq!(closed["coworkEgressAllowedHosts"], serde_json::json!(["*"]));
|
|
assert!(closed.get("inferenceGatewayBaseUrl").is_none());
|
|
|
|
apply(&home.0, AgentClient::ClaudeDesktop, "gpt-one").unwrap();
|
|
let reconnected: Value = serde_json::from_slice(&fs::read(&paths[2]).unwrap()).unwrap();
|
|
assert_eq!(
|
|
reconnected["coworkEgressAllowedHosts"],
|
|
serde_json::json!(["*"])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn clear_integration_preserves_claude_settings_for_other_endpoints() {
|
|
let home = Home::new();
|
|
let paths = config_paths("claude-code", &home.0).unwrap();
|
|
for base_url in [
|
|
"http://localhost:11434",
|
|
"http://127.0.0.1:11434",
|
|
"http://127.0.0.1:83170",
|
|
"http://127.0.0.1:8317/other-proxy",
|
|
"http://127.0.0.1:8317?route=other",
|
|
"http://127.0.0.1:8317#other",
|
|
"http://other:secret@127.0.0.1:8317",
|
|
"http://127.0.0.1.example.com:8317",
|
|
"https://127.0.0.1:8317",
|
|
] {
|
|
let original = serde_json::json!({
|
|
"model": "local-model", "permissions": {"allow": ["Read"]},
|
|
"env": {"ANTHROPIC_BASE_URL": base_url, "ANTHROPIC_AUTH_TOKEN": "other-key", "ANTHROPIC_MODEL": "local-model"}
|
|
}).to_string();
|
|
save(&paths[0], &original);
|
|
let result =
|
|
clear_agent_managed_configuration(AgentClient::ClaudeCode, &home.0, 8317).unwrap();
|
|
assert!(result.changed_files.is_empty(), "{base_url}");
|
|
assert_eq!(
|
|
fs::read_to_string(&paths[0]).unwrap(),
|
|
original,
|
|
"{base_url}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn clear_integration_uses_the_configured_claude_endpoint_without_a_backup() {
|
|
let home = Home::new();
|
|
let paths = config_paths("claude-code", &home.0).unwrap();
|
|
for port in [8317, 9527] {
|
|
// URL parsing accepts the trailing slash without treating another port as CPA.
|
|
let base_url = format!("http://127.0.0.1:{port}/");
|
|
save(
|
|
&paths[0],
|
|
build_claude_agent_config(
|
|
Some(r#"{"env":{"KEEP":"yes"}}"#),
|
|
&base_url,
|
|
"test-secret",
|
|
"gpt-one",
|
|
&models(),
|
|
None,
|
|
)
|
|
.unwrap(),
|
|
);
|
|
clear_agent_managed_configuration(AgentClient::ClaudeCode, &home.0, port).unwrap();
|
|
assert_eq!(
|
|
parse(&paths[0], Some(&fs::read_to_string(&paths[0]).unwrap())).unwrap(),
|
|
serde_json::json!({"env":{"KEEP":"yes"}})
|
|
);
|
|
}
|
|
for base_url in ["https://[::1]:9527", "https://192.168.1.10:9527"] {
|
|
save(
|
|
&paths[0],
|
|
build_claude_agent_config(None, base_url, "test-secret", "gpt-one", &models(), None)
|
|
.unwrap(),
|
|
);
|
|
let plan = prepare_claude_code_managed_removal(&paths, base_url).unwrap();
|
|
assert_eq!(plan, vec![(paths[0].clone(), None)], "{base_url}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn clear_integration_handles_inline_and_mixed_toml_tables() {
|
|
let examples = [
|
|
(
|
|
AgentClient::GrokBuild,
|
|
r#"# custom settings
|
|
models = { default = "cpa-gui/gpt-one", keep = true }
|
|
model = { "cpa-gui/gpt-one" = { model = "gpt-one", base_url = "http://127.0.0.1:8317/v1", api_key = "test-secret" }, other = { model = "other", api_key = "keep-secret" } }
|
|
"#,
|
|
serde_json::json!({"models":{"keep":true},"model":{"other":{"model":"other","api_key":"keep-secret"}}}),
|
|
),
|
|
(
|
|
AgentClient::GrokBuild,
|
|
r#"# custom settings
|
|
model = { "cpa-gui/gpt-one" = { model = "gpt-one" }, other = { model = "other" } }
|
|
[models]
|
|
default = "other"
|
|
"#,
|
|
serde_json::json!({"models":{"default":"other"},"model":{"other":{"model":"other"}}}),
|
|
),
|
|
(
|
|
AgentClient::KimiCode,
|
|
r#"default_model = "cpa-gui/gpt-one"
|
|
# custom settings
|
|
providers = { "cpa-gui" = { type = "openai", base_url = "http://127.0.0.1:8317/v1", api_key = "test-secret" }, other = { api_key = "keep-secret" } }
|
|
models = { "cpa-gui/gpt-one" = { provider = "cpa-gui", model = "gpt-one" }, other = { provider = "other", model = "other" } }
|
|
"#,
|
|
serde_json::json!({"providers":{"other":{"api_key":"keep-secret"}},"models":{"other":{"provider":"other","model":"other"}}}),
|
|
),
|
|
(
|
|
AgentClient::KimiCode,
|
|
r#"# custom settings
|
|
default_model = "other"
|
|
providers = { "cpa-gui" = { api_key = "test-secret" }, other = { api_key = "keep-secret" } }
|
|
[models."cpa-gui/gpt-one"]
|
|
provider = "cpa-gui"
|
|
model = "gpt-one"
|
|
[models.other]
|
|
provider = "other"
|
|
model = "other"
|
|
"#,
|
|
serde_json::json!({"default_model":"other","providers":{"other":{"api_key":"keep-secret"}},"models":{"other":{"provider":"other","model":"other"}}}),
|
|
),
|
|
];
|
|
for (client, content, expected) in examples {
|
|
let home = Home::new();
|
|
let paths = config_paths(client.id(), &home.0).unwrap();
|
|
save(&paths[0], content);
|
|
let result = clear_agent_managed_configuration(client, &home.0, 8317).unwrap();
|
|
assert!(!result.enabled);
|
|
assert_eq!(result.changed_files, vec![path_to_string(&paths[0])]);
|
|
let rendered = fs::read_to_string(&paths[0]).unwrap();
|
|
assert_eq!(parse(&paths[0], Some(&rendered)).unwrap(), expected);
|
|
assert!(rendered.contains("# custom settings"));
|
|
assert!(!agent_has_connection_evidence(client, &paths).unwrap());
|
|
assert!(clear_agent_managed_configuration(client, &home.0, 8317)
|
|
.unwrap()
|
|
.changed_files
|
|
.is_empty());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn clear_integration_removes_empty_inline_toml_tables() {
|
|
for (client, content) in [
|
|
(
|
|
AgentClient::GrokBuild,
|
|
r#"models = { default = "cpa-gui/gpt-one" }
|
|
model = { "cpa-gui/gpt-one" = { model = "gpt-one", api_key = "test-secret" } }
|
|
"#,
|
|
),
|
|
(
|
|
AgentClient::KimiCode,
|
|
r#"default_model = "cpa-gui/gpt-one"
|
|
providers = { "cpa-gui" = { api_key = "test-secret" } }
|
|
models = { "cpa-gui/gpt-one" = { provider = "cpa-gui", model = "gpt-one" } }
|
|
"#,
|
|
),
|
|
] {
|
|
let home = Home::new();
|
|
let paths = config_paths(client.id(), &home.0).unwrap();
|
|
save(&paths[0], content);
|
|
clear_agent_managed_configuration(client, &home.0, 8317).unwrap();
|
|
assert!(!paths[0].exists());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn clear_integration_rejects_invalid_files_before_writing() {
|
|
let home = Home::new();
|
|
let paths = config_paths("deepseek-harness", &home.0).unwrap();
|
|
apply(&home.0, AgentClient::DeepSeekHarness, "gpt-one").unwrap();
|
|
save(&paths[1], "refs: [invalid");
|
|
let before = config_images(&paths).unwrap();
|
|
assert!(clear_agent_managed_configuration(AgentClient::DeepSeekHarness, &home.0, 8317).is_err());
|
|
assert_eq!(config_images(&paths).unwrap(), before);
|
|
}
|
|
|
|
#[test]
|
|
fn clear_integration_rolls_back_config_and_state_together() {
|
|
let home = Home::new();
|
|
let paths = config_paths("claude-code", &home.0).unwrap();
|
|
apply(&home.0, AgentClient::ClaudeCode, "gpt-one").unwrap();
|
|
let state = agent_state_path(&paths).unwrap();
|
|
save(&state, "legacy state");
|
|
let before = config_images(&paths).unwrap();
|
|
let after = paths.iter().map(|p| (p.clone(), None)).collect();
|
|
let mut first = true;
|
|
let error = commit_config_with_writer(
|
|
"claude-code",
|
|
&paths,
|
|
&before,
|
|
&after,
|
|
"clear-integration",
|
|
None,
|
|
None,
|
|
&mut |client, images| {
|
|
write_config_images(client, images)?;
|
|
if first {
|
|
first = false;
|
|
Err("injected failure".into())
|
|
} else {
|
|
Ok(())
|
|
}
|
|
},
|
|
)
|
|
.unwrap_err();
|
|
assert!(error.contains("已回滚"));
|
|
assert_eq!(config_images(&paths).unwrap(), before);
|
|
assert_eq!(fs::read_to_string(state).unwrap(), "legacy state");
|
|
}
|
|
|
|
#[test]
|
|
fn partial_writes_and_failed_verification_roll_back_without_persistent_snapshots() {
|
|
let home = Home::new();
|
|
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 pi_config_file_allows_cpa_connection_without_detected_cli() {
|
|
let home = Home::new();
|
|
let paths = config_paths("pi", &home.0).unwrap();
|
|
save(&paths[0], "{}\n");
|
|
|
|
let before = inspect_pi_provider_status(&home.0, 8317, "test-key");
|
|
assert!(before.config_exists);
|
|
assert!(!before.plugin_installed);
|
|
|
|
configure_pi_provider_without_cli_inner(&home.0, 8317, "test-key", "gpt-one").unwrap();
|
|
|
|
let connected = inspect_pi_provider_status(&home.0, 8317, "test-key");
|
|
assert!(connected.configured);
|
|
assert!(connected.plugin_installed);
|
|
assert_eq!(connected.current_model.as_deref(), Some("gpt-one"));
|
|
}
|
|
|
|
#[test]
|
|
fn desktop_setup_repairs_legacy_names_without_changing_other_profiles() {
|
|
if !AgentClient::ClaudeDesktop.supported_platform() {
|
|
return;
|
|
}
|
|
let home = Home::new();
|
|
let paths = config_paths("claude-desktop", &home.0).unwrap();
|
|
let legacy_id = "fbd7eef8-4705-47c7-a5a7-a44b2952afe1";
|
|
let named_id = "d186251a-0529-4dc7-bab8-81f391d975ce";
|
|
let initial = serde_json::json!({
|
|
"appliedId": legacy_id,
|
|
"custom": {"keep": true},
|
|
"entries": [
|
|
{"id": legacy_id, "custom": [1, 2]},
|
|
{"id": named_id, "name": "My gateway", "custom": true}
|
|
]
|
|
});
|
|
save(&paths[3], initial.to_string());
|
|
let other_profile = paths[3].parent().unwrap().join(format!("{legacy_id}.json"));
|
|
let other_content = "{\"inferenceGatewayBaseUrl\":\"https://example.test\"}\n";
|
|
save(&other_profile, other_content);
|
|
|
|
apply(&home.0, AgentClient::ClaudeDesktop, "gpt-one").unwrap();
|
|
let repaired: Value = serde_json::from_slice(&fs::read(&paths[3]).unwrap()).unwrap();
|
|
assert!(repaired["entries"].as_array().unwrap().iter().all(|entry| {
|
|
entry["id"].is_string() && entry["name"].is_string()
|
|
}));
|
|
assert_eq!(repaired["entries"][0]["name"], format!("Configuration {legacy_id}"));
|
|
assert_eq!(repaired["entries"][0]["custom"], initial["entries"][0]["custom"]);
|
|
assert_eq!(repaired["entries"][1], initial["entries"][1]);
|
|
assert_eq!(repaired["custom"], initial["custom"]);
|
|
assert_eq!(fs::read_to_string(other_profile).unwrap(), other_content);
|
|
|
|
let before = config_images(&paths).unwrap();
|
|
apply(&home.0, AgentClient::ClaudeDesktop, "gpt-one").unwrap();
|
|
assert_eq!(before, config_images(&paths).unwrap());
|
|
for index in [0, 1] {
|
|
let mut changed = repaired.clone();
|
|
changed["entries"][index]["name"] = serde_json::json!("Unexpected rename");
|
|
let updates = vec![AgentFileUpdate { path: paths[3].clone(), after: changed.to_string() }];
|
|
assert!(prepare_config_updates("claude-desktop", &paths, &before, &updates, false).is_err());
|
|
}
|
|
}
|
|
|
|
#[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_ne!(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});
|
|
value["inferenceModels"][0]["contextWindow"] = serde_json::json!(1_000_000);
|
|
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("contextWindow").is_none());
|
|
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());
|
|
}
|
|
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());
|
|
}
|
|
}
|