Conversation
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>
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.
|
Adding a production data point, since this one is unaffected by #6338 and still reproduces. On our self-hosted relay (
Same relay, same harness, same What makes it expensive to diagnose is that it is silent at all three layers: the relay only increments 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. |
Summary
An agent registered as a relay member in its own right never gets
users.agent_owner_pubkeywritten, so every owner-gated feature is silently disabled for it — and nothing anywhere reports why.check_relay_membershiptests direct membership first and short-circuits onMembershipDecision::Member. The owner is only resolved on theViaOwnerbranch, reached solely by agents that are not members and are admitted by NIP-OA delegation; the other backfill path inhandlers/auth.rsis 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:That rejection is invisible from both sides. The relay only increments
buzz_events_rejected_total{reason="auth"}and logs nothing at all, whilebuzz-acpdoes not surface theOK=false— so the agent reportsrelay 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:
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, asextract_nip_oa_ownerdoes on open relays.handlers/auth.rsandapi/bridge.rs— already pass it straight tomaterialize_nip_oa_owner, so the fix reaches every transport at once. The remaining callers discard the value.agent_owner_pubkeyis 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_pubkeyand 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.require_relay_membership=true,allow_nip_oa_auth=true) runningghcr.io/block/buzz:sha-1b3dbca, with an opencode agent underbuzz-acpandBUZZ_ACP_RELAY_OBSERVER=true. Itsusersrow hadagent_owner_pubkey = NULLwhile 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.