diff --git a/desktop/src-tauri/src/migration.rs b/desktop/src-tauri/src/migration.rs index 06672e4a94..86dd2ebe58 100644 --- a/desktop/src-tauri/src/migration.rs +++ b/desktop/src-tauri/src/migration.rs @@ -173,7 +173,6 @@ fn run_boot_migrations_inner(app: &tauri::AppHandle, reset_completed: bool) { // Post-fold runtime reads fall back to unified-store definitions. fold_personas_into_agent_store(app); pollen::migrate_pollen_agent_name(app); - openai_credentials::migrate_openai_credentials(app); // Clean the legacy baked team-instructions suffix out of stored prompts // AFTER the fold (so definitions lifted out of personas.json are cleaned in // the same boot) and BEFORE backfill_standalone_agents (so a manufactured @@ -190,6 +189,7 @@ fn run_boot_migrations_inner(app: &tauri::AppHandle, reset_completed: bool) { reconcile_provider_mcp_commands(app); reconcile_databricks_v1_to_v2(app); materialize_agent_runtimes(app); + openai_credentials::migrate_openai_credentials(app); } /// Copy one-time app state from the legacy app identifier directory to diff --git a/desktop/src-tauri/src/migration/openai_credentials.rs b/desktop/src-tauri/src/migration/openai_credentials.rs index aeae5dc72c..37648a186c 100644 --- a/desktop/src-tauri/src/migration/openai_credentials.rs +++ b/desktop/src-tauri/src/migration/openai_credentials.rs @@ -1,367 +1,792 @@ -use std::path::{Path, PathBuf}; +use std::{ + collections::{BTreeMap, HashMap}, + path::{Path, PathBuf}, +}; -use serde_json::{Map, Value}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::managed_agents::{ + effective_config::{resolve_effective_config, ConfigSource, EffectiveConfigResult}, + AgentDefinition, GlobalAgentConfig, ManagedAgentRecord, +}; const OPENAI_API_KEY: &str = "OPENAI_API_KEY"; const OPENAI_COMPAT_API_KEY: &str = "OPENAI_COMPAT_API_KEY"; const OPENAI_COMPAT_BASE_URL: &str = "OPENAI_COMPAT_BASE_URL"; -const MIGRATION_SUFFIX: &str = "openai-credentials-v1.migrated"; +const CANONICAL_OPENAI_ORIGIN: &str = "https://api.openai.com/v1"; +const MARKER_FILE: &str = ".openai-credentials-v1.migrated"; +const MANIFEST_FILE: &str = ".openai-credentials-v1.transaction.json"; const BACKUP_SUFFIX: &str = "pre-openai-credentials-v1.bak"; -fn uses_official_openai_endpoint( - env_vars: Option<&Map>, - inherited_env_vars: Option<&Map>, -) -> bool { - let Some(base_url) = env_vars - .and_then(|env| env.get(OPENAI_COMPAT_BASE_URL)) - .or_else(|| inherited_env_vars.and_then(|env| env.get(OPENAI_COMPAT_BASE_URL))) - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - else { - return true; - }; - let Ok(url) = url::Url::parse(base_url) else { - return false; - }; - url.scheme() == "https" - && url - .host_str() - .is_some_and(|host| host.eq_ignore_ascii_case("api.openai.com")) - && url.port_or_known_default() == Some(443) - && url.username().is_empty() - && url.password().is_none() - && matches!(url.path().trim_end_matches('/'), "" | "/v1") - && url.query().is_none() - && url.fragment().is_none() +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +enum Owner { + Global, + Record(usize), + Baked, + Harness(String), + BuiltinDefinition(String), } -fn migrate_openai_credential_record( - record: &mut Map, - effective_provider: Option<&str>, - inherited_env_vars: Option<&Map>, - owns_effective_provider: bool, -) -> Result { - let local_provider = record - .get("provider") - .and_then(Value::as_str) - .map(str::trim) - .filter(|provider| !provider.is_empty()); - let Some(provider) = effective_provider - .or(local_provider) - .map(str::to_ascii_lowercase) - .filter(|provider| matches!(provider.as_str(), "openai" | "openai-compat")) - else { - return Ok(false); - }; - let official_endpoint = uses_official_openai_endpoint( - record.get("env_vars").and_then(Value::as_object), - inherited_env_vars, - ); - let target_provider = if official_endpoint { - "openai" - } else { - "openai-compat" - }; - let mut changed = false; - if provider != target_provider { - if !owns_effective_provider { - return Err(format!( - "cannot migrate a linked instance whose local endpoint requires provider {target_provider:?} while its definition resolves to {provider:?}" - )); - } - // Classification is based on the fully layered endpoint. Persist the - // resulting identity at the tier that owns provider resolution even - // when that tier previously inherited its provider. Without this, the - // rewrite is locally plausible but resolves back to the old provider - // after restart — precisely the kind of lie a one-shot marker makes - // permanent. - record.insert( - "provider".to_string(), - Value::String(target_provider.to_string()), - ); - changed = true; - } - if target_provider != "openai" { - return Ok(changed); - } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Identity { + Official, + Compatible, +} - let Some(env_vars) = record.get_mut("env_vars").and_then(Value::as_object_mut) else { - return Ok(changed); - }; - changed |= env_vars.remove(OPENAI_COMPAT_BASE_URL).is_some(); - if env_vars.contains_key(OPENAI_API_KEY) { - return Ok(changed); - } - let Some(legacy_key) = env_vars.remove(OPENAI_COMPAT_API_KEY) else { - return Ok(changed); - }; - env_vars.insert(OPENAI_API_KEY.to_string(), legacy_key); - Ok(true) +type EnvLayer = (Owner, BTreeMap); +type SerializedStore = (Option>, Option>); + +#[derive(Clone, Debug)] +struct ResolvedValue { + value: String, + owner: Owner, } #[derive(Clone, Debug)] -struct DefinitionContext { - provider: Option, - runtime: Option, - env: Map, +struct Consumer { + label: String, + record_index: usize, + provider: String, + provider_owner: Owner, + identity: Identity, + endpoint: Option, + legacy_key: Option, + official_key: Option, } -fn harness_env_for_runtime( - runtime: Option<&str>, - command_override_present: bool, - harnesses: &std::collections::HashMap>, -) -> Result, String> { - let Some(runtime) = runtime.map(str::trim).filter(|value| !value.is_empty()) else { - return Ok(Map::new()); - }; - if let Some(env) = harnesses.get(runtime) { - return Ok(env.clone()); - } - if command_override_present { - // Spawn accepts an explicit command before resolving the runtime. A - // deleted runtime then contributes no definition env rather than - // making the otherwise runnable record fail. - return Ok(Map::new()); - } - if crate::managed_agents::custom_harnesses::check_id_collision(runtime).is_ok() { - return Err(format!("unresolved custom harness {runtime:?}")); - } - Ok(Map::new()) +#[derive(Clone, Debug)] +struct Store { + global: GlobalAgentConfig, + records: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +struct FileHashes { + source: Option, + target: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +struct TransactionManifest { + global: FileHashes, + managed: FileHashes, +} + +#[derive(Clone)] +struct PlannedStore { + global: Option>, + managed: Option>, + consumers: Vec, +} + +fn hash(bytes: Option<&[u8]>) -> Option { + bytes.map(|bytes| hex::encode(Sha256::digest(bytes))) } fn sibling_path(path: &Path, suffix: &str) -> PathBuf { - let file_name = path + let name = path .file_name() - .map(|name| name.to_string_lossy()) + .map(|n| n.to_string_lossy()) .unwrap_or_default(); - crate::util::resolved_backup_path(path, &format!("{file_name}.{suffix}")) + crate::util::resolved_backup_path(path, &format!("{name}.{suffix}")) } -// This migration is intentionally one-shot. After the split, a single config may -// legitimately hold both credentials; reclassifying it on every boot would delete -// a newly added compat key whenever its default provider is official OpenAI. -fn migrate_openai_credentials_in_file( - path: &Path, - effective_provider: Option<&str>, - inherited_env_vars: Option<&Map>, - harnesses: &std::collections::HashMap>, -) -> Result<(), String> { - let marker_path = sibling_path(path, MIGRATION_SUFFIX); - if marker_path.exists() { - return Ok(()); - } +fn read_optional(path: &Path) -> Result>, String> { if !path.exists() { - // A later boot migration or sync step may still create this file with - // legacy bytes. Absence is not evidence that migration is complete. - return Ok(()); + return Ok(None); } + std::fs::read(path) + .map(Some) + .map_err(|e| format!("failed to read {}: {e}", path.display())) +} - let content = std::fs::read_to_string(path) - .map_err(|error| format!("failed to read {}: {error}", path.display()))?; - let mut root: Value = serde_json::from_str(&content) - .map_err(|error| format!("failed to parse {}: {error}", path.display()))?; - let changed = match &mut root { - Value::Array(records) => { - let definition_contexts = |records: &[Value]| { - records - .iter() - .filter_map(Value::as_object) - .filter(|record| record.get("pubkey").and_then(Value::as_str) == Some("")) - .filter_map(|record| { - let slug = record.get("slug")?.as_str()?.to_string(); - let provider = record - .get("provider") - .and_then(Value::as_str) - .map(str::to_owned); - let runtime = record - .get("runtime") - .and_then(Value::as_str) - .map(str::to_owned); - let env = record - .get("env_vars") - .and_then(Value::as_object) - .cloned() - .unwrap_or_default(); - Some(( - slug, - DefinitionContext { - provider, - runtime, - env, - }, - )) - }) - .collect::>() - }; - let mut changed = false; +fn parse_store(global: Option<&[u8]>, managed: Option<&[u8]>) -> Result { + let global = match global { + Some(bytes) => serde_json::from_slice(bytes) + .map_err(|e| format!("failed to parse global agent config: {e}"))?, + None => GlobalAgentConfig::default(), + }; + let records = match managed { + Some(bytes) => serde_json::from_slice(bytes) + .map_err(|e| format!("failed to parse managed agent store: {e}"))?, + None => Vec::new(), + }; + Ok(Store { global, records }) +} - // Provider ownership and env ownership are not the same graph. - // Definitions and standalone records own their provider tier, so - // migrate them first. Linked instances are definition-authoritative - // and must be evaluated only after those definitions have reached - // their persisted post-migration identity. - for record in records - .iter_mut() - .filter_map(Value::as_object_mut) - .filter(|record| record.get("persona_id").and_then(Value::as_str).is_none()) - { - let local_provider = record - .get("provider") - .and_then(Value::as_str) - .map(str::trim) - .filter(|provider| !provider.is_empty()) - .map(str::to_owned); - let resolved_provider = local_provider - .as_deref() - .or(effective_provider) - .map(str::to_owned); - let selected_runtime = record - .get("runtime") - .and_then(Value::as_str) - .map(str::trim) - .filter(|runtime| !runtime.is_empty()); - let command_override_present = record - .get("agent_command_override") - .and_then(Value::as_str) - .map(str::trim) - .is_some_and(|command| !command.is_empty()); - let mut inherited_env = - harness_env_for_runtime(selected_runtime, command_override_present, harnesses)?; - if let Some(global_env) = inherited_env_vars { - inherited_env.extend(global_env.clone()); - } - changed |= migrate_openai_credential_record( - record, - resolved_provider.as_deref(), - (!inherited_env.is_empty()).then_some(&inherited_env), - true, - )?; +fn definitions(store: &Store) -> Vec { + let mut defs: Vec<_> = store + .records + .iter() + .filter(|r| r.pubkey.is_empty()) + .filter_map(ManagedAgentRecord::to_definition_view) + .collect(); + for id in store.records.iter().filter_map(|r| r.persona_id.as_deref()) { + if !defs.iter().any(|d| d.id == id) { + if let Some(def) = crate::managed_agents::built_in_persona_definition(id, "") { + defs.push(def); } + } + } + defs +} - let definitions = definition_contexts(records); - for record in records.iter_mut().filter_map(Value::as_object_mut) { - let Some(persona_id) = record.get("persona_id").and_then(Value::as_str) else { - continue; - }; - let definition = definitions - .get(persona_id) - .ok_or_else(|| format!("cannot resolve linked definition {persona_id:?}"))?; - let resolved_provider = definition - .provider - .as_deref() - .map(str::trim) - .filter(|provider| !provider.is_empty()) - .or(effective_provider); - let selected_runtime = record - .get("runtime") - .and_then(Value::as_str) - .map(str::trim) - .filter(|runtime| !runtime.is_empty()) - .or_else(|| { - definition - .runtime - .as_deref() - .map(str::trim) - .filter(|runtime| !runtime.is_empty()) - }); - let command_override_present = record - .get("agent_command_override") - .and_then(Value::as_str) - .map(str::trim) - .is_some_and(|command| !command.is_empty()); - let mut inherited_env = - harness_env_for_runtime(selected_runtime, command_override_present, harnesses)?; - if let Some(global_env) = inherited_env_vars { - inherited_env.extend(global_env.clone()); - } - inherited_env.extend(definition.env.clone()); - changed |= migrate_openai_credential_record( - record, - resolved_provider, - (!inherited_env.is_empty()).then_some(&inherited_env), - false, - )?; - } - changed +fn selected_definition<'a>( + record: &ManagedAgentRecord, + defs: &'a [AgentDefinition], +) -> Option<&'a AgentDefinition> { + record + .persona_id + .as_deref() + .and_then(|id| defs.iter().find(|d| d.id == id)) +} + +fn runtime_id<'a>( + record: &'a ManagedAgentRecord, + def: Option<&'a AgentDefinition>, +) -> Option<&'a str> { + record + .runtime + .as_deref() + .or_else(|| def.and_then(|d| d.runtime.as_deref())) + .map(str::trim) + .filter(|v| !v.is_empty()) +} + +fn layers( + store: &Store, + record: &ManagedAgentRecord, + def: Option<&AgentDefinition>, +) -> Result, String> { + let mut result = vec![(Owner::Baked, crate::managed_agents::baked_build_env())]; + if let Some(id) = runtime_id(record, def) { + if let Some(harness) = + crate::managed_agents::custom_harnesses::lookup_loaded_harness_by_id(id) + { + result.push((Owner::Harness(id.to_string()), harness.env.clone())); + } else if crate::managed_agents::known_acp_runtime_exact(id).is_none() + && record + .agent_command_override + .as_deref() + .map(str::trim) + .filter(|v| !v.is_empty()) + .is_none() + { + return Err(format!("unresolved custom harness {id:?}")); } - Value::Object(record) => { - migrate_openai_credential_record(record, effective_provider, inherited_env_vars, true)? - } - _ => { + } + result.push((Owner::Global, store.global.env_vars.clone())); + if let Some(def) = def { + let owner = store + .records + .iter() + .position(|r| r.pubkey.is_empty() && r.slug.as_deref() == Some(def.id.as_str())) + .map(Owner::Record) + .unwrap_or_else(|| Owner::BuiltinDefinition(def.id.clone())); + result.push((owner, def.env_vars.clone())); + } + result.push(( + Owner::Record( + store + .records + .iter() + .position(|candidate| std::ptr::eq(candidate, record)) + .ok_or("consumer record is not in store")?, + ), + record.env_vars.clone(), + )); + Ok(result) +} + +fn resolve_key(layers: &[(Owner, BTreeMap)], key: &str) -> Option { + layers.iter().rev().find_map(|(owner, env)| { + env.get(key).map(|value| ResolvedValue { + value: value.clone(), + owner: owner.clone(), + }) + }) +} + +fn classify_endpoint(endpoint: Option<&str>) -> Result { + let Some(value) = endpoint.map(str::trim).filter(|v| !v.is_empty()) else { + return Ok(Identity::Official); + }; + let url = url::Url::parse(value).map_err(|_| format!("invalid OpenAI endpoint {value:?}"))?; + if !matches!(url.scheme(), "http" | "https") + || url.host_str().is_none() + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + { + return Err(format!("invalid OpenAI endpoint {value:?}")); + } + let official = url.scheme() == "https" + && url + .host_str() + .is_some_and(|h| h.eq_ignore_ascii_case("api.openai.com")) + && url.port_or_known_default() == Some(443) + && matches!(url.path().trim_end_matches('/'), "" | "/v1"); + Ok(if official { + Identity::Official + } else { + Identity::Compatible + }) +} + +fn collect_consumers(store: &Store) -> Result, String> { + let defs = definitions(store); + let mut consumers = Vec::new(); + for (index, record) in store.records.iter().enumerate() { + let def = selected_definition(record, &defs); + if record.persona_id.is_some() && def.is_none() { return Err(format!( - "expected a JSON object or array in {}", - path.display() + "cannot resolve linked definition {:?}", + record.persona_id )); } - }; + let effective = resolve_effective_config(record, &defs, &store.global); + let config = match effective { + EffectiveConfigResult::Resolved(config) => config, + EffectiveConfigResult::OrphanedInstance { + missing_persona_id, .. + } => { + return Err(format!( + "cannot resolve linked definition {missing_persona_id:?}" + )) + } + }; + let Some(provider) = config + .provider + .value + .as_deref() + .map(str::trim) + .filter(|v| !v.is_empty()) + else { + continue; + }; + if !matches!( + provider.to_ascii_lowercase().as_str(), + "openai" | "openai-compat" + ) { + continue; + } + let provider_owner = match config.provider.source { + ConfigSource::Global => Owner::Global, + ConfigSource::Definition => { + let id = record + .persona_id + .as_deref() + .or(record.slug.as_deref()) + .ok_or("definition provider has no owner")?; + store + .records + .iter() + .position(|r| r.pubkey.is_empty() && r.slug.as_deref() == Some(id)) + .map(Owner::Record) + .unwrap_or_else(|| Owner::BuiltinDefinition(id.to_string())) + } + ConfigSource::InstanceLegacy => Owner::Record(index), + }; + let resolved_layers = layers(store, record, def)?; + let endpoint = resolve_key(&resolved_layers, OPENAI_COMPAT_BASE_URL); + let identity = classify_endpoint(endpoint.as_ref().map(|v| v.value.as_str()))?; + consumers.push(Consumer { + label: record + .slug + .clone() + .or_else(|| (!record.pubkey.is_empty()).then(|| record.pubkey.clone())) + .unwrap_or_else(|| format!("record-{index}")), + record_index: index, + provider: provider.to_ascii_lowercase(), + provider_owner, + identity, + endpoint, + legacy_key: resolve_key(&resolved_layers, OPENAI_COMPAT_API_KEY), + official_key: resolve_key(&resolved_layers, OPENAI_API_KEY), + }); + } + Ok(consumers) +} - if changed { - let backup_path = sibling_path(path, BACKUP_SUFFIX); - crate::util::create_restricted_backup_once(&backup_path, content.as_bytes()) - .map_err(|error| format!("failed to back up {}: {error}", path.display()))?; - let bytes = serde_json::to_vec_pretty(&root) - .map_err(|error| format!("failed to serialize {}: {error}", path.display()))?; - crate::managed_agents::atomic_write_json_restricted(path, &bytes)?; +fn env_mut(store: &mut Store, owner: Owner) -> Result<&mut BTreeMap, String> { + match owner { + Owner::Global => Ok(&mut store.global.env_vars), + Owner::Record(i) => Ok(&mut store + .records + .get_mut(i) + .ok_or("record owner is missing")? + .env_vars), + Owner::Baked | Owner::Harness(_) | Owner::BuiltinDefinition(_) => { + Err("required mutation is owned by a read-only harness/build layer".into()) + } + } +} + +fn set_provider(store: &mut Store, owner: Owner, provider: &str) -> Result<(), String> { + match owner { + Owner::Global => { + store.global.provider = Some(provider.into()); + Ok(()) + } + Owner::Record(i) => { + store + .records + .get_mut(i) + .ok_or("record owner is missing")? + .provider = Some(provider.into()); + Ok(()) + } + Owner::Baked | Owner::Harness(_) | Owner::BuiltinDefinition(_) => { + Err("required provider mutation is read-only".into()) + } + } +} + +fn plan_store(mut store: Store) -> Result<(Store, Vec), String> { + let consumers = collect_consumers(&store)?; + let all_env_consumers: Vec<_> = store + .records + .iter() + .enumerate() + .map(|(index, record)| { + let defs = definitions(&store); + let def = selected_definition(record, &defs); + layers(&store, record, def).map(|layers| { + ( + index, + resolve_key(&layers, OPENAI_COMPAT_API_KEY), + resolve_key(&layers, OPENAI_API_KEY), + resolve_key(&layers, OPENAI_COMPAT_BASE_URL), + ) + }) + }) + .collect::>()?; + let mut provider_targets: HashMap = HashMap::new(); + for consumer in &consumers { + let expected = if consumer.identity == Identity::Official { + "openai" + } else { + "openai-compat" + }; + match provider_targets.entry(consumer.provider_owner.clone()) { + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert((consumer.identity, consumer.provider != expected)); + } + std::collections::hash_map::Entry::Occupied(mut entry) => { + if entry.get().0 != consumer.identity { + return Err(format!( + "provider owner {:?} feeds mixed official/custom consumers", + consumer.provider_owner + )); + } + entry.get_mut().1 |= consumer.provider != expected; + } + } + } + for (owner, (identity, needs_change)) in provider_targets { + if needs_change { + set_provider( + &mut store, + owner, + if identity == Identity::Official { + "openai" + } else { + "openai-compat" + }, + )?; + } } - crate::managed_agents::atomic_write_json_restricted(&marker_path, b"1\n") + let mut legacy_targets: HashMap = HashMap::new(); + for consumer in &consumers { + if consumer.identity == Identity::Official + && consumer + .official_key + .as_ref() + .is_some_and(|v| !v.value.is_empty()) + { + continue; + } + let Some(legacy) = &consumer.legacy_key else { + continue; + }; + if let Some(previous) = legacy_targets.insert(legacy.owner.clone(), consumer.identity) { + if previous != consumer.identity { + return Err(format!( + "credential owner {:?} feeds mixed official/custom consumers", + legacy.owner + )); + } + } + } + for (owner, identity) in legacy_targets { + if identity == Identity::Official { + for (index, legacy, official, _) in &all_env_consumers { + if legacy.as_ref().is_some_and(|value| value.owner == owner) { + let relevant = consumers + .iter() + .find(|consumer| consumer.record_index == *index); + if relevant.is_none_or(|consumer| consumer.identity != Identity::Official) { + return Err(format!( + "credential owner {owner:?} is shared with a non-official consumer" + )); + } + if official + .as_ref() + .is_some_and(|value| !value.value.is_empty()) + { + return Err(format!( + "credential owner {owner:?} is also visible beside an existing distinct official credential" + )); + } + } + } + let env = env_mut(&mut store, owner)?; + if env.get(OPENAI_API_KEY).is_none_or(|value| value.is_empty()) { + if let Some(value) = env.remove(OPENAI_COMPAT_API_KEY) { + env.insert(OPENAI_API_KEY.into(), value); + } + } + } + } + + // Official aliases and inherited custom origins must be shadowed with the one + // spelling accepted by both readiness and buzz-agent. Plan this by the + // shared endpoint owner: changing a lower alias is forbidden when it also + // routes a custom/non-OpenAI consumer. + let mut alias_owners = Vec::new(); + for consumer in &consumers { + if consumer.identity != Identity::Official { + continue; + } + if let Some(endpoint) = &consumer.endpoint { + if endpoint.value.trim().trim_end_matches('/') != CANONICAL_OPENAI_ORIGIN { + alias_owners.push(endpoint.owner.clone()); + } + } + } + alias_owners.sort_by_key(|owner| format!("{owner:?}")); + alias_owners.dedup(); + for owner in alias_owners { + for (index, _, _, endpoint) in &all_env_consumers { + if endpoint.as_ref().is_some_and(|value| value.owner == owner) { + let relevant = consumers + .iter() + .find(|consumer| consumer.record_index == *index); + if relevant.is_none_or(|consumer| consumer.identity != Identity::Official) { + return Err(format!( + "endpoint owner {owner:?} is shared with a non-official consumer" + )); + } + } + } + env_mut(&mut store, owner)?.insert( + OPENAI_COMPAT_BASE_URL.into(), + CANONICAL_OPENAI_ORIGIN.into(), + ); + } + + // A canonical local endpoint may be the only thing hiding a lower custom + // route. Removing it would make official OpenAI fail closed after restart, + // so leave every already-canonical shadow intact. + Ok((store, consumers)) +} + +fn serialize_store( + store: &Store, + source_global: Option<&[u8]>, + source_managed: Option<&[u8]>, +) -> Result { + let global = if source_global.is_some() || store.global != GlobalAgentConfig::default() { + Some(serde_json::to_vec_pretty(&store.global).map_err(|e| e.to_string())?) + } else { + None + }; + let managed = if source_managed.is_some() || !store.records.is_empty() { + Some(serde_json::to_vec_pretty(&store.records).map_err(|e| e.to_string())?) + } else { + None + }; + Ok((global, managed)) +} + +fn verify_post_split(before: &[Consumer], after: &Store) -> Result<(), String> { + let post = collect_consumers(after)?; + let defs = definitions(after); + for old in before { + let new = post + .iter() + .find(|c| c.record_index == old.record_index) + .ok_or_else(|| format!("consumer {:?} disappeared", old.label))?; + if new.identity != old.identity { + return Err(format!( + "consumer {:?} changed endpoint identity", + old.label + )); + } + let expected_provider = if old.identity == Identity::Official { + "openai" + } else { + "openai-compat" + }; + let config = match resolve_effective_config( + &after.records[old.record_index], + &defs, + &after.global, + ) { + EffectiveConfigResult::Resolved(c) => c, + _ => return Err(format!("consumer {:?} became unresolved", old.label)), + }; + if config.provider.value.as_deref() != Some(expected_provider) { + return Err(format!( + "consumer {:?} resolves provider {:?}, expected {expected_provider}", + old.label, config.provider.value + )); + } + let descriptor = crate::managed_agents::resolve_effective_harness_descriptor( + &after.records[old.record_index], + &defs, + &after.global, + ) + .map_err(|e| format!("consumer {:?} harness resolution failed: {e}", old.label))?; + let old_selected = if old.provider == "openai" { + old.official_key.as_ref().or(old.legacy_key.as_ref()) + } else { + old.legacy_key.as_ref() + }; + let effective = crate::managed_agents::resolve_effective_agent_env( + &after.records[old.record_index], + &defs, + crate::managed_agents::known_acp_runtime(&descriptor.command), + &after.global, + ); + let readiness = crate::managed_agents::agent_readiness(&effective); + let readiness_failure = match &readiness { + crate::managed_agents::AgentReadiness::Ready => false, + crate::managed_agents::AgentReadiness::NotReady { requirements } => { + requirements.iter().any(|requirement| match requirement { + crate::managed_agents::Requirement::ConfigInvalid { .. } => true, + crate::managed_agents::Requirement::EnvKey { key } => { + key == if old.identity == Identity::Official { + OPENAI_API_KEY + } else { + OPENAI_COMPAT_BASE_URL + } + } + _ => false, + }) + } + }; + if old_selected.is_some() && readiness_failure { + return Err(format!( + "consumer {:?} fails post-split readiness", + old.label + )); + } + let selected = if old.identity == Identity::Official { + descriptor.env.get(OPENAI_API_KEY) + } else { + descriptor.env.get(OPENAI_COMPAT_API_KEY) + }; + let expected = if old.identity == Identity::Official { + old.official_key + .as_ref() + .filter(|v| !v.value.is_empty()) + .or(old.legacy_key.as_ref()) + .map(|v| &v.value) + } else { + old.legacy_key.as_ref().map(|v| &v.value) + }; + if old_selected.is_some() && selected != expected { + return Err(format!( + "consumer {:?} changed selected credential", + old.label + )); + } + if old.identity == Identity::Official { + let origin = descriptor + .env + .get(OPENAI_COMPAT_BASE_URL) + .map(String::as_str); + if origin.is_some_and(|v| v.trim().trim_end_matches('/') != CANONICAL_OPENAI_ORIGIN) { + return Err(format!( + "consumer {:?} retains rejected official origin", + old.label + )); + } + } else if classify_endpoint( + descriptor + .env + .get(OPENAI_COMPAT_BASE_URL) + .map(String::as_str), + )? != Identity::Compatible + { + return Err(format!("consumer {:?} lost its custom origin", old.label)); + } + } + Ok(()) +} + +fn make_plan(global: Option<&[u8]>, managed: Option<&[u8]>) -> Result { + let source = parse_store(global, managed)?; + let (store, consumers) = plan_store(source)?; + verify_post_split(&consumers, &store)?; + let (global, managed) = serialize_store(&store, global, managed)?; + Ok(PlannedStore { + global, + managed, + consumers, + }) +} + +fn current_hash(path: &Path) -> Result, String> { + Ok(hash(read_optional(path)?.as_deref())) +} + +fn apply_target(path: &Path, target: Option<&[u8]>) -> Result<(), String> { + match target { + Some(bytes) => crate::managed_agents::atomic_write_json_restricted(path, bytes), + None if path.exists() => std::fs::remove_file(path) + .map_err(|e| format!("failed to remove {}: {e}", path.display())), + None => Ok(()), + } +} + +fn verify_written_targets( + expected: &TransactionManifest, + consumers: &[Consumer], + global: Option<&[u8]>, + managed: Option<&[u8]>, +) -> Result<(), String> { + if hash(global) != expected.global.target || hash(managed) != expected.managed.target { + return Err("migration target hash mismatch after write".into()); + } + let reread = parse_store(global, managed)?; + verify_post_split(consumers, &reread) +} + +fn migrate_pair(agents_dir: &Path) -> Result<(), String> { + let marker = agents_dir.join(MARKER_FILE); + if marker.is_file() + && std::fs::read(&marker) + .ok() + .as_deref() + .is_some_and(|bytes| bytes == b"1\n") + { + return Ok(()); + } + let global_path = agents_dir.join("global-agent-config.json"); + let managed_path = agents_dir.join("managed-agents.json"); + let manifest_path = agents_dir.join(MANIFEST_FILE); + + let (source_global, source_managed, manifest) = if manifest_path.exists() { + let manifest: TransactionManifest = + serde_json::from_slice(&std::fs::read(&manifest_path).map_err(|e| e.to_string())?) + .map_err(|e| format!("invalid migration manifest: {e}"))?; + for (path, hashes) in [ + (&global_path, &manifest.global), + (&managed_path, &manifest.managed), + ] { + let current = current_hash(path)?; + if current != hashes.source && current != hashes.target { + return Err(format!( + "external edit detected during OpenAI credential migration: {}", + path.display() + )); + } + } + let source_global = if manifest.global.source.is_some() { + read_optional(&sibling_path(&global_path, BACKUP_SUFFIX))? + } else { + None + }; + let source_managed = if manifest.managed.source.is_some() { + read_optional(&sibling_path(&managed_path, BACKUP_SUFFIX))? + } else { + None + }; + (source_global, source_managed, Some(manifest)) + } else { + ( + read_optional(&global_path)?, + read_optional(&managed_path)?, + None, + ) + }; + if source_global.is_none() && source_managed.is_none() { + return Ok(()); + } + let plan = make_plan(source_global.as_deref(), source_managed.as_deref())?; + let expected = TransactionManifest { + global: FileHashes { + source: hash(source_global.as_deref()), + target: hash(plan.global.as_deref()), + }, + managed: FileHashes { + source: hash(source_managed.as_deref()), + target: hash(plan.managed.as_deref()), + }, + }; + if let Some(existing) = manifest { + if existing.global.source != expected.global.source + || existing.global.target != expected.global.target + || existing.managed.source != expected.managed.source + || existing.managed.target != expected.managed.target + { + return Err("migration manifest does not match pristine backups".into()); + } + } else { + if let Some(bytes) = &source_global { + let backup_path = sibling_path(&global_path, BACKUP_SUFFIX); + crate::util::create_restricted_backup_once(&backup_path, bytes) + .map_err(|e| e.to_string())?; + if read_optional(&backup_path)?.as_deref() != Some(bytes.as_slice()) { + return Err(format!( + "existing migration backup does not match pristine source: {}", + backup_path.display() + )); + } + } + if let Some(bytes) = &source_managed { + let backup_path = sibling_path(&managed_path, BACKUP_SUFFIX); + crate::util::create_restricted_backup_once(&backup_path, bytes) + .map_err(|e| e.to_string())?; + if read_optional(&backup_path)?.as_deref() != Some(bytes.as_slice()) { + return Err(format!( + "existing migration backup does not match pristine source: {}", + backup_path.display() + )); + } + } + let bytes = serde_json::to_vec_pretty(&expected).map_err(|e| e.to_string())?; + crate::managed_agents::atomic_write_json_restricted(&manifest_path, &bytes)?; + } + if current_hash(&global_path)? == expected.global.source { + apply_target(&global_path, plan.global.as_deref())?; + } + if current_hash(&managed_path)? == expected.managed.source { + apply_target(&managed_path, plan.managed.as_deref())?; + } + let reread_global = read_optional(&global_path)?; + let reread_managed = read_optional(&managed_path)?; + verify_written_targets( + &expected, + &plan.consumers, + reread_global.as_deref(), + reread_managed.as_deref(), + )?; + crate::managed_agents::atomic_write_json_restricted(&marker, b"1\n") } pub(super) fn migrate_openai_credentials(app: &tauri::AppHandle) { let Ok(agents_dir) = crate::managed_agents::managed_agents_base_dir(app) else { return; }; - let custom_harnesses_dir = agents_dir - .parent() - .map(|path| path.join("custom_harnesses")); - let mut harness_definitions = crate::managed_agents::preset_harness_definitions(); - harness_definitions.extend( - custom_harnesses_dir - .as_deref() - .map(crate::managed_agents::custom_harnesses::load_custom_harnesses) - .unwrap_or_default(), - ); - let harnesses: std::collections::HashMap> = harness_definitions - .into_iter() - .map(|definition| { - let env = definition - .env - .into_iter() - .map(|(key, value)| (key, Value::String(value))) - .collect(); - (definition.id, env) - }) - .collect(); - let global_path = agents_dir.join("global-agent-config.json"); - if let Err(error) = migrate_openai_credentials_in_file(&global_path, None, None, &harnesses) { - eprintln!("buzz-desktop: openai-credential-migration: {error}"); - } - - // Agent and definition records inherit the global env floor. Classification - // must use that effective endpoint when a record does not override it; - // otherwise an `openai` definition inheriting a custom global origin would - // be silently relabeled as official OpenAI and stop working. - let global = std::fs::read_to_string(&global_path) - .ok() - .and_then(|content| serde_json::from_str::(&content).ok()); - let global_provider = global - .as_ref() - .and_then(|root| root.get("provider")) - .and_then(Value::as_str) - .map(str::to_owned); - let global_env = global - .as_ref() - .and_then(|root| root.get("env_vars")) - .and_then(Value::as_object) - .cloned(); - let managed_path = agents_dir.join("managed-agents.json"); - if let Err(error) = migrate_openai_credentials_in_file( - &managed_path, - global_provider.as_deref(), - global_env.as_ref(), - &harnesses, - ) { + let custom_dir = agents_dir.parent().map(|p| p.join("custom_harnesses")); + crate::managed_agents::custom_harnesses::warm_harness_registry_from_dir(custom_dir.as_deref()); + if let Err(error) = migrate_pair(&agents_dir) { eprintln!("buzz-desktop: openai-credential-migration: {error}"); } } diff --git a/desktop/src-tauri/src/migration/openai_credentials_tests.rs b/desktop/src-tauri/src/migration/openai_credentials_tests.rs index 43baee1c67..6054b70415 100644 --- a/desktop/src-tauri/src/migration/openai_credentials_tests.rs +++ b/desktop/src-tauri/src/migration/openai_credentials_tests.rs @@ -1,711 +1,486 @@ use super::*; -fn custom_gateway_harnesses() -> std::collections::HashMap> { - std::collections::HashMap::from([( - "custom-gateway".to_string(), - serde_json::json!({ - "OPENAI_COMPAT_BASE_URL": "https://gateway.example/v1" - }) - .as_object() - .unwrap() - .clone(), - )]) -} -fn migrate_file( - path: &Path, - effective_provider: Option<&str>, - inherited_env_vars: Option<&Map>, -) -> Result<(), String> { - migrate_openai_credentials_in_file( - path, - effective_provider, - inherited_env_vars, - &std::collections::HashMap::new(), - ) -} - -fn migrate(value: Value) -> (Value, bool) { - let mut record = value.as_object().unwrap().clone(); - let changed = migrate_openai_credential_record(&mut record, None, None, true).unwrap(); - (Value::Object(record), changed) -} - -#[test] -fn official_openai_renames_the_legacy_credential_and_removes_routing_state() { - let (record, changed) = migrate(serde_json::json!({ - "provider": "openai", - "env_vars": { - "OPENAI_COMPAT_API_KEY": "official-secret", - "OPENAI_COMPAT_BASE_URL": "https://api.openai.com/v1", - "KEEP": "value" - } - })); - - assert!(changed); - assert_eq!(record["env_vars"][OPENAI_API_KEY], "official-secret"); - assert!(record["env_vars"].get(OPENAI_COMPAT_API_KEY).is_none()); - assert!(record["env_vars"].get(OPENAI_COMPAT_BASE_URL).is_none()); - assert_eq!(record["env_vars"]["KEEP"], "value"); -} - -#[test] -fn custom_endpoint_stays_openai_compat_with_its_credential() { - let original = serde_json::json!({ - "provider": "openai-compat", - "env_vars": { - "OPENAI_COMPAT_API_KEY": "compat-secret", - "OPENAI_COMPAT_BASE_URL": "https://gateway.example/v1" - } - }); - let (record, changed) = migrate(original.clone()); - - assert!(!changed); - assert_eq!(record, original); -} - -#[test] -fn legacy_openai_custom_endpoint_becomes_openai_compat() { - let (record, changed) = migrate(serde_json::json!({ - "provider": "openai", - "env_vars": { - "OPENAI_COMPAT_API_KEY": "compat-secret", - "OPENAI_COMPAT_BASE_URL": "http://localhost:11434/v1" - } - })); - - assert!(changed); - assert_eq!(record["provider"], "openai-compat"); - assert_eq!(record["env_vars"][OPENAI_COMPAT_API_KEY], "compat-secret"); - assert!(record["env_vars"].get(OPENAI_API_KEY).is_none()); -} - -#[test] -fn non_api_openai_subdomain_is_still_a_compat_endpoint() { - let (record, changed) = migrate(serde_json::json!({ - "provider": "openai", - "env_vars": { - "OPENAI_COMPAT_API_KEY": "compat-secret", - "OPENAI_COMPAT_BASE_URL": "https://gateway.openai.com/v1" - } - })); - - assert!(changed); - assert_eq!(record["provider"], "openai-compat"); - assert_eq!(record["env_vars"][OPENAI_COMPAT_API_KEY], "compat-secret"); -} - -#[test] -fn non_api_path_on_openai_host_is_still_a_compat_endpoint() { - let (record, changed) = migrate(serde_json::json!({ - "provider": "openai", - "env_vars": { - "OPENAI_COMPAT_API_KEY": "compat-secret", - "OPENAI_COMPAT_BASE_URL": "https://api.openai.com/proxy/v1" - } - })); - - assert!(changed); - assert_eq!(record["provider"], "openai-compat"); - assert_eq!(record["env_vars"][OPENAI_COMPAT_API_KEY], "compat-secret"); - assert!(record["env_vars"].get(OPENAI_API_KEY).is_none()); -} - -#[test] -fn legacy_openai_compat_without_custom_endpoint_becomes_official() { - let (record, changed) = migrate(serde_json::json!({ - "provider": "openai-compat", - "env_vars": { "OPENAI_COMPAT_API_KEY": "official-secret" } - })); - - assert!(changed); - assert_eq!(record["provider"], "openai"); - assert_eq!(record["env_vars"][OPENAI_API_KEY], "official-secret"); - assert!(record["env_vars"].get(OPENAI_COMPAT_API_KEY).is_none()); -} - -#[test] -fn existing_distinct_credentials_are_both_preserved() { - let original = serde_json::json!({ - "provider": "openai", - "env_vars": { - "OPENAI_API_KEY": "official-secret", - "OPENAI_COMPAT_API_KEY": "compat-secret" - } - }); - let (record, changed) = migrate(original.clone()); - - assert!(!changed); - assert_eq!(record, original); -} - -#[test] -fn providerless_record_is_not_guessed() { - let original = serde_json::json!({ - "env_vars": { "OPENAI_COMPAT_API_KEY": "ambiguous-secret" } - }); - let (record, changed) = migrate(original.clone()); - - assert!(!changed); - assert_eq!(record, original); -} - -#[test] -fn record_inherits_custom_global_endpoint_for_classification() { - let inherited_env = serde_json::json!({ - "OPENAI_COMPAT_BASE_URL": "https://gateway.example/v1" - }); - let mut record = serde_json::json!({ - "provider": "openai", - "env_vars": { "OPENAI_COMPAT_API_KEY": "compat-secret" } - }) - .as_object() +fn record(pubkey: &str, name: &str) -> ManagedAgentRecord { + serde_json::from_value(serde_json::json!({ + "pubkey": pubkey, + "name": name, + "relay_url": "wss://relay.example", + "acp_command": "buzz-acp", + "agent_command": "buzz-agent", + "agent_args": [], + "mcp_command": "", + "turn_timeout_seconds": 320, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z" + })) .unwrap() - .clone(); +} - let changed = - migrate_openai_credential_record(&mut record, None, inherited_env.as_object(), true) - .unwrap(); +fn definition(id: &str) -> ManagedAgentRecord { + let mut value = record("", id); + value.slug = Some(id.to_string()); + value.display_name = Some(id.to_string()); + value.system_prompt = Some(String::new()); + value +} - assert!(changed); - assert_eq!(record["provider"], "openai-compat"); - assert_eq!(record["env_vars"][OPENAI_COMPAT_API_KEY], "compat-secret"); - assert!(record["env_vars"].get(OPENAI_API_KEY).is_none()); +fn planned(global: GlobalAgentConfig, records: Vec) -> Result { + plan_store(Store { global, records }).map(|(store, _)| store) } #[test] -fn local_endpoint_override_wins_over_inherited_endpoint() { - let inherited_env = serde_json::json!({ - "OPENAI_COMPAT_BASE_URL": "https://gateway.example/v1" - }); - let mut record = serde_json::json!({ - "provider": "openai", - "env_vars": { - "OPENAI_COMPAT_API_KEY": "official-secret", - "OPENAI_COMPAT_BASE_URL": "https://api.openai.com/v1" +fn whole_store_matrix_classifies_origins_and_moves_only_official_credentials() { + for (origin, expected_provider, expected_key) in [ + (None, "openai", OPENAI_API_KEY), + (Some("https://api.openai.com/v1"), "openai", OPENAI_API_KEY), + (Some("https://api.openai.com"), "openai", OPENAI_API_KEY), + ( + Some("http://localhost:11434/v1"), + "openai-compat", + OPENAI_COMPAT_API_KEY, + ), + ] { + let mut agent = record("agent", "agent"); + agent.provider = Some("openai".into()); + agent + .env_vars + .insert(OPENAI_COMPAT_API_KEY.into(), "secret".into()); + if let Some(origin) = origin { + agent + .env_vars + .insert(OPENAI_COMPAT_BASE_URL.into(), origin.into()); } - }) - .as_object() - .unwrap() - .clone(); - let changed = - migrate_openai_credential_record(&mut record, None, inherited_env.as_object(), true) - .unwrap(); - - assert!(changed); - assert_eq!(record["provider"], "openai"); - assert_eq!(record["env_vars"][OPENAI_API_KEY], "official-secret"); -} - -#[test] -fn credentialed_openai_host_is_not_classified_as_official() { - let (record, changed) = migrate(serde_json::json!({ - "provider": "openai", - "env_vars": { - "OPENAI_COMPAT_API_KEY": "compat-secret", - "OPENAI_COMPAT_BASE_URL": "https://attacker@example.com@api.openai.com/v1" + let store = planned(GlobalAgentConfig::default(), vec![agent]).unwrap(); + assert_eq!( + store.records[0].provider.as_deref(), + Some(expected_provider) + ); + assert_eq!( + store.records[0] + .env_vars + .get(expected_key) + .map(String::as_str), + Some("secret") + ); + if expected_provider == "openai" && origin.is_some() { + assert_eq!( + store.records[0] + .env_vars + .get(OPENAI_COMPAT_BASE_URL) + .map(String::as_str), + Some(CANONICAL_OPENAI_ORIGIN) + ); } - })); - - assert!(changed); - assert_eq!(record["provider"], "openai-compat"); - assert_eq!(record["env_vars"][OPENAI_COMPAT_API_KEY], "compat-secret"); + } } #[test] -fn custom_harness_origin_prevents_official_credential_relabeling() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("managed-agents.json"); - std::fs::write( - &path, - serde_json::to_vec(&serde_json::json!([{ - "pubkey": "", - "slug": "custom-definition", - "runtime": "custom-gateway", - "provider": "openai", - "env_vars": { "OPENAI_COMPAT_API_KEY": "compat-secret" } - }])) - .unwrap(), - ) - .unwrap(); - let harnesses = custom_gateway_harnesses(); +fn malformed_origin_fails_before_mutation() { + let mut agent = record("agent", "agent"); + agent.provider = Some("openai".into()); + agent + .env_vars + .insert(OPENAI_COMPAT_BASE_URL.into(), "not a url".into()); + assert!(planned(GlobalAgentConfig::default(), vec![agent]) + .unwrap_err() + .contains("invalid OpenAI endpoint")); +} - migrate_openai_credentials_in_file(&path, None, None, &harnesses).unwrap(); - let records: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); - - assert_eq!(records[0]["provider"], "openai-compat"); +#[test] +fn existing_official_key_wins_without_copying_legacy_secret() { + let mut agent = record("agent", "agent"); + agent.provider = Some("openai".into()); + agent + .env_vars + .insert(OPENAI_API_KEY.into(), "official".into()); + agent + .env_vars + .insert(OPENAI_COMPAT_API_KEY.into(), "compat".into()); + let store = planned(GlobalAgentConfig::default(), vec![agent]).unwrap(); assert_eq!( - records[0]["env_vars"][OPENAI_COMPAT_API_KEY], - "compat-secret" - ); - assert!(records[0]["env_vars"].get(OPENAI_API_KEY).is_none()); -} - -#[test] -fn unresolved_custom_harness_leaves_file_unmarked() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("managed-agents.json"); - let original = serde_json::to_vec(&serde_json::json!([{ - "pubkey": "", - "slug": "custom-definition", - "runtime": "missing-custom-harness", - "provider": "openai", - "env_vars": { "OPENAI_COMPAT_API_KEY": "compat-secret" } - }])) - .unwrap(); - std::fs::write(&path, &original).unwrap(); - - let error = - migrate_openai_credentials_in_file(&path, None, None, &std::collections::HashMap::new()) - .unwrap_err(); - - assert!(error.contains("unresolved custom harness"), "{error}"); - assert_eq!(std::fs::read(&path).unwrap(), original); - assert!(!sibling_path(&path, MIGRATION_SUFFIX).exists()); -} - -#[test] -fn raw_command_override_does_not_block_runtime_less_record_migration() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("managed-agents.json"); - std::fs::write( - &path, - serde_json::to_vec(&serde_json::json!([{ - "pubkey": "agent-pubkey", - "agent_command_override": "/opt/custom/my-agent", - "provider": "openai", - "env_vars": { "OPENAI_COMPAT_API_KEY": "official-secret" } - }])) - .unwrap(), - ) - .unwrap(); - - migrate_openai_credentials_in_file(&path, None, None, &std::collections::HashMap::new()) - .unwrap(); - let records: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); - - assert_eq!(records[0]["env_vars"][OPENAI_API_KEY], "official-secret"); - assert!(sibling_path(&path, MIGRATION_SUFFIX).exists()); -} - -#[test] -fn raw_command_override_allows_migration_past_dangling_runtime() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("managed-agents.json"); - std::fs::write( - &path, - serde_json::to_vec(&serde_json::json!([ - { - "pubkey": "overridden-agent", - "runtime": "missing-custom-harness", - "agent_command_override": "/opt/custom/my-agent", - "provider": "openai", - "env_vars": { "OPENAI_COMPAT_API_KEY": "overridden-secret" } - }, - { - "pubkey": "unrelated-agent", - "provider": "openai", - "env_vars": { "OPENAI_COMPAT_API_KEY": "unrelated-secret" } - } - ])) - .unwrap(), - ) - .unwrap(); - - migrate_openai_credentials_in_file(&path, None, None, &std::collections::HashMap::new()) - .unwrap(); - let records: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); - - assert_eq!(records[0]["env_vars"][OPENAI_API_KEY], "overridden-secret"); - assert_eq!(records[1]["env_vars"][OPENAI_API_KEY], "unrelated-secret"); - assert!(sibling_path(&path, MIGRATION_SUFFIX).exists()); -} - -#[test] -fn global_credentials_ignore_preferred_runtime_harness_env() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("global-agent-config.json"); - std::fs::write( - &path, - serde_json::to_vec(&serde_json::json!({ - "preferred_runtime": "custom-gateway", - "provider": "openai", - "env_vars": { "OPENAI_COMPAT_API_KEY": "official-secret" } - })) - .unwrap(), - ) - .unwrap(); - let harnesses = custom_gateway_harnesses(); - - migrate_openai_credentials_in_file(&path, None, None, &harnesses).unwrap(); - let global: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); - - assert_eq!(global["provider"], "openai"); - assert_eq!(global["env_vars"][OPENAI_API_KEY], "official-secret"); - assert!(global["env_vars"].get(OPENAI_COMPAT_API_KEY).is_none()); -} - -#[test] -fn runtime_less_record_ignores_custom_preferred_runtime() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("managed-agents.json"); - std::fs::write( - &path, - serde_json::to_vec(&serde_json::json!([{ - "pubkey": "agent-pubkey", - "provider": "openai", - "env_vars": { "OPENAI_COMPAT_API_KEY": "official-secret" } - }])) - .unwrap(), - ) - .unwrap(); - let harnesses = custom_gateway_harnesses(); - - migrate_openai_credentials_in_file(&path, None, None, &harnesses).unwrap(); - let records: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); - - assert_eq!(records[0]["provider"], "openai"); - assert_eq!(records[0]["env_vars"][OPENAI_API_KEY], "official-secret"); -} - -#[test] -fn command_override_does_not_suppress_record_runtime_env() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("managed-agents.json"); - std::fs::write( - &path, - serde_json::to_vec(&serde_json::json!([{ - "pubkey": "agent-pubkey", - "runtime": "custom-gateway", - "agent_command_override": "buzz-agent", - "provider": "openai", - "env_vars": { "OPENAI_COMPAT_API_KEY": "official-secret" } - }])) - .unwrap(), - ) - .unwrap(); - let harnesses = custom_gateway_harnesses(); - - migrate_openai_credentials_in_file(&path, None, None, &harnesses).unwrap(); - let records: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); - - assert_eq!(records[0]["provider"], "openai-compat"); - assert_eq!( - records[0]["env_vars"][OPENAI_COMPAT_API_KEY], - "official-secret" - ); - assert!(records[0]["env_vars"].get(OPENAI_API_KEY).is_none()); -} - -#[test] -fn inherited_openai_is_materialized_when_local_endpoint_is_compat() { - let dir = tempfile::tempdir().unwrap(); - let global_path = dir.path().join("global-agent-config.json"); - let managed_path = dir.path().join("managed-agents.json"); - std::fs::write( - &global_path, - serde_json::to_vec(&serde_json::json!({ - "provider": "openai", - "env_vars": {} - })) - .unwrap(), - ) - .unwrap(); - std::fs::write( - &managed_path, - serde_json::to_vec(&serde_json::json!([{ - "pubkey": "agent-pubkey", - "agent_command_override": "buzz-agent", - "env_vars": { - "OPENAI_COMPAT_BASE_URL": "https://gateway.example/v1", - "OPENAI_COMPAT_API_KEY": "compat-secret" - } - }])) - .unwrap(), - ) - .unwrap(); - let harnesses = std::collections::HashMap::new(); - - migrate_openai_credentials_in_file(&global_path, None, None, &harnesses).unwrap(); - let global: Value = - serde_json::from_str(&std::fs::read_to_string(&global_path).unwrap()).unwrap(); - migrate_openai_credentials_in_file( - &managed_path, - global.get("provider").and_then(Value::as_str), - global.get("env_vars").and_then(Value::as_object), - &harnesses, - ) - .unwrap(); - let records: Value = - serde_json::from_str(&std::fs::read_to_string(&managed_path).unwrap()).unwrap(); - - assert_eq!(records[0]["provider"], "openai-compat"); - assert_eq!( - records[0]["env_vars"][OPENAI_COMPAT_API_KEY], - "compat-secret" + store.records[0].env_vars.get(OPENAI_API_KEY).unwrap(), + "official" ); assert_eq!( - records[0]["env_vars"][OPENAI_COMPAT_BASE_URL], - "https://gateway.example/v1" + store.records[0] + .env_vars + .get(OPENAI_COMPAT_API_KEY) + .unwrap(), + "compat" ); - assert!(records[0]["env_vars"].get(OPENAI_API_KEY).is_none()); - assert!(sibling_path(&managed_path, MIGRATION_SUFFIX).exists()); } #[test] -fn inherited_openai_is_materialized_on_providerless_definition() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("managed-agents.json"); - std::fs::write( - &path, - serde_json::to_vec(&serde_json::json!([ - { - "pubkey": "", - "slug": "shared-definition", - "env_vars": { - "OPENAI_COMPAT_BASE_URL": "https://gateway.example/v1", - "OPENAI_COMPAT_API_KEY": "definition-secret" - } - }, - { - "pubkey": "agent-pubkey", - "persona_id": "shared-definition", - "env_vars": {} - } - ])) - .unwrap(), - ) - .unwrap(); - - migrate_file(&path, Some("openai"), None).unwrap(); - let records: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); - - assert_eq!(records[0]["provider"], "openai-compat"); - assert_eq!( - records[0]["env_vars"][OPENAI_COMPAT_API_KEY], - "definition-secret" +fn mixed_global_provider_owner_is_rejected() { + let global = GlobalAgentConfig { + provider: Some("openai".into()), + ..Default::default() + }; + let mut official = record("official", "official"); + official + .env_vars + .insert(OPENAI_COMPAT_API_KEY.into(), "a".into()); + let mut custom = record("custom", "custom"); + custom + .env_vars + .insert(OPENAI_COMPAT_API_KEY.into(), "b".into()); + custom.env_vars.insert( + OPENAI_COMPAT_BASE_URL.into(), + "https://gateway.example/v1".into(), ); - assert!(records[1].get("provider").is_none()); - assert!(sibling_path(&path, MIGRATION_SUFFIX).exists()); + assert!(planned(global, vec![official, custom]) + .unwrap_err() + .contains("mixed official/custom")); } #[test] -fn linked_local_endpoint_conflict_is_left_unwritten_and_unmarked() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("managed-agents.json"); - let original = serde_json::to_vec(&serde_json::json!([ - { - "pubkey": "", - "slug": "shared-definition", - "provider": "openai", - "env_vars": {} - }, - { - "pubkey": "agent-pubkey", - "persona_id": "shared-definition", - "env_vars": { - "OPENAI_COMPAT_BASE_URL": "https://gateway.example/v1", - "OPENAI_COMPAT_API_KEY": "compat-secret" - } - } - ])) - .unwrap(); - std::fs::write(&path, &original).unwrap(); +fn mixed_global_credential_owner_is_rejected_even_for_unrelated_consumer() { + let global = GlobalAgentConfig { + provider: Some("openai".into()), + env_vars: BTreeMap::from([(OPENAI_COMPAT_API_KEY.into(), "shared".into())]), + ..Default::default() + }; + let official = record("official", "official"); + let mut unrelated = record("unrelated", "unrelated"); + unrelated.provider = Some("anthropic".into()); + assert!(planned(global, vec![official, unrelated]) + .unwrap_err() + .contains("shared with a non-official consumer")); +} - let error = migrate_file(&path, Some("openai"), None).unwrap_err(); +#[test] +fn linked_provider_snapshot_is_ignored_and_definition_is_mutated() { + let mut def = definition("shared"); + def.provider = Some("openai".into()); + def.env_vars.insert( + OPENAI_COMPAT_BASE_URL.into(), + "https://gateway.example/v1".into(), + ); + let mut instance = record("instance", "instance"); + instance.persona_id = Some("shared".into()); + instance.provider = Some("anthropic".into()); + instance + .env_vars + .insert(OPENAI_COMPAT_API_KEY.into(), "compat".into()); + let store = planned(GlobalAgentConfig::default(), vec![def, instance]).unwrap(); + assert_eq!(store.records[0].provider.as_deref(), Some("openai-compat")); + assert_eq!(store.records[1].provider.as_deref(), Some("anthropic")); +} +#[test] +fn linked_local_identity_that_definition_cannot_represent_is_rejected() { + let mut def = definition("shared"); + def.provider = Some("openai".into()); + let mut official = record("official", "official"); + official.persona_id = Some("shared".into()); + official + .env_vars + .insert(OPENAI_COMPAT_API_KEY.into(), "a".into()); + let mut custom = record("custom", "custom"); + custom.persona_id = Some("shared".into()); + custom + .env_vars + .insert(OPENAI_COMPAT_API_KEY.into(), "b".into()); + custom.env_vars.insert( + OPENAI_COMPAT_BASE_URL.into(), + "https://gateway.example/v1".into(), + ); assert!( - error.contains("cannot migrate a linked instance"), - "{error}" + planned(GlobalAgentConfig::default(), vec![def, official, custom]) + .unwrap_err() + .contains("mixed official/custom") ); - assert_eq!(std::fs::read(&path).unwrap(), original); - assert!(!sibling_path(&path, BACKUP_SUFFIX).exists()); - assert!(!sibling_path(&path, MIGRATION_SUFFIX).exists()); } #[test] -fn linked_instance_uses_definition_provider_and_layered_endpoint() { +fn first_fold_builtin_link_does_not_block_unrelated_official_consumer() { + let mut builtin = record("builtin", "builtin"); + builtin.persona_id = Some("builtin:fizz".into()); + let mut official = record("official", "official"); + official.provider = Some("openai".into()); + official + .env_vars + .insert(OPENAI_COMPAT_API_KEY.into(), "secret".into()); + let store = planned(GlobalAgentConfig::default(), vec![builtin, official]).unwrap(); + assert_eq!( + store.records[1] + .env_vars + .get(OPENAI_API_KEY) + .map(String::as_str), + Some("secret") + ); +} + +#[test] +fn shared_global_official_alias_with_non_openai_consumer_is_rejected() { + let global = GlobalAgentConfig { + provider: Some("openai".into()), + env_vars: BTreeMap::from([( + OPENAI_COMPAT_BASE_URL.into(), + "https://api.openai.com".into(), + )]), + ..Default::default() + }; + let official = record("official", "official"); + let mut unrelated = record("unrelated", "unrelated"); + unrelated.provider = Some("anthropic".into()); + assert!(planned(global, vec![official, unrelated]) + .unwrap_err() + .contains("endpoint owner")); +} + +#[test] +fn builtin_read_only_provider_is_accepted_when_identity_already_matches() { + let mut builtin = record("builtin", "builtin"); + builtin.persona_id = Some("builtin:fizz".into()); + assert!(planned(GlobalAgentConfig::default(), vec![builtin]).is_ok()); +} + +#[test] +fn canonical_local_shadow_is_preserved_over_inherited_custom_origin() { + let global = GlobalAgentConfig { + env_vars: BTreeMap::from([( + OPENAI_COMPAT_BASE_URL.into(), + "https://gateway.example/v1".into(), + )]), + ..Default::default() + }; + let mut agent = record("agent", "agent"); + agent.provider = Some("openai".into()); + agent.env_vars.insert( + OPENAI_COMPAT_BASE_URL.into(), + CANONICAL_OPENAI_ORIGIN.into(), + ); + agent + .env_vars + .insert(OPENAI_COMPAT_API_KEY.into(), "secret".into()); + let store = planned(global, vec![agent]).unwrap(); + assert_eq!( + store.records[0] + .env_vars + .get(OPENAI_COMPAT_BASE_URL) + .map(String::as_str), + Some(CANONICAL_OPENAI_ORIGIN) + ); +} + +#[test] +fn dangling_runtime_requires_command_override() { + let mut agent = record("agent", "agent"); + agent.provider = Some("openai".into()); + agent.runtime = Some("missing-custom-runtime".into()); + agent + .env_vars + .insert(OPENAI_COMPAT_API_KEY.into(), "secret".into()); + assert!(planned(GlobalAgentConfig::default(), vec![agent.clone()]) + .unwrap_err() + .contains("unresolved custom harness")); + agent.agent_command_override = Some("buzz-agent".into()); + assert!(planned(GlobalAgentConfig::default(), vec![agent]).is_ok()); +} + +#[test] +fn semantic_failure_creates_no_transaction_artifacts() { let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("managed-agents.json"); + let agents = dir.path(); + let mut agent = record("agent", "agent"); + agent.provider = Some("openai".into()); + agent + .env_vars + .insert(OPENAI_COMPAT_BASE_URL.into(), "broken".into()); std::fs::write( - &path, - serde_json::to_vec(&serde_json::json!([ - { - "pubkey": "", - "slug": "shared-definition", - "provider": "openai", - "env_vars": { - "OPENAI_COMPAT_API_KEY": "definition-compat-key", - "OPENAI_COMPAT_BASE_URL": "https://gateway.example/v1" - } - }, - { - "pubkey": "agent-pubkey", - "persona_id": "shared-definition", - "provider": "openai", - "env_vars": { "OPENAI_COMPAT_API_KEY": "instance-compat-key" } - } - ])) - .unwrap(), + agents.join("managed-agents.json"), + serde_json::to_vec(&vec![agent]).unwrap(), ) .unwrap(); - - migrate_file(&path, Some("openai"), None).unwrap(); - let records: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); - - assert_eq!(records[0]["provider"], "openai-compat"); - assert_eq!( - records[1]["provider"], "openai", - "linked instance provider is a stale snapshot and is not an ownership tier" - ); - assert_eq!( - records[1]["env_vars"][OPENAI_COMPAT_API_KEY], - "instance-compat-key" - ); - assert!(records[1]["env_vars"].get(OPENAI_API_KEY).is_none()); + assert!(migrate_pair(agents).is_err()); + assert!(!agents.join(MANIFEST_FILE).exists()); + assert!(!agents.join(MARKER_FILE).exists()); + assert!(!sibling_path(&agents.join("managed-agents.json"), BACKUP_SUFFIX).exists()); } #[test] -fn linked_instance_ignores_legacy_provider_snapshot_when_definition_uses_global() { +fn stale_backup_is_refused_before_manifest_or_config_write() { let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("managed-agents.json"); + let agents = dir.path(); + let mut agent = record("agent", "agent"); + agent.provider = Some("openai".into()); + agent + .env_vars + .insert(OPENAI_COMPAT_API_KEY.into(), "secret".into()); + let source = serde_json::to_vec(&vec![agent]).unwrap(); + let path = agents.join("managed-agents.json"); + std::fs::write(&path, &source).unwrap(); + std::fs::write(sibling_path(&path, BACKUP_SUFFIX), b"stale").unwrap(); + assert!(migrate_pair(agents) + .unwrap_err() + .contains("backup does not match pristine source")); + assert_eq!(std::fs::read(path).unwrap(), source); + assert!(!agents.join(MANIFEST_FILE).exists()); + assert!(!agents.join(MARKER_FILE).exists()); +} + +#[test] +fn transaction_writes_backups_manifest_targets_then_marker() { + let dir = tempfile::tempdir().unwrap(); + let agents = dir.path(); + let global = GlobalAgentConfig { + provider: Some("openai".into()), + ..Default::default() + }; + let mut agent = record("agent", "agent"); + agent + .env_vars + .insert(OPENAI_COMPAT_API_KEY.into(), "secret".into()); std::fs::write( - &path, - serde_json::to_vec(&serde_json::json!([ - { - "pubkey": "", - "slug": "shared-definition", - "provider": null, - "env_vars": {} - }, - { - "pubkey": "agent-pubkey", - "persona_id": "shared-definition", - "provider": "anthropic", - "env_vars": { "OPENAI_COMPAT_API_KEY": "legacy-official-key" } - } - ])) - .unwrap(), + agents.join("global-agent-config.json"), + serde_json::to_vec(&global).unwrap(), ) .unwrap(); - - migrate_file(&path, Some("openai"), None).unwrap(); - let records: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); - - assert_eq!(records[1]["provider"], "anthropic"); - assert_eq!( - records[1]["env_vars"][OPENAI_API_KEY], - "legacy-official-key" - ); - assert!(records[1]["env_vars"].get(OPENAI_COMPAT_API_KEY).is_none()); -} - -#[test] -fn array_migration_updates_every_record() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("managed-agents.json"); std::fs::write( - &path, - serde_json::to_vec(&serde_json::json!([ - { - "provider": "openai", - "env_vars": { "OPENAI_COMPAT_API_KEY": "first" } - }, - { - "provider": "openai", - "env_vars": { "OPENAI_COMPAT_API_KEY": "second" } - } - ])) - .unwrap(), + agents.join("managed-agents.json"), + serde_json::to_vec(&vec![agent]).unwrap(), ) .unwrap(); - - migrate_file(&path, None, None).unwrap(); - let records: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); - assert_eq!(records[0]["env_vars"][OPENAI_API_KEY], "first"); - assert_eq!(records[1]["env_vars"][OPENAI_API_KEY], "second"); + migrate_pair(agents).unwrap(); + assert!(agents.join(MANIFEST_FILE).exists()); + assert!(agents.join(MARKER_FILE).exists()); + assert!(sibling_path(&agents.join("global-agent-config.json"), BACKUP_SUFFIX).exists()); + assert!(sibling_path(&agents.join("managed-agents.json"), BACKUP_SUFFIX).exists()); + let reread: Vec = + serde_json::from_slice(&std::fs::read(agents.join("managed-agents.json")).unwrap()) + .unwrap(); + assert_eq!( + reread[0].env_vars.get(OPENAI_API_KEY).map(String::as_str), + Some("secret") + ); } #[test] -fn migration_marker_preserves_post_upgrade_compat_credentials() { +fn marker_write_failure_leaves_verified_targets_retryable() { let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("managed-agents.json"); + let agents = dir.path(); + let mut agent = record("agent", "agent"); + agent.provider = Some("openai".into()); + agent + .env_vars + .insert(OPENAI_COMPAT_API_KEY.into(), "secret".into()); std::fs::write( - &path, - serde_json::to_vec(&serde_json::json!([{ - "provider": "openai", - "env_vars": { "OPENAI_COMPAT_API_KEY": "legacy-secret" } - }])) - .unwrap(), + agents.join("managed-agents.json"), + serde_json::to_vec(&vec![agent]).unwrap(), ) .unwrap(); + std::fs::create_dir(agents.join(MARKER_FILE)).unwrap(); - migrate_file(&path, None, None).unwrap(); - let mut records: Value = - serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); - records[0]["env_vars"][OPENAI_COMPAT_API_KEY] = Value::String("new-compat".to_string()); - std::fs::write(&path, serde_json::to_vec(&records).unwrap()).unwrap(); - - migrate_file(&path, None, None).unwrap(); - let records: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); - assert_eq!(records[0]["env_vars"][OPENAI_API_KEY], "legacy-secret"); - assert_eq!(records[0]["env_vars"][OPENAI_COMPAT_API_KEY], "new-compat"); -} - -#[test] -fn changed_file_gets_pristine_backup_before_marker() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("global-agent-config.json"); - let original = serde_json::json!({ - "provider": "openai", - "env_vars": { "OPENAI_COMPAT_API_KEY": "official-secret" } - }); - let original_bytes = serde_json::to_vec(&original).unwrap(); - std::fs::write(&path, &original_bytes).unwrap(); - - migrate_file(&path, None, None).unwrap(); - + assert!(migrate_pair(agents).is_err()); + assert!(agents.join(MANIFEST_FILE).exists()); + let target: Vec = + serde_json::from_slice(&std::fs::read(agents.join("managed-agents.json")).unwrap()) + .unwrap(); assert_eq!( - std::fs::read(sibling_path(&path, BACKUP_SUFFIX)).unwrap(), - original_bytes + target[0].env_vars.get(OPENAI_API_KEY).map(String::as_str), + Some("secret") ); - assert_eq!( - std::fs::read_to_string(sibling_path(&path, MIGRATION_SUFFIX)).unwrap(), - "1\n" - ); - let migrated: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); - assert_eq!(migrated["env_vars"][OPENAI_API_KEY], "official-secret"); + + std::fs::remove_dir(agents.join(MARKER_FILE)).unwrap(); + migrate_pair(agents).unwrap(); + assert_eq!(std::fs::read(agents.join(MARKER_FILE)).unwrap(), b"1\n"); } #[test] -fn malformed_file_is_preserved_and_not_marked() { +fn malformed_marker_does_not_suppress_retry() { let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("managed-agents.json"); - let original = b"{ not valid json"; - std::fs::write(&path, original).unwrap(); - - let error = migrate_file(&path, None, None).unwrap_err(); - - assert!(error.contains("failed to parse"), "{error}"); - assert_eq!(std::fs::read(&path).unwrap(), original); - assert!(!sibling_path(&path, BACKUP_SUFFIX).exists()); - assert!(!sibling_path(&path, MIGRATION_SUFFIX).exists()); + let agents = dir.path(); + let mut agent = record("agent", "agent"); + agent.provider = Some("openai".into()); + agent + .env_vars + .insert(OPENAI_COMPAT_API_KEY.into(), "secret".into()); + std::fs::write( + agents.join("managed-agents.json"), + serde_json::to_vec(&vec![agent]).unwrap(), + ) + .unwrap(); + std::fs::write(agents.join(MARKER_FILE), b"").unwrap(); + migrate_pair(agents).unwrap(); + assert_eq!(std::fs::read(agents.join(MARKER_FILE)).unwrap(), b"1\n"); } #[test] -fn absent_file_is_not_marked_so_later_legacy_content_can_be_migrated() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("global-agent-config.json"); - migrate_file(&path, None, None).unwrap(); - assert!(!sibling_path(&path, MIGRATION_SUFFIX).exists()); - let legacy = serde_json::json!({ - "provider": "openai", - "env_vars": { "OPENAI_COMPAT_API_KEY": "official-secret" } - }); - std::fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap(); +fn post_write_verification_rejects_hash_mismatch() { + let mut agent = record("agent", "agent"); + agent.provider = Some("openai".into()); + agent + .env_vars + .insert(OPENAI_COMPAT_API_KEY.into(), "secret".into()); + let source = serde_json::to_vec(&vec![agent]).unwrap(); + let plan = make_plan(None, Some(&source)).unwrap(); + let expected = TransactionManifest { + global: FileHashes { + source: None, + target: hash(plan.global.as_deref()), + }, + managed: FileHashes { + source: hash(Some(&source)), + target: hash(plan.managed.as_deref()), + }, + }; - migrate_file(&path, None, None).unwrap(); - let after: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); - assert_eq!(after["env_vars"][OPENAI_API_KEY], "official-secret"); - assert!(after["env_vars"].get(OPENAI_COMPAT_API_KEY).is_none()); + let err = verify_written_targets(&expected, &plan.consumers, None, Some(b"[]")).unwrap_err(); + assert!(err.contains("target hash mismatch")); +} + +#[test] +fn partial_retry_finishes_expected_target_but_refuses_external_edit() { + let dir = tempfile::tempdir().unwrap(); + let agents = dir.path(); + let global_bytes = serde_json::to_vec(&GlobalAgentConfig { + provider: Some("openai".into()), + ..Default::default() + }) + .unwrap(); + let mut agent = record("agent", "agent"); + agent + .env_vars + .insert(OPENAI_COMPAT_API_KEY.into(), "secret".into()); + let managed_bytes = serde_json::to_vec(&vec![agent]).unwrap(); + let plan = make_plan(Some(&global_bytes), Some(&managed_bytes)).unwrap(); + std::fs::write( + agents.join("global-agent-config.json"), + plan.global.as_ref().unwrap(), + ) + .unwrap(); + std::fs::write(agents.join("managed-agents.json"), &managed_bytes).unwrap(); + std::fs::write( + sibling_path(&agents.join("global-agent-config.json"), BACKUP_SUFFIX), + &global_bytes, + ) + .unwrap(); + std::fs::write( + sibling_path(&agents.join("managed-agents.json"), BACKUP_SUFFIX), + &managed_bytes, + ) + .unwrap(); + let manifest = TransactionManifest { + global: FileHashes { + source: hash(Some(&global_bytes)), + target: hash(plan.global.as_deref()), + }, + managed: FileHashes { + source: hash(Some(&managed_bytes)), + target: hash(plan.managed.as_deref()), + }, + }; + std::fs::write( + agents.join(MANIFEST_FILE), + serde_json::to_vec(&manifest).unwrap(), + ) + .unwrap(); + migrate_pair(agents).unwrap(); + assert!(agents.join(MARKER_FILE).exists()); + + std::fs::remove_file(agents.join(MARKER_FILE)).unwrap(); + std::fs::write(agents.join("managed-agents.json"), b"[]").unwrap(); + assert!(migrate_pair(agents).unwrap_err().contains("external edit")); }