diff --git a/crates/buzz-core/src/private_managed_agent.rs b/crates/buzz-core/src/private_managed_agent.rs index 11fd681508..562e640084 100644 --- a/crates/buzz-core/src/private_managed_agent.rs +++ b/crates/buzz-core/src/private_managed_agent.rs @@ -417,7 +417,9 @@ fn validate_identity_and_config( if let Some(auth_tag) = &identity.auth_tag { validate_auth_tag(auth_tag, &owner.to_hex(), agent)?; } - if config.relay_url.is_empty() || config.relay_url.len() > 4096 { + // Empty is legal: a pin-less agent resolves to the active workspace relay + // at read time, so only the upper bound is enforced here. + if config.relay_url.len() > 4096 { return Err(Error::InvalidPayload("invalid relay_url length".into())); } if config.name.is_empty() || config.name.len() > 4096 { @@ -764,6 +766,16 @@ mod tests { )); } + /// A pin-less agent (empty `relay_url`, legal since the per-record pin + /// became read-time-ignored) must still produce a valid payload. + #[test] + fn empty_relay_url_is_accepted() { + let owner = Keys::generate(); + let mut candidate = payload(&owner, &Keys::generate()); + candidate.config.relay_url.clear(); + validate_payload(&candidate).unwrap(); + } + #[test] fn valid_owner_attestation_passes_and_binds_agent() { let owner = Keys::generate(); diff --git a/desktop/src-tauri/src/managed_agents/reconcile.rs b/desktop/src-tauri/src/managed_agents/reconcile.rs index 3f1fb11f61..11316cd069 100644 --- a/desktop/src-tauri/src/managed_agents/reconcile.rs +++ b/desktop/src-tauri/src/managed_agents/reconcile.rs @@ -144,8 +144,12 @@ fn reconcile_agents_in_dir_with( continue; } - if retain_agent_record_at_boot(&conn, keys, record)? { - reconciled += 1; + // One unpublishable record must not strand every record after it in + // file order: log it and keep walking. + match retain_agent_record_at_boot(&conn, keys, record) { + Ok(true) => reconciled += 1, + Ok(false) => {} + Err(e) => eprintln!("buzz-desktop: agent-event-reconcile: {e}"), } } diff --git a/desktop/src-tauri/src/managed_agents/reconcile/tests.rs b/desktop/src-tauri/src/managed_agents/reconcile/tests.rs index 43d0845b2f..e1e84f0a41 100644 --- a/desktop/src-tauri/src/managed_agents/reconcile/tests.rs +++ b/desktop/src-tauri/src/managed_agents/reconcile/tests.rs @@ -258,6 +258,51 @@ fn keyring_resident_nsec_is_hydrated_for_private_config() { assert_eq!(payload.identity.private_key_nsec, nsec); } +/// A record whose legacy `relay_url` pin is empty (legal since #2122 — the +/// pin is ignored at read time) must not stop the boot reconcile: the records +/// after it in file order still get their first 30179. +#[test] +fn empty_relay_url_pin_does_not_abort_boot_reconcile() { + use nostr::ToBech32; + + let owner_keys = nostr::Keys::generate(); + let pinless_keys = nostr::Keys::generate(); + let later_keys = nostr::Keys::generate(); + let mut pinless = sample_record(&pinless_keys.public_key().to_hex(), "pinless"); + pinless.relay_url.clear(); + pinless.private_key_nsec = pinless_keys.secret_key().to_bech32().unwrap(); + let mut later = sample_record(&later_keys.public_key().to_hex(), "later"); + later.private_key_nsec = later_keys.secret_key().to_bech32().unwrap(); + + let dir = TempDir::new().unwrap(); + write_store(&dir, &[pinless, later]); + let db_path = dir.path().join("retention.db"); + reconcile_agents_in_dir_with( + dir.path(), + &owner_keys, + &db_path, + None::<&crate::secret_store::SecretStore>, + ) + .unwrap(); + + let conn = open_retention_db(&db_path).unwrap(); + let owner = owner_keys.public_key().to_hex(); + for keys in [&pinless_keys, &later_keys] { + let agent = keys.public_key().to_hex(); + assert!( + get_retained_event(&conn, KIND_MANAGED_AGENT, &owner, &agent) + .unwrap() + .is_some(), + "30177 for {agent}" + ); + let row = get_retained_event(&conn, KIND_PRIVATE_MANAGED_AGENT, &owner, &agent) + .unwrap() + .unwrap_or_else(|| panic!("first 30179 for {agent}")); + let event = nostr::Event::from_json(&row.raw_event).unwrap(); + private_managed_agent::validate_and_decrypt(&event, &owner_keys).unwrap(); + } +} + #[test] fn fresh_record_is_retained_pending() { let dir = TempDir::new().unwrap();