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

fix(buzz-relay): record the agent→owner relationship for direct members#6098
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
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