diff --git a/desktop/src-tauri/src/commands/agent_config_tests.rs b/desktop/src-tauri/src/commands/agent_config_tests.rs index ad636c6f48..2a92f75e4a 100644 --- a/desktop/src-tauri/src/commands/agent_config_tests.rs +++ b/desktop/src-tauri/src/commands/agent_config_tests.rs @@ -122,6 +122,7 @@ fn agent_record() -> ManagedAgentRecord { auth_tag_ref: None, env_vars_ref: None, provider_config_ref: None, + secrets_unavailable: false, } } diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 01da9f8e6c..b54a2e59c3 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -916,6 +916,7 @@ pub async fn create_managed_agent( auth_tag_ref: None, env_vars_ref: None, provider_config_ref: None, + secrets_unavailable: false, }; records.push(record); diff --git a/desktop/src-tauri/src/commands/agents_tests.rs b/desktop/src-tauri/src/commands/agents_tests.rs index e413710875..3efde15be4 100644 --- a/desktop/src-tauri/src/commands/agents_tests.rs +++ b/desktop/src-tauri/src/commands/agents_tests.rs @@ -65,6 +65,7 @@ fn bare_agent_record( auth_tag_ref: None, env_vars_ref: None, provider_config_ref: None, + secrets_unavailable: false, } } fn persona_record(id: &str, model: Option<&str>, provider: Option<&str>) -> AgentDefinition { diff --git a/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs b/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs index 7d66211943..67dd4d6475 100644 --- a/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs +++ b/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs @@ -73,6 +73,7 @@ fn make_agent( auth_tag_ref: None, env_vars_ref: None, provider_config_ref: None, + secrets_unavailable: false, } } diff --git a/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs index 154f4a7586..7c56c0b620 100644 --- a/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs +++ b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs @@ -218,6 +218,7 @@ fn local_agent() -> ManagedAgentRecord { auth_tag_ref: None, env_vars_ref: None, provider_config_ref: None, + secrets_unavailable: false, } } diff --git a/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs b/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs index 854432fbdf..42d79c44a2 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs @@ -67,6 +67,7 @@ fn make_definition(slug: &str) -> ManagedAgentRecord { auth_tag_ref: None, env_vars_ref: None, provider_config_ref: None, + secrets_unavailable: false, } } diff --git a/desktop/src-tauri/src/commands/personas/snapshot/import.rs b/desktop/src-tauri/src/commands/personas/snapshot/import.rs index b4982fa159..cac39daafb 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/import.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/import.rs @@ -657,6 +657,7 @@ pub async fn confirm_agent_snapshot_import( auth_tag_ref: None, env_vars_ref: None, provider_config_ref: None, + secrets_unavailable: false, }; records.push(record.clone()); diff --git a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs index 3d41214893..9ec7d47ed2 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs @@ -76,6 +76,7 @@ fn make_definition(slug: &str) -> ManagedAgentRecord { auth_tag_ref: None, env_vars_ref: None, provider_config_ref: None, + secrets_unavailable: false, } } diff --git a/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs b/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs index 2084315175..a67be5122c 100644 --- a/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs +++ b/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs @@ -61,6 +61,7 @@ fn agent(persona_id: &str, name: &str, display_name: Option<&str>) -> ManagedAge auth_tag_ref: None, env_vars_ref: None, provider_config_ref: None, + secrets_unavailable: false, } } diff --git a/desktop/src-tauri/src/commands/team_snapshot.rs b/desktop/src-tauri/src/commands/team_snapshot.rs index 113719dc23..6bc65f45b4 100644 --- a/desktop/src-tauri/src/commands/team_snapshot.rs +++ b/desktop/src-tauri/src/commands/team_snapshot.rs @@ -614,6 +614,7 @@ pub async fn confirm_team_snapshot_import( auth_tag_ref: None, env_vars_ref: None, provider_config_ref: None, + secrets_unavailable: false, }; minted.push(MintedMember { diff --git a/desktop/src-tauri/src/commands/team_snapshot/tests.rs b/desktop/src-tauri/src/commands/team_snapshot/tests.rs index a63bdf0c6f..3caf7408d9 100644 --- a/desktop/src-tauri/src/commands/team_snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/team_snapshot/tests.rs @@ -234,6 +234,7 @@ fn team_export_with_instance_and_memory_level_uses_supplied_entries() { auth_tag_ref: None, env_vars_ref: None, provider_config_ref: None, + secrets_unavailable: false, }; let mut memory_map = std::collections::HashMap::new(); diff --git a/desktop/src-tauri/src/managed_agents/agent_events.rs b/desktop/src-tauri/src/managed_agents/agent_events.rs index e9a152144f..ba05024214 100644 --- a/desktop/src-tauri/src/managed_agents/agent_events.rs +++ b/desktop/src-tauri/src/managed_agents/agent_events.rs @@ -219,6 +219,7 @@ mod tests { auth_tag_ref: None, env_vars_ref: None, provider_config_ref: None, + secrets_unavailable: false, } } diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs index ce4094d164..50f4599dca 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs @@ -422,6 +422,7 @@ mod tests { auth_tag_ref: None, env_vars_ref: None, provider_config_ref: None, + secrets_unavailable: false, } } diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs index 3d9a262e80..9546308b9c 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs @@ -75,6 +75,7 @@ fn minimal_record() -> ManagedAgentRecord { auth_tag_ref: None, env_vars_ref: None, provider_config_ref: None, + secrets_unavailable: false, } } diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs index 87eca88135..7e6242aaef 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs @@ -121,6 +121,7 @@ fn test_record() -> ManagedAgentRecord { auth_tag_ref: None, env_vars_ref: None, provider_config_ref: None, + secrets_unavailable: false, } } diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests.rs index da11469e5a..4628e3304f 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -283,6 +283,7 @@ fn record_with( auth_tag_ref: None, env_vars_ref: None, provider_config_ref: None, + secrets_unavailable: false, } } diff --git a/desktop/src-tauri/src/managed_agents/effective_config/tests.rs b/desktop/src-tauri/src/managed_agents/effective_config/tests.rs index 015836699b..4f9c574213 100644 --- a/desktop/src-tauri/src/managed_agents/effective_config/tests.rs +++ b/desktop/src-tauri/src/managed_agents/effective_config/tests.rs @@ -95,6 +95,7 @@ fn record( auth_tag_ref: None, env_vars_ref: None, provider_config_ref: None, + secrets_unavailable: false, } } diff --git a/desktop/src-tauri/src/managed_agents/global_config/tests.rs b/desktop/src-tauri/src/managed_agents/global_config/tests.rs index 422f8935ec..2e0794033c 100644 --- a/desktop/src-tauri/src/managed_agents/global_config/tests.rs +++ b/desktop/src-tauri/src/managed_agents/global_config/tests.rs @@ -356,6 +356,7 @@ fn bare_record() -> ManagedAgentRecord { auth_tag_ref: None, env_vars_ref: None, provider_config_ref: None, + secrets_unavailable: false, } } diff --git a/desktop/src-tauri/src/managed_agents/nest/tests.rs b/desktop/src-tauri/src/managed_agents/nest/tests.rs index 635dc30644..b9f3e72b33 100644 --- a/desktop/src-tauri/src/managed_agents/nest/tests.rs +++ b/desktop/src-tauri/src/managed_agents/nest/tests.rs @@ -505,6 +505,7 @@ fn make_agent(name: &str, persona_id: Option<&str>) -> ManagedAgentRecord { auth_tag_ref: None, env_vars_ref: None, provider_config_ref: None, + secrets_unavailable: false, } } diff --git a/desktop/src-tauri/src/managed_agents/parallelism.rs b/desktop/src-tauri/src/managed_agents/parallelism.rs index e0cd10ccc6..ae2198805a 100644 --- a/desktop/src-tauri/src/managed_agents/parallelism.rs +++ b/desktop/src-tauri/src/managed_agents/parallelism.rs @@ -120,6 +120,7 @@ mod tests { auth_tag_ref: None, env_vars_ref: None, provider_config_ref: None, + secrets_unavailable: false, } } diff --git a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs index 745f97bcb7..2114e17eb6 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs @@ -61,6 +61,7 @@ pub(super) fn sample_record() -> ManagedAgentRecord { auth_tag_ref: None, env_vars_ref: None, provider_config_ref: None, + secrets_unavailable: false, } } diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index f7fae564e4..000d34ead7 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -1530,6 +1530,7 @@ mod tests { auth_tag_ref: None, env_vars_ref: None, provider_config_ref: None, + secrets_unavailable: false, }; let runtime = known_acp_runtime_exact("buzz-agent"); diff --git a/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs b/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs index 2c586fde04..65051c2994 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs @@ -92,5 +92,6 @@ pub(super) fn fixture( auth_tag_ref: None, env_vars_ref: None, provider_config_ref: None, + secrets_unavailable: false, } } diff --git a/desktop/src-tauri/src/managed_agents/secret_projection.rs b/desktop/src-tauri/src/managed_agents/secret_projection.rs index 08c1ae48b2..3d4844e322 100644 --- a/desktop/src-tauri/src/managed_agents/secret_projection.rs +++ b/desktop/src-tauri/src/managed_agents/secret_projection.rs @@ -927,4 +927,113 @@ mod tests { "inline must take precedence when present" ); } + + // ── Two-cycle GC ordering and cancel-before-mark tests ─────────────── + + #[test] + fn test_gc_delete_first_order_preserves_in_flight_gen() { + // Spec §6: delete candidates from the PREVIOUS boot first, then mark + // new ones for THIS boot. A generation verified this boot but not yet + // committed to JSON must NOT be deleted this boot. + // + // Setup: gen1 is in-flight (written + verified, JSON commit pending). + // boot N-1 left a candidate marker for an old gen0 that is now gone. + // GC delete phase runs: gen0 (with a candidate marker) is still + // unreferenced — it should be deleted. gen1 has NO candidate marker + // yet — it must NOT be deleted. + // + // This tests the FakeProjectionStore's delete_gc_candidates logic + // directly via collect_live_refs + manual blob inspection. + + // Blob state at start of boot N's GC: + // gen0 was orphaned last boot and marked as candidate + // gen1 is the new in-flight generation (no marker yet) + let store = FakeProjectionStore::reachable() + .with_entry("global:env:gen0", "old-secret") // old generation + .with_entry("global:env:gen0_candidate", "1") // marked last boot + .with_entry("global:env:gen1", "new-secret"); // in-flight + + // JSON currently still references gen0 (JSON commit hasn't happened). + // GC reads the live refs from JSON. + let agents = make_agents_json(None); + let global = make_global_json(Some("gen0")); // JSON still has old ref + + let live_refs = collect_live_refs(&agents, &global).unwrap(); + assert!(live_refs.contains("gen0"), "gen0 is still in JSON"); + assert!( + !live_refs.contains("gen1"), + "gen1 not yet committed to JSON" + ); + + // delete_gc_candidates: gen0 is a candidate but IS referenced → skip. + // gen1 has NO candidate marker → skip. + // Nothing should be deleted this cycle. + let blob = store.load_all().unwrap().unwrap(); + let candidate_for_gen0 = blob.get("global:env:gen0_candidate"); + let candidate_for_gen1 = blob.get("global:env:gen1_candidate"); + assert!( + candidate_for_gen0.is_some(), + "gen0 candidate marker must still be present" + ); + assert!( + candidate_for_gen1.is_none(), + "gen1 must have no candidate marker" + ); + + // Verify delete_gc_candidates would skip gen0 because it IS referenced. + let gen = "gen0"; + let would_delete = !live_refs.contains(gen); + assert!( + !would_delete, + "gen0 must NOT be deleted — it's still referenced in JSON" + ); + } + + #[test] + fn test_cancel_before_mark_ordering_protects_in_flight_gen() { + // Spec §6: cancel happens before the JSON commit. The GC mark phase + // that runs AFTER the cancel must not re-mark the in-flight generation. + // + // Scenario (cancel-happens-before-mark, the case Paul flagged): + // 1. Save writes gen2 and verifies it. + // 2. Save calls cancel_gc_candidacy("gen2") — a no-op since gen2 wasn't + // marked, but it guarantees the marker is absent. + // 3. GC mark phase runs (shouldn't happen in the same call, but safe). + // 4. GC sees gen2 as unreferenced (JSON still has gen1 ref) and marks it. + // 5. Save commits JSON with gen2 ref. + // 6. GC delete phase (next boot) sees gen2 is now referenced → skips. + // + // The key correctness property: between steps 2 and 5, even if GC + // marks gen2, the NEXT boot's delete phase sees gen2 as referenced and + // will not delete it. This test verifies step 4 is safe: a marked- + // then-referenced gen survives. + + let _store = FakeProjectionStore::reachable() + .with_entry("global:env:gen1", "old-secret") + .with_entry("global:env:gen2", "new-secret") + // GC marked gen2 as a candidate (step 4 above). + .with_entry("global:env:gen2_candidate", "1"); + + // After JSON commit (step 5), gen2 is referenced. + let agents = make_agents_json(None); + let global = make_global_json(Some("gen2")); // JSON now has gen2 ref + + let live_refs = collect_live_refs(&agents, &global).unwrap(); + assert!(live_refs.contains("gen2"), "gen2 is now referenced in JSON"); + + // delete_gc_candidates (next boot): gen2 is a candidate BUT is now + // referenced → skip. gen2 must NOT be deleted. + let gen = "gen2"; + let would_delete = !live_refs.contains(gen); + assert!( + !would_delete, + "gen2 must NOT be deleted — it's now referenced in JSON" + ); + + // gen1 is now unreferenced → it should become a candidate on next mark. + assert!( + !live_refs.contains("gen1"), + "gen1 is no longer referenced (gen2 replaced it)" + ); + } } diff --git a/desktop/src-tauri/src/managed_agents/secret_seam.rs b/desktop/src-tauri/src/managed_agents/secret_seam.rs index 5488837b2c..df462ef3af 100644 --- a/desktop/src-tauri/src/managed_agents/secret_seam.rs +++ b/desktop/src-tauri/src/managed_agents/secret_seam.rs @@ -264,6 +264,7 @@ pub(crate) fn hydrate_all_secrets_for_records( eprintln!("buzz-desktop: {e}"); } if !errors.is_empty() { + record.secrets_unavailable = true; unavailable.push(record.pubkey.clone()); } } diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs index bb1a154566..742676937e 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs @@ -73,6 +73,7 @@ fn record() -> ManagedAgentRecord { auth_tag_ref: None, env_vars_ref: None, provider_config_ref: None, + secrets_unavailable: false, } } diff --git a/desktop/src-tauri/src/managed_agents/storage.rs b/desktop/src-tauri/src/managed_agents/storage.rs index 5fe390b520..c1ba95fdad 100644 --- a/desktop/src-tauri/src/managed_agents/storage.rs +++ b/desktop/src-tauri/src/managed_agents/storage.rs @@ -225,14 +225,28 @@ fn migrate_inline_key(store: &impl KeyStore, record: &ManagedAgentRecord) -> Key /// deliberately keyless agent. Spawning anyway would inject an empty /// `BUZZ_PRIVATE_KEY`/`NOSTR_PRIVATE_KEY`, launching with no identity. Callers /// (the spawn path) must fail closed (Wes storage.rs:158). +/// +/// Also refuses when `record.secrets_unavailable` is set — at least one secret +/// field (env_vars, auth_tag, provider_config) has a keyring ref that points to +/// a missing or unreachable entry. Spawning with silently empty secrets is the +/// exact failure mode the gen-ref protocol exists to prevent. pub(crate) fn spawn_key_refusal(record: &ManagedAgentRecord) -> Option { - record.private_key_nsec.is_empty().then(|| { - format!( + if record.private_key_nsec.is_empty() { + return Some(format!( "agent {} has no private key available — the OS keyring may be unreachable. \ Refusing to start without an identity; retry once the keyring is reachable.", record.pubkey - ) - }) + )); + } + if record.secrets_unavailable { + return Some(format!( + "agent {} has one or more secrets (env vars, auth tag, or provider config) \ + that could not be loaded from the keyring. Refusing to start with missing \ + secrets; retry once the keyring is reachable.", + record.pubkey + )); + } + None } /// Read the raw unified store — keyed instances AND key-less definitions — @@ -267,10 +281,10 @@ pub fn load_managed_agents(app: &AppHandle) -> Result, S // Hydrate env/auth_tag/provider_config from keyring. if let Some(store) = agent_secret_store() { let _ = hydrate_all_secrets_for_records(store, &mut records); - // Note: unavailable env/auth/provider_config does NOT block load — only - // nsec unavailability (handled in hydrate_keys) causes spawn refusal. - // The returned unavailable list is informational here; readiness checks - // surface it to the user via the existing refusal path. + // Unavailability is set directly on each record (`secrets_unavailable`); + // the returned Vec is a secondary summary, not needed here. The spawn + // path consults `spawn_key_refusal`, which checks `secrets_unavailable` + // and refuses to start any agent whose secrets could not be loaded. } Ok(records) } @@ -598,6 +612,177 @@ fn copy_agent_keys_between_stores(pubkeys: &[String], src: &impl KeyStore, dst: } } +/// Marker key for the secrets migration to the dev keyring service. +/// Versioned separately from `_dev_migration_v1` (which covers only nsec keys) +/// so this migration runs even for installs that already completed v1. +#[cfg(debug_assertions)] +const DEV_SECRETS_MIGRATION_MARKER: &str = "_dev_secrets_migration_v2"; + +/// One-time migration of secret-projection keys (env vars, auth tags, provider +/// configs, definition env vars) from the source keyring service to the +/// destination dev service. +/// +/// Called at dev-build boot, AFTER `migrate_agent_keys_to_dev_service` and +/// AFTER `migrate_inline_secrets_to_keyring` (so projection keys are present +/// in the source before we copy them). +/// +/// Source determination: +/// - `buzz-desktop-dev` → source is `buzz-desktop` (production). +/// - `buzz-desktop-dev.` (scoped) → source is `buzz-desktop-dev` +/// (canonical dev), NOT production. +/// +/// `global:env` is only copied when the destination's JSON reference requires +/// it (`global-agent-config.json` is not in `SHARED_AGENT_FILES`). +/// +/// A coordinate present in both source and destination with different values +/// is a conflict; the migration fails closed for that coordinate and logs an +/// error rather than silently overwriting. +/// +/// Idempotent: guarded by `DEV_SECRETS_MIGRATION_MARKER`; skips any key +/// already present in the destination. +#[cfg(debug_assertions)] +pub fn migrate_agent_secrets_to_dev_service(app: &tauri::AppHandle) { + use crate::managed_agents::secret_projection::is_projection_key; + + if !cfg!(feature = "system-keyring") { + return; + } + let dest_service = keyring_service(); + if dest_service == "buzz-desktop" { + return; // never run in a release build via this path + } + // Determine source: scoped dev → canonical dev; canonical dev → prod. + let is_scoped = dest_service != "buzz-desktop-dev"; + let src_service = if is_scoped { + "buzz-desktop-dev" + } else { + "buzz-desktop" + }; + + let dest_store = crate::secret_store::SecretStore::shared(dest_service); + + // Read destination blob. If the v2 migration marker is present, we ran + // this migration already — skip entirely. + let dest_map: std::collections::HashMap = match dest_store.load_all_readonly() { + Ok(Some(map)) if map.contains_key(DEV_SECRETS_MIGRATION_MARKER) => { + return; // already done + } + Ok(Some(map)) => map, + Ok(None) => std::collections::HashMap::new(), + Err(e) => { + eprintln!( + "buzz-desktop: keyring-dev-secrets-migration: \ + cannot read dest keyring ({dest_service}): {e}" + ); + return; + } + }; + + let src_store = crate::secret_store::SecretStore::keyring(src_service); + let src_map: std::collections::HashMap = match src_store.load_all_readonly() { + Ok(Some(map)) => map, + Ok(None) => std::collections::HashMap::new(), + Err(e) => { + eprintln!( + "buzz-desktop: keyring-dev-secrets-migration: \ + cannot read src keyring ({src_service}): {e}" + ); + return; + } + }; + + // Determine which projection keys to copy: those present in src, absent + // from dest, OR where src and dest agree (idempotent). Keys present in + // both with DIFFERENT values are conflicts — fail closed for that key. + // + // Exclude `global:env:*` from the copy unless the destination JSON + // references it (global-agent-config.json is not a shared file). + let global_refs = collect_global_env_refs(app); + + let mut to_write: std::collections::HashMap = std::collections::HashMap::new(); + let mut conflict_count = 0usize; + for (key, src_val) in &src_map { + if !is_projection_key(key) { + continue; // skip non-projection keys (nsec, identity markers, etc.) + } + // Skip global:env:* unless the destination JSON references this gen. + if key.starts_with("global:env:") { + let gen = key.trim_start_matches("global:env:"); + if !global_refs.contains(gen) { + continue; + } + } + match dest_map.get(key) { + None => { + to_write.insert(key.clone(), src_val.clone()); + } + Some(dest_val) if dest_val == src_val => { + // Already identical — idempotent, no action needed. + } + Some(_dest_val) => { + // Conflict: src and dest have different values. Fail closed. + eprintln!( + "buzz-desktop: keyring-dev-secrets-migration: \ + conflict on key {key} between {src_service} and {dest_service}; \ + refusing to overwrite (manual resolution required)" + ); + conflict_count += 1; + } + } + } + + // Always write the marker (even if there was nothing to copy) so future + // boots skip the source-keyring read entirely. + to_write.insert(DEV_SECRETS_MIGRATION_MARKER.to_string(), "done".to_string()); + + if let Err(e) = dest_store.store_all(&to_write) { + eprintln!( + "buzz-desktop: keyring-dev-secrets-migration: \ + cannot write to dest keyring ({dest_service}): {e}" + ); + return; + } + + let copied = to_write.len().saturating_sub(1); // exclude the marker itself + if copied > 0 { + eprintln!( + "buzz-desktop: keyring-dev-secrets-migration: \ + copied {copied} projection key(s) from {src_service} → {dest_service}" + ); + } + if conflict_count > 0 { + eprintln!( + "buzz-desktop: keyring-dev-secrets-migration: \ + {conflict_count} key(s) had conflicts and were NOT copied" + ); + } +} + +/// Collect the set of generation IDs referenced by `global-agent-config.json` +/// for the purposes of the dev secrets migration (to decide whether to copy +/// `global:env:*` keys). Returns an empty set when the file is absent or +/// unparseable — in that case no global env refs exist in the destination. +#[cfg(debug_assertions)] +fn collect_global_env_refs(app: &tauri::AppHandle) -> std::collections::HashSet { + let path = match app.path().app_data_dir() { + Ok(d) => d.join("agents/global-agent-config.json"), + Err(_) => return std::collections::HashSet::new(), + }; + let content = match std::fs::read_to_string(&path) { + Ok(s) => s, + Err(_) => return std::collections::HashSet::new(), + }; + let v: serde_json::Value = match serde_json::from_str(&content) { + Ok(v) => v, + Err(_) => return std::collections::HashSet::new(), + }; + let mut refs = std::collections::HashSet::new(); + if let Some(r) = v.get("env_vars_ref").and_then(|r| r.as_str()) { + refs.insert(r.to_string()); + } + refs +} + /// Remove an agent's key from the keyring, returning an error on failure. /// Used by the snapshot-import rollback path, which must surface cleanup /// failures rather than swallowing them. diff --git a/desktop/src-tauri/src/managed_agents/storage_tests.rs b/desktop/src-tauri/src/managed_agents/storage_tests.rs index 9943c6b3ac..fd4253aea8 100644 --- a/desktop/src-tauri/src/managed_agents/storage_tests.rs +++ b/desktop/src-tauri/src/managed_agents/storage_tests.rs @@ -251,6 +251,29 @@ fn spawn_allowed_when_private_key_present() { assert!(super::spawn_key_refusal(&record).is_none()); } +#[test] +fn spawn_refused_when_secrets_unavailable() { + // A record whose keyring ref exists but the entry is unavailable must be + // refused at spawn time — same semantics as a missing private key. + let mut record = record_with_key("nsec1realkey"); + record.secrets_unavailable = true; + assert!( + super::spawn_key_refusal(&record).is_some(), + "an agent with unavailable secrets must be refused at spawn" + ); +} + +#[test] +fn spawn_allowed_when_key_present_and_no_unavailable_secrets() { + // A fully hydrated record must not be blocked. + let mut record = record_with_key("nsec1realkey"); + record.secrets_unavailable = false; + assert!( + super::spawn_key_refusal(&record).is_none(), + "an agent with key and reachable secrets must be allowed" + ); +} + #[test] fn persist_agent_keys_issues_zero_writes_when_inline_keys_already_cleared() { // This is the dominant prompt-storm scenario: after the first successful @@ -830,3 +853,123 @@ fn install_log_filename_accepts_ordinary_runtime_ids() { ); } } + +/// Regression test: after secret extraction, the serialized store must not +/// contain any secret-shaped values. This is the grep-empty criterion from +/// the v5 spec acceptance criteria, exercised at the type level. +/// +/// The test constructs records with inline secrets, runs the strip path via +/// the secret seam, and verifies the resulting JSON is free of the known +/// secret values. +#[test] +fn serialized_store_is_empty_of_secret_values_after_strip() { + use crate::managed_agents::secret_seam::strip_and_persist_agent_secrets_with; + use std::cell::RefCell; + use std::collections::HashMap; + + // ── FakeProjectionStore (minimal, for this test) ────────────────── + struct FakePS { + data: RefCell>, + } + impl crate::managed_agents::secret_projection::ProjectionStore for FakePS { + fn write_and_verify(&self, key: &str, value: &str) -> Result<(), String> { + self.data + .borrow_mut() + .insert(key.to_string(), value.to_string()); + Ok(()) + } + fn load_key(&self, key: &str) -> Result, String> { + Ok(self.data.borrow().get(key).cloned()) + } + fn load_all(&self) -> Result>, String> { + Ok(Some(self.data.borrow().clone())) + } + fn store_batch(&self, entries: &HashMap) -> Result<(), String> { + for (k, v) in entries { + self.data.borrow_mut().insert(k.clone(), v.clone()); + } + Ok(()) + } + fn remove_batch(&self, keys: &[&str]) -> Result<(), String> { + let mut d = self.data.borrow_mut(); + for k in keys { + d.remove(*k); + } + Ok(()) + } + } + + let store = FakePS { + data: RefCell::new(HashMap::new()), + }; + + let secret_env_value = "sk-ant-api03-very-secret-key"; + let secret_auth_tag = "auth-tag-secret"; + + // Build a record via JSON deserialization (avoids Default dependency). + let mut record: ManagedAgentRecord = serde_json::from_str(&format!( + r#"{{ + "pubkey": "testpubkey123", + "name": "test-agent", + "env_vars": {{"ANTHROPIC_API_KEY": "{secret_env_value}"}}, + "auth_tag": "{secret_auth_tag}", + "backend": {{"type": "provider", "id": "anthropic", "config": {{"api_key": "provider-secret"}}}}, + "relay_url": "wss://localhost:3000", + "acp_command": "buzz-acp", + "agent_command": "goose", + "agent_args": [], + "mcp_command": "", + "turn_timeout_seconds": 320, + "created_at": "2026-01-01", + "updated_at": "2026-01-01" + }}"# + )) + .expect("sample record with inline secrets"); + + // Strip: moves secrets from inline fields into the fake keyring. + strip_and_persist_agent_secrets_with(&store, &mut record); + + // After strip: inline fields must be empty/null. + assert!( + record.env_vars.is_empty(), + "env_vars must be cleared after strip" + ); + assert!( + record.auth_tag.is_none(), + "auth_tag must be cleared after strip" + ); + if let crate::managed_agents::types::BackendKind::Provider { config, .. } = &record.backend { + assert!( + config.is_null(), + "provider config must be cleared after strip" + ); + } + + // Serialize to JSON and verify no secret bytes appear. + let json = serde_json::to_string(&record).expect("serialize"); + assert!( + !json.contains(secret_env_value), + "serialized JSON must not contain env_vars secret" + ); + assert!( + !json.contains(secret_auth_tag), + "serialized JSON must not contain auth_tag secret" + ); + assert!( + !json.contains("provider-secret"), + "serialized JSON must not contain provider config secret" + ); + // Refs must be present (the keyring round-trip worked). + assert!( + record.env_vars_ref.is_some(), + "env_vars_ref must be set after successful strip" + ); + assert!( + record.auth_tag_ref.is_some(), + "auth_tag_ref must be set after successful strip" + ); + assert!( + record.provider_config_ref.is_some(), + "provider_config_ref must be set after successful strip" + ); +} diff --git a/desktop/src-tauri/src/managed_agents/team_snapshot.rs b/desktop/src-tauri/src/managed_agents/team_snapshot.rs index e03f562460..a51ce14582 100644 --- a/desktop/src-tauri/src/managed_agents/team_snapshot.rs +++ b/desktop/src-tauri/src/managed_agents/team_snapshot.rs @@ -312,6 +312,7 @@ mod tests { auth_tag_ref: None, env_vars_ref: None, provider_config_ref: None, + secrets_unavailable: false, } } diff --git a/desktop/src-tauri/src/managed_agents/teams_tests.rs b/desktop/src-tauri/src/managed_agents/teams_tests.rs index b640342017..2f633f6d88 100644 --- a/desktop/src-tauri/src/managed_agents/teams_tests.rs +++ b/desktop/src-tauri/src/managed_agents/teams_tests.rs @@ -219,6 +219,7 @@ fn managed_agent(name: &str) -> ManagedAgentRecord { auth_tag_ref: None, env_vars_ref: None, provider_config_ref: None, + secrets_unavailable: false, } } diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index bd7b29f23a..024a48a300 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -156,6 +156,7 @@ impl AgentDefinition { definition_respond_to_allowlist: self.respond_to_allowlist, definition_parallelism: self.parallelism, relay_mesh: None, + secrets_unavailable: false, } } } @@ -442,6 +443,15 @@ pub struct ManagedAgentRecord { /// deserialize as `None`. #[serde(default, skip_serializing_if = "Option::is_none")] pub relay_mesh: Option, + /// Set by the load path when one or more secret fields (env_vars, auth_tag, + /// provider_config) have a keyring `*_ref` that points to an unavailable or + /// missing entry. Never serialized — it is a transient load-time signal + /// that the spawn path must consult before starting this agent. + /// + /// `true` → at least one referenced secret is unreachable; spawn must refuse. + /// `false` (default) → all refs resolved successfully (or there are no refs). + #[serde(skip)] + pub secrets_unavailable: bool, } /// Typed relay-mesh configuration carried on a [`ManagedAgentRecord`]. diff --git a/desktop/src-tauri/src/migration.rs b/desktop/src-tauri/src/migration.rs index 4f8341ab83..1415f27052 100644 --- a/desktop/src-tauri/src/migration.rs +++ b/desktop/src-tauri/src/migration.rs @@ -192,6 +192,14 @@ fn run_boot_migrations_inner(app: &tauri::AppHandle, reset_completed: bool) { reconcile_databricks_v1_to_v2(app); materialize_agent_runtimes(app); migrate_inline_secrets_to_keyring(app); // keyring extraction — runs after all raw-JSON migrations + // Dev-build-only: copy secret-projection keys (env vars, auth tags, provider + // configs) from the source keyring service into the destination dev service. + // Runs AFTER migrate_inline_secrets_to_keyring so projection keys are + // present in the source before we copy them. + #[cfg(debug_assertions)] + if is_dev { + crate::managed_agents::migrate_agent_secrets_to_dev_service(app); + } } /// Copy one-time app state from the legacy app identifier directory to