fix(managed-agents): boot reconcile survives pin-less records

Boot reconcile minted zero 30179s on a store with any record whose
legacy relay_url pin is "". Two defects compounded:

- buzz-core validate_identity_and_config rejected an empty relay_url.
  The per-record pin has been ignored at read time since #2122 (the
  agent resolves to the active workspace relay), so "" is a legal
  record and must encode. Keep only the 4096 upper bound.
- reconcile_agents_in_dir_with used `?` on each record, so the first
  build failure aborted the loop and every record after it in file
  order was never visited. Log the failing record and keep walking;
  each record is its own transaction, so partial progress is safe.

Tests: buzz-core `empty_relay_url_is_accepted`; desktop
`empty_relay_url_pin_does_not_abort_boot_reconcile` writes a pin-less
record followed by a normal one and asserts both get 30177 and a
decryptable 30179. The desktop test fails on f7c247756 with
"invalid relay_url length" and passes with this change.

Co-authored-by: Meli <5aaa86bce934fc3445fc254aab560a40923f10252f92107e665073dede0e04d3@buzz.block.builderlab.xyz>
Signed-off-by: Meli <5aaa86bce934fc3445fc254aab560a40923f10252f92107e665073dede0e04d3@buzz.block.builderlab.xyz>
This commit is contained in:
Meli
2026-08-25 13:52:38 -04:00
parent f7c2477569
commit 66995b1fce
3 changed files with 64 additions and 3 deletions

View File

@@ -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();

View File

@@ -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}"),
}
}

View File

@@ -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();