Skip to content

fix(buzz-relay): record the agent→owner relationship for direct members - #6098

Open
Schnitzel wants to merge 1 commit into
block:mainfrom
Schnitzel:relay/record-agent-owner-for-direct-members
Open

Schnitzel wants to merge 1 commit into
block:mainfrom
Schnitzel:relay/record-agent-owner-for-direct-members

Conversation

@Schnitzel

Copy link
Copy Markdown

Summary

An agent registered as a relay member in its own right never gets users.agent_owner_pubkey written, so every owner-gated feature is silently disabled for it — and nothing anywhere reports why.

check_relay_membership tests direct membership first and short-circuits on MembershipDecision::Member. The owner is only resolved on the ViaOwner branch, reached solely by agents that are not members and are admitted by NIP-OA delegation; the other backfill path in handlers/auth.rs is gated on !require_relay_membership. So a closed relay throws away a NIP-OA proof the agent did present, on every connection, purely because the agent was also granted membership.

The visible symptom is Buzz Desktop's per-agent ACP activity tab. It is fed by kind 24200 observer frames, the relay gates those on users.agent_owner_pubkey, and with no mapping it rejects every one:

restricted: observer frame is not authorized for this agent owner

That rejection is invisible from both sides. The relay only increments buzz_events_rejected_total{reason="auth"} and logs nothing at all, while buzz-acp does not surface the OK=false — so the agent reports relay observer enabled, resolves an owner, looks entirely healthy, and every frame it sends is dropped. The operator sees an empty tab and reasonably concludes the UI is broken.

Desktop-managed agents are unaffected, which makes this hard to place: they are minted with an owner rather than added to relay_members, so the same agent works when created in the app and fails when run by the harness. Granting an agent membership is what breaks it.

This resolves the owner for a direct member too, via a new MembershipDecision::MemberWithOwner. Membership is still decided exactly as before — the new variant only carries a relationship that was already proved out of the check so it can be recorded.

Notes for review:

  • Security posture is unchanged. Tag verification and the owner-is-member lookup are shared with the delegation path in resolve_nip_oa_owner, so an owner that is not itself a relay member is still not recorded. That matters on a closed relay, where it would otherwise hand agent-management authority to someone who cannot connect. Happy to relax it if you would rather treat the NIP-OA tag as self-proving here, as extract_nip_oa_owner does on open relays.
  • No new cost for ordinary clients. The extra lookup runs only when an auth tag is presented, which in practice means agents.
  • No call-site changes needed. Both consumers of the returned owner — handlers/auth.rs and api/bridge.rs — already pass it straight to materialize_nip_oa_owner, so the fix reaches every transport at once. The remaining callers discard the value.
  • agent_owner_pubkey is first-write-wins, so this records the relationship where one is missing and never overwrites an existing one.

Existing deployments with an already-affected agent still need the row backfilled once; this stops it recurring, and any reconnect after upgrading writes it.

Related issue

None found — I searched issues and PRs for the observer-frame rejection, kind 24200, and agent_owner_pubkey and could not find this reported. Related in spirit to #6097, which fixes a different silent gap for harness-run agents (the kind 10100 directory entry), but the two are independent and touch different crates.

Testing

  • cargo test -p buzz-relay --lib relay_members — 6 passed, including three new cases covering that a direct member surfaces a proved owner, that delegation still does, and that admission without proof surfaces none.
  • cargo clippy -p buzz-relay --all-targets -- -D warnings — clean.
  • cargo fmt -p buzz-relay.
  • Confirmed end-to-end against a self-hosted closed relay (require_relay_membership=true, allow_nip_oa_auth=true) running ghcr.io/block/buzz:sha-1b3dbca, with an opencode agent under buzz-acp and BUZZ_ACP_RELAY_OBSERVER=true. Its users row had agent_owner_pubkey = NULL while every desktop-created agent on the same relay had it set, and the activity tab was empty for a demonstrably working agent. Populating that one column — which is what this patch causes to happen automatically — made the tab populate.

No UI change in this repo's code; the desktop tab renders as it always has, once the frames are allowed through.

An agent registered as a relay member in its own right never gets
`users.agent_owner_pubkey` written, so every owner-gated feature is
silently disabled for it — and nothing anywhere reports why.

`check_relay_membership` tests direct membership first and short-circuits
on `MembershipDecision::Member`. The owner is only resolved on the
`ViaOwner` branch, reached solely by agents that are *not* members and
are admitted by NIP-OA delegation; the other backfill path in auth.rs is
gated on `!require_relay_membership`. So a closed relay throws away a
NIP-OA proof the agent did present, on every single connection, purely
because the agent was also granted membership.

The visible symptom is Buzz Desktop's per-agent ACP activity tab. It is
fed by kind 24200 observer frames, the relay gates those on
`users.agent_owner_pubkey`, and with no mapping it rejects all of them:

    restricted: observer frame is not authorized for this agent owner

That rejection is invisible from both sides. The relay only increments
`buzz_events_rejected_total{reason="auth"}` and logs nothing at all,
while buzz-acp does not surface the `OK=false` — so the agent reports
`relay observer enabled`, resolves an owner, looks entirely healthy, and
every frame it sends is dropped. The operator sees an empty tab and
reasonably concludes the UI is broken.

Desktop-managed agents are unaffected, which makes this harder to place:
they are minted with an owner rather than added to `relay_members`, so
the same agent works when created in the app and fails when run by the
harness. Granting an agent membership is what breaks it.

This resolves the owner for a direct member too, adding
`MembershipDecision::MemberWithOwner`. Membership is still decided
exactly as before — the new variant only carries a relationship that was
already proved out of the check so it can be recorded.

The tag verification and owner-is-member lookup are shared with the
delegation path in `resolve_nip_oa_owner`, so the security posture is
unchanged: an owner that is not itself a relay member is still not
recorded, which matters on a closed relay where that would hand
agent-management authority to someone who cannot connect. The extra
lookup runs only for callers that present an auth tag, so ordinary
client connections do no additional work.

Both call sites that consume the owner — `handlers/auth.rs` and
`api/bridge.rs` — already pass it straight to `materialize_nip_oa_owner`,
so they need no change and the fix reaches every transport at once. The
remaining callers discard the value.

Signed-off-by: Michael Schmid <michael.schmid@amazee.com>
@Schnitzel
Schnitzel requested a review from a team as a code owner August 17, 2026 04:09
Schnitzel added a commit to Schnitzel/buzz-agent-deploy that referenced this pull request Aug 18, 2026
The skill offered two routes onto a closed relay — NIP-AA virtual
membership or `buzz-admin add-member` — as a preference with one stated
cost, "a second thing to remember to revoke". They are not
interchangeable, and the difference is invisible until you go looking.

Virtual membership makes the relay resolve the agent's NIP-OA owner and
write users.agent_owner_pubkey. Enrolment does not: check_relay_membership
matches direct membership first and returns before the auth tag is read,
so a valid proof is discarded on every connection and no owner is ever
recorded. That column gates NIP-AO kind 24200 observer frames, so an
enrolled agent has every frame rejected and its ACP activity tab is empty
forever. Enrolling the agent is what breaks it.

Nothing reports this. The relay logs nothing and only increments
buzz_events_rejected_total{reason="auth"}, buzz-acp never surfaces the
OK=false, and the agent goes on reporting "relay observer enabled" with a
resolved owner. Sent upstream as block/buzz#6098.

The component table also claimed NIP-AO was "handled by buzz-acp", which
is wrong in the sense that matters: the frames are opt-in behind
--relay-observer and neither env example set it. So an agent built by
following this skill had no activity feed for two independent reasons.

Adds the flag to both env examples, a section on the two gates with the
query that tells them apart, the relay-side mechanism in internals, and
troubleshooting rows — including that a teammate seeing an empty tab is
correct, since the frames are encrypted to the owner alone.

Also notes block/buzz#6097 against the every-deploy 10100 republish: if
the harness carries that, the workaround stops being load-bearing.
@Schnitzel

Copy link
Copy Markdown
Author

Adding a production data point, since this one is unaffected by #6338 and still reproduces.

On our self-hosted relay (require_relay_membership=true, allow_nip_oa_auth=true), two agents differing only in how they were admitted:

Agent Admission users.agent_owner_pubkey NIP-AO activity tab
enrolled via buzz-admin add-member direct member NULL every kind 24200 frame rejected
admitted by NIP-OA virtual membership ViaOwner populated automatically works immediately

Same relay, same harness, same BUZZ_ACP_RELAY_OBSERVER=true. The enrolled agent needed a hand-written UPDATE before its activity tab would ever show anything, because check_relay_membership matches direct membership first and returns before the auth tag is read — so a valid NIP-OA proof is discarded on every connection.

What makes it expensive to diagnose is that it is silent at all three layers: the relay only increments buzz_events_rejected_total{reason="auth"} and logs nothing, buzz-acp never surfaces the OK=false, and the agent goes on reporting relay observer enabled with a resolved owner. Everything an operator would think to check looks correct.

No rush on this from our side — we have the mapping written by hand and documented — but the PR is small and still applies cleanly. Happy to rebase if it would help.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant