Commit Graph

3088 Commits

Author SHA1 Message Date
phil baker fafc3ddce5 fix: deliver Buzz media as native attachments 2026-08-31 10:06:34 -07:00
Riyaaz ebda1fbf8e fix(buzz): deliver local images through native upload 2026-08-31 10:06:34 -07:00
EmpireOperating f857f13b30 test(buzz): isolate authorization cases from CLI lookup 2026-08-31 10:06:34 -07:00
EmpireOperating 366506205a fix(gateway): require boolean authorization decisions 2026-08-31 10:06:34 -07:00
EmpireOperating d15cbbcbf3 fix(buzz): gate inbound attachment side effects 2026-08-31 10:06:34 -07:00
EmpireOperating 00394acfae fix(buzz): ingest verified native attachments 2026-08-31 10:06:34 -07:00
Mathias Gorf aaad054330 fix(buzz): gate authenticated inbound media on explicit authorization
Localizing inbound relay media spends the agent's own Buzz credentials on
a URL chosen by the sender, so it must not run on the strength of the
adapter's local allow-list alone. Require the gateway's authorization
callback to return an explicit True before any `buzz media get` runs; a
denial, a missing callback, or a raising callback fails closed and leaves
the message text exactly as it arrived.

`_is_sender_authorized` previously wrapped the callback result in
`bool()`, so a truthy non-boolean (a status string, a sentinel) would
satisfy an `is True` gate's intent while bypassing its guarantee. Only
the literal booleans now propagate; anything else is "unknown", which the
existing Slack and Discord callers already treat as trust-unknown.

Reviewers asked for this boundary on the sibling inbound-media PRs
(#77734, #78051); it applies equally to the retrieval path in #75614,
which this change builds on.
2026-08-31 10:06:34 -07:00
joelbrilliant 55136adcc4 fix(buzz): preserve inbound media captions 2026-08-31 10:06:34 -07:00
joelbrilliant bce94cc1b9 fix(buzz): localize inbound relay media 2026-08-31 10:06:34 -07:00
Teknium ac7cefe0d8 test(gateway): regression coverage for the durable-reaped session guard (#99106) 2026-08-31 09:58:32 -07:00
Teknium fc2421cfa7 fix(gateway): hold inbound gate until turn machinery is warm on fresh boot (#99373)
On a fresh boot with no resume_pending sessions, _finish_startup_restore
opened the inbound gate almost immediately while the agent-side turn
machinery (run_agent import graph, tool schemas + check_fn probes,
context-file tier) was still cold. A message arriving in that window was
served with a skeleton system prompt (~1.7K tokens vs ~14.6K healthy):
no AGENTS.md/context tier, no tool schemas, memory provider initializing
mid-turn.

Fix: start a background turn-machinery warm-up when the startup gate
closes (overlapping the network-bound platform connects) and have
_finish_startup_restore await it — BOUNDED by
agent.gateway_startup_warmup_timeout (default 20s, 0 disables) — before
draining the queue and opening the gate. On timeout the gate opens
anyway and the warm-up finishes in the background, so a wedged init can
never make the gateway permanently unavailable.

Reported by @yhfmstr in #99373.

Fixes #99373
2026-08-31 09:58:24 -07:00
HexLab98 2ea37eb8c5 test(gateway): cover hygiene fence-cancel cooldown and in-flight skip (#96953)
Prove a fence-cancelled helper (no abort flag) persists cooldown so the
next turn does not re-arm compression, the host does not wait out the
600s ceiling after cancel, a held lock skips the sibling agent, and
unwind cancellation records the same brake.

(cherry picked from commit d2e178cd96fbcc8b2baf68488a8f46c70bff31a2)
2026-08-31 09:57:13 -07:00
Nio Thomas ba7743b076 fix(state-db): report corruption instead of "session not found", detect it early
Re-applied onto 3aee29089 after `hermes update` reset main to origin/main.

1. web_routers/sessions.py: _resolve_session_id() classifies malformed-DB
   errors via the existing is_malformed_db_error() and raises 503 at all five
   call sites. delete_session_endpoint was the worst — an unresolvable id
   counted as idempotent success, so DELETE reported it had removed a session
   that was still on disk.
2. gateway/lifecycle_ledger.py: check_state_db_integrity() runs PRAGMA
   quick_check(1) on the unclean-exit path only (~2s on 500MB) and records the
   verdict into gateway-exit-diag.log. The 2026-08-31 corruption sat undetected
   for 3.5 days because nothing ever looked.
3. hermes_cli/gateway.py: `gateway run --replace` gave the outgoing gateway 5s
   before SIGKILL; SessionDB.close() runs a PASSIVE WAL checkpoint that does
   not finish in 5s on a WAL 4x past the autocheckpoint threshold, and a kill
   mid-checkpoint tears b-tree pages. Grace raised to 30s via a testable
   _await_gateway_exit() that also re-checks after the final sleep (a PID
   exiting in the last interval must not be SIGKILLed — PID-reuse hazard).

NOT added: wal_checkpoint(TRUNCATE) at shutdown — removed upstream in #45383
because a TRUNCATE reset races the live writer and tears b-tree pages.

Adversarial review: Codex gpt-5.6-sol, 9.0/10 across three groups, no must-fix.
2026-08-31 09:56:54 -07:00
Teknium d16622cd14 test(buzz): regression coverage for fresh-subscription window, membership-rejection phrasing, and WS periodic discovery
- fresh conversation subscribes with no since floor + bounded limit; seeded
  channels keep since=last_ts-1 (#78429)
- all three production CLOSED membership phrasings prune per-subscription
  without reconnect (#76850 + #97502)
- restricted channels are not re-adopted by discovery sweeps
- WS periodic discovery subscribes conversations found without a membership
  event (#93557) and the companion task dies with its connection
- watch-all live channel adoption vs explicit-list scoping (#75107)

All six new tests sabotage-checked: each fails when its fix is reverted.
2026-08-31 09:49:30 -07:00
José Leos d3730a3fa9 fix(buzz): handle restricted CLOSED per-subscription, stop reconnect flood
When a Buzz relay sends a CLOSED frame for a single subscription with a
'restricted: not a channel member' error, the adapter was raising
ConnectionError, tearing down the entire WebSocket connection, and
immediately reconnecting — causing a ~1.6 s flood in gateway.log.

Root cause: the CLOSED handler unconditionally raised ConnectionError
regardless of whether the error was permanent (restricted) or transient
(e.g. server shutdown).

Fix:
- On a 'restricted' CLOSED, drop only the offending subscription and
  record the channel in a new _restricted_channels set instead of
  tearing down the whole connection.
- Skip restricted channels during connect() seeding and
  _subscribe_websocket() so reconnects don't re-trigger the same error.
- Non-restricted CLOSED frames still raise ConnectionError and reconnect
  as before.

Adds three regression tests:
- test_websocket_loop_drops_restricted_channel_without_reconnect
- test_websocket_loop_reconnects_on_non_restricted_closed
- test_restricted_channels_skipped_during_subscribe

Tested on macOS against buzz.xozai.com: gateway.log shows zero
'restricted' errors and stable 'watching N channel(s) via websocket'
after the fix.
2026-08-31 09:49:30 -07:00
Alessandro Boni d36827b086 fix(buzz): resume watched channels from a durable cursor across restarts (#90464)
`connect()` calls `_seed_channel()` unconditionally, and seeding marks every
event currently in the channel as seen so a start never replays history at the
agent. A message that arrives after the process starts but before the seed
completes — or at any point while the gateway is down — sits in exactly that
history, so the seed swallows it permanently even though the Buzz relay still
has it. The `seen` set and `last_ts` lived only in memory, so there was nothing
to distinguish "already handled" from "never seen".

Each watched channel's cursor (`chat_type`, `last_ts`, and the bounded `seen`
id list) is now persisted under `HERMES_HOME/buzz/channel-cursors.json` and
restored at connect. Where a cursor exists the channel resumes from it and the
history fetch is skipped entirely; where none exists the old seed-from-history
behaviour is unchanged, so a first-ever run still never replays a backlog.

Details worth noting:

- The file records the identity and relay it was written for. A cursor from a
  different bot or relay is ignored rather than trusted — the channel ids
  would collide while the event stream behind them is a different one.
- Any read or parse failure leaves the cursors empty, which degrades to
  seeding instead of failing the connect. Writes go through
  `utils.atomic_json_write` (temp + fsync + replace), so a crash mid-write
  cannot leave a truncated cursor behind.
- The restored `seen` list is trimmed to `_SEEN_CAP` on load, keeping the
  newest ids, so a hand-edited or legacy file cannot grow the de-dupe set
  without bound.
- Saves are gated on the cursor actually moving, so an idle channel does not
  rewrite the file every poll interval. Both inbound transports are covered:
  the poll sweep and the WebSocket event path share the same check.

Tests: six new cases in `TestChannelCursorPersistence` — the cursor is written
on seed, a restart resumes without spending a CLI call on history and then
delivers the mention that landed while the gateway was down, a foreign
identity or relay is ignored, a corrupt file falls back to seeding, the
restored `seen` set stays bounded, and an idle poll leaves the file untouched.
All six fail on main.

Tested on: Windows 11, Python 3.12. `python -m pytest
tests/gateway/test_buzz_adapter.py tests/gateway/test_buzz_websocket.py -q` —
33 passed (23 pre-existing + 6 new here, plus 4 WebSocket). Requires
`pytest-asyncio` (pinned at 1.3.0 in pyproject) — without it the async cases
in this file error out as unknown marks.
2026-08-31 09:49:30 -07:00
liuhao1024 94d86fa4de fix(buzz): bound WebSocket read idle time to force reconnect on silent relays
A relay-side close the transport never surfaces (observed as a CLOSE_WAIT
socket behind Cloudflare, #98097) parks the read loop forever while the
gateway keeps reporting connected: inbound stops, gateway_state.json stays
healthy, and only a restart recovers. The library keepalive should catch
this first, but as a last resort the read side now waits at most
_WS_READ_IDLE_TIMEOUT (300s) for a frame before raising into the existing
reconnect path, which re-authenticates and re-subscribes with per-channel
since filters intact.

Fixes #98097
2026-08-31 09:49:30 -07:00
Teknium fc0a10a924 test(buzz): align send-recovery tests with the composed mention ladder
Follow-up for salvaged PRs #82646 + #83414: resolution probes precede
publishes, the presentation-escape retry precedes the self-mention
downgrade, and a new test pins the escape-retry-delivers path.
2026-08-31 09:05:41 -07:00
Matt Lutz a74080437a fix(buzz): resolve @mentions to member pubkeys so agent-to-agent pings work
Salvaged from PR #83414 (4 commits squashed to final state) and composed
with the presentation-mention escape retry from PR #82646 already on this
branch: send() now resolves @Name tokens to channel-member pubkeys
(membership-accurate via `channels members`, TTL-cached, Unicode token
boundaries, ambiguous names stay presentation-only) and passes explicit
--mention args; recovery ladder handles membership drift, unresolvable
prose @tokens (escape retry, #78797), and a final self-mention downgrade.
2026-08-31 09:05:41 -07:00
blunkjamie-dev 1885a40ad3 fix(buzz): preserve literal mentions and exact UUID targets 2026-08-31 09:05:41 -07:00
Cameron Aragon 743f86ed36 test(buzz): clarify reaction-only precedence 2026-08-31 09:05:41 -07:00
Cameron Aragon c10c77d577 fix(buzz): acknowledge trusted agent tags without dispatch 2026-08-31 09:05:41 -07:00
Elmar Conradie f40edee459 fix(buzz): trust explicit DM metadata fallback 2026-08-31 09:05:41 -07:00
Elmar Conradie 722209bb51 fix(buzz): require explicit group addressing 2026-08-31 09:05:41 -07:00
arimu1 ef2be55025 fix(buzz): treat NIP-10 replies to own messages as mentions
require_mention gated only on visible text, so Desktop thread replies
(e.g. /approve session) to the agent's own prompts were dropped with no
log. Cache event_id→(author, snippet) from seed/poll/WS/send, resolve
the direct e-tag parent, and dispatch when that parent is ours; also
populate reply_to_* on MessageEvent for gateway context injection.

Fixes #75826
2026-08-31 09:05:41 -07:00
liuhao1024 306dc874c1 fix(buzz): dispatch forum-channel kinds instead of chat kind 9 only
The inbound path hardcoded Nostr kind 9 at both the WebSocket
subscription filter and the dispatch gate (which runs before mention
gating), so Buzz forum channels — kind 45001 thread roots and 45003
comment replies — were silently never dispatched to the agent; chat and
stream channels worked, making the gap invisible (#90309). Block's own
ACP harness documents the forum kinds explicitly.

Introduce _DISPATCH_KINDS = {9, 45001, 45003} for the subscription
filter and dispatch gate. The stream kinds (46010/40007/45002) stay out
of scope until their dispatch semantics are confirmed.
_is_direct_message_event deliberately keeps its kind-9-only check:
widening it would let a p-tagged forum post be reclassified as a DM and
bypass mention gating. The send path already works unchanged (send()
omits --kind and threads via --reply-to).

Fixes #90309
2026-08-31 09:05:41 -07:00
Teknium e7f6e22c3b test(buzz): topology standalone-send fakes accept the auth_tag kwarg
The threading cluster (#99429) branched before the auth cluster (#99427)
added auth_tag to _exec_buzz's signature; merging both left two
fake_exec doubles in test_buzz_thread_topology.py without the kwarg,
red on main. Sibling-test blast radius, no behavior change.
2026-08-31 20:57:30 +05:30
Teknium 4b1374bec1 test(buzz): stub CLI in thread-root propagation test so it never execs a real subprocess 2026-08-31 07:30:44 -07:00
Teknium 06a627d7c2 test(buzz): E2E regressions for thread-root anchoring, reply_in_thread honoring, progress routing, display tier
Real adapter imports with synthetic NIP-10 relay events; covers the composed
cluster behavior end to end: in-thread triggers anchor replies to the thread
root (never nesting), top-level triggers still open exactly one thread,
reply_in_thread/reply_to_mode opt-outs post flat on send/send_image/
standalone cron send, progress thread resolution honors the opt-out, and
buzz no longer inherits verbose _GLOBAL_DEFAULTS (#95841). Sabotage-checked:
the display-tier tests fail against origin/main's display_config.py.
2026-08-31 07:30:44 -07:00
NanakoIce 66fa6e41c4 fix(buzz): reply in-thread instead of flat channel posts
Buzz has no native thread_id; channel threading is entirely --reply-to on
the triggering event. Interim commentary and progress bubbles only passed
the anchor via metadata.reply_to_message_id (or not at all), so most Kathy
posts landed as new top-level messages and cluttered channels.

- Honor metadata.reply_to_message_id in BuzzAdapter.send
- Pass reply_to on stream commentary sends
- Treat buzz like slack/mattermost for progress thread resolution
- Set _progress_reply_to to the trigger event for buzz
- Add unit tests for adapter metadata and progress routing
2026-08-31 07:30:44 -07:00
Tom Watts cbeb925f0a fix(buzz): reply into the existing thread instead of nesting a new one
Every Buzz reply opened a fresh thread, including when the user was
already replying inside one. A threaded client fills up with an endless
ladder of one-message threads and the conversation becomes unreadable.

The adapter itself never had threading logic; the behaviour comes from
the generic gateway default. `_reply_anchor_for_event()` in
gateway/platforms/base.py returns `event.message_id`, which is right for
reply-style platforms (Telegram/Discord "reply to this message") but
wrong for a thread-style one: anchoring to the message you are answering
nests a new sub-thread under every single turn.

Buzz threads are NIP-10, so the information needed is already on the
inbound event. The adapter now records each inbound message's thread
root from its `e` tags and resolves the outbound anchor to that root, so
a reply joins the thread the user is typing in. When the trigger was
itself top-level there is no root and the anchor passes through
unchanged, preserving the existing behaviour of opening exactly one
thread from a top-level message.

Fixed in the adapter rather than in `_reply_anchor_for_event()`: Buzz is
a plugin-supplied platform, and its NIP-10 tag semantics do not belong in
core. Root extraction prefers an explicit `root` marker, falls back to a
lone `reply` marker (a message bearing only `reply` started the thread,
so that parent is the root for everything after it), and treats a legacy
unmarked `e` tag as the parent. A mention-only `p` tag is not a reply.

The root cache is an OrderedDict bounded at 512 entries with FIFO
eviction so a long-lived gateway cannot leak, and the resolver is applied
to the image send path as well as `send()`. Both helpers tolerate a
missing `_thread_roots` attribute, since the standalone/cron send path
constructs an adapter without running `__init__`.

Tests cover root extraction (top-level, thread opener, nested, legacy
unmarked tag), the top-level passthrough that guards the existing
behaviour, unknown/None anchors, cache bounding and eviction, and an
end-to-end assertion through `send()` that `--reply-to` carries the root.
Verified against the real event shapes returned by a live hosted relay.
2026-08-31 07:30:44 -07:00
yuvalfis 09cbce43e0 fix(buzz): preserve stable thread roots 2026-08-31 07:30:44 -07:00
Reinhold 367c13ad5c test(buzz): cover progress reply CLI boundary 2026-08-31 07:30:44 -07:00
Reinhold cc979f7e12 test(buzz): preserve explicit progress thread 2026-08-31 07:30:44 -07:00
Reinhold b3af0b6863 fix(buzz): keep progress messages in thread 2026-08-31 07:30:44 -07:00
Han Ngo 34c10f83c3 feat(buzz): implement edit_message and delete_message so replies can stream
The gateway already streams by sending a first partial message and re-editing
it as tokens arrive, falling back to that path when an adapter does not support
native drafts. The Buzz adapter never implemented edit_message, so it inherited
the base stub that returns success=False and every reply was delivered in one
block when the turn finished, however long the turn took.

buzz-cli already exposes `messages edit` and `messages delete`, so no new
mechanism is needed.

One detail worth calling out for review: buzz-cli reports a NEW event id for
each edit, but the edit TARGET stays the original id, and the stream consumer
holds a single message_id for the whole stream. edit_message therefore returns
the id it was given rather than the one the CLI reports. Returning the CLI's id
would make every edit after the first address a message that was never sent.

delete_message is included because the consumer's fresh-final cleanup path
calls it when it replaces a preview rather than editing in place.

Tested: 10 new cases in tests/gateway/test_buzz_adapter.py covering the edit
target, stdin content, the returned id, echo suppression, finalize being inert,
both no-op guards, retryable vs non-retryable CLI failures, and delete. The
file goes from 33 passing to 6 failing if the adapter change is reverted while
the tests stay.
2026-08-31 07:30:44 -07:00
686f6c61 e703717513 fix(cron): treat a live multiplexer as gateway-alive for satellite profiles
A named profile has no local gateway.pid, so cron warned that jobs
would not fire and recommended hermes gateway install — which the
start guard then refuses with exit 78. Share the multiplexer-serving
probe with the start guard and count it as liveness.
2026-08-31 07:28:39 -07:00
Teknium 5742f6987d test(buzz): pin the single-profile config-only allowlist path (#82871)
The #82871 symptom — gateway default-denies every Buzz user because no
central env allowlist exists and the adapter's config.extra.allowed_users
was never consulted — is fixed by the plugin-platform extra.allowed_users
fallback salvaged from PR #98748 (registry-gated, normalize_user_id-aware).
That PR's tests cover the multiplex profile path; these pin the plain
single-profile gateway path from the issue's repro: npub-only and hex-only
config lists authorize, unlisted senders and empty lists stay default-deny.
Sabotage-verified: all four fail on origin/main's authz_mixin.
2026-08-31 07:28:30 -07:00
Teknium 9113cf24a9 fix(buzz): reconcile scoped auth-tag resolution across salvaged fixes
The WS NIP-42 auth path now prefers the connect()-resolved _auth_tag
(credentials-file aware, #79514) and falls back to a lazy scope-aware
_resolve_auth_tag() so a bare adapter re-auth stays profile-correct
(#98738): scoped multiplex profiles fail closed instead of borrowing
the default profile's tag from os.environ. _exec_buzz fakes updated
for the auth_tag kwarg introduced by the #83155 salvage.
2026-08-31 07:28:30 -07:00
Teknium 66c56d46fc test(buzz): cover standalone owner auth_tag injection
Regression for the cron/standalone path: credentials-file auth_tag must be
passed into _exec_buzz, and a bare BUZZ_PRIVATE_KEY must not invent a tag
from ambient credential files.

Reapplied from PR #83155 head (original commit carried a placeholder
local identity).

Co-authored-by: Alex P. Günsberg <alex@gunsberg.fi>
2026-08-31 07:28:30 -07:00
Alex P. Günsberg 29ee8230be fix(buzz): fail closed on multiplex credential discovery 2026-08-31 07:28:30 -07:00
Alex P. Günsberg 8c09c39530 fix(buzz): scope owner credentials per profile 2026-08-31 07:28:30 -07:00
Alex P. Günsberg 51ffeb059c fix(buzz): load owner auth tag from credentials 2026-08-31 07:28:30 -07:00
webtecnica c83121cf5f fix(buzz): normalize npub entries in BUZZ_ALLOWED_USERS to hex
Summary:
The gateway's central allowlist check compared the inbound Buzz sender's
64-char hex pubkey against the raw BUZZ_ALLOWED_USERS entries. An operator
who listed only their npub saw every message rejected with
"Unauthorized user: <hex pubkey>" (gateway drops the message). npub
entries are now decoded to hex before the comparison, so npub and hex
forms of the same identity are equivalent.

Root Cause:
The Buzz adapter's own intake check already normalizes npub→hex via
_normalize_user_ref when building _allowed_pubkeys, but the gateway
applies BUZZ_ALLOWED_USERS centrally as well (authz_mixin._is_user_authorized
via the platform registry's allowed_users_env). That central path did a
raw string comparison of the allowlist entries against the hex user_id,
so an npub-only entry never matched.

Change:
- gateway/authz_mixin.py: add a pure-stdlib bech32 npub→hex decoder
  (mirroring plugins/platforms/buzz/adapter.py) and normalize the buzz
  allowlist set in _is_user_authorized: each npub1… entry is decoded and
  its hex form added; hex entries pass through unchanged, so existing
  hex-only allowlists keep working. Comparison stays fail-closed for
  unrelated senders.
- tests/gateway/test_buzz_authz.py: new tests covering npub-only,
  hex-only, mixed, uppercase-npub, and denial of unrelated users, plus
  unit tests for the decoder/helper.

Verification:
- 43 passed (test_buzz_authz.py + test_buzz_adapter.py +
  test_pairing_allowlist_bypass.py)
- 9 passed (test_multiplex_profile_authz.py + test_buzz_websocket.py)

Closes #78428
2026-08-31 07:28:30 -07:00
Kosta Gorod a5e7355766 fix(buzz): resolve BUZZ_AUTH_TAG through the profile secret scope
One BUZZ_* read survived the #98738 sweep unscoped: the NIP-42 WebSocket
auth path read BUZZ_AUTH_TAG with a bare os.getenv. Under
gateway.multiplex_profiles the process env holds the default profile's
bridge/.env output, so a scoped secondary profile without its own tag
signed its relay auth event with the default profile's NIP-OA
owner-attestation tag. Reproduced on f3845a72af before the fix; the same
repro now attaches no tag (fail-closed).

The read goes through _get_scoped_secret: scoped multiplex profiles fail
closed to "", while single-profile and unscoped default-profile reads
keep the legacy env behavior. Adversarial coverage added for the leak
itself, the scoped positive control, unscoped precedence, partial-extra
adapter config, scoped validate_config, scoped standalone-send target
resolution, central-authz wildcard/blank-entry/normalization semantics,
and adapter-intake vs central-authz agreement on the same allowlist.

Fixes #98738

Signed-off-by: Kosta Gorod <35299380+KostaGorod@users.noreply.github.com>
2026-08-31 07:28:30 -07:00
liuhao1024 91d4c791be fix(tests): resolve Buzz Platform member by value, not attribute access
The four new #98738 authorization tests failed on CI shards that had
never looked Buzz up: plugin platforms have no static Platform member —
Platform._missing_ creates one on demand and caches it in _member_map_,
so attribute access (Platform.BUZZ) only works after an earlier value
lookup in the same process. Local full-suite runs happened to register
it first, which is why this only surfaced on a fresh shard.

Resolve the member once via Platform("buzz") at module import and use
that constant in the runner/source helpers.
2026-08-31 07:28:30 -07:00
liuhao1024 aaa5f27d0f fix(buzz): secondary multiplex profiles must not inherit the default profile's env
Under gateway.multiplex_profiles the default profile's YAML-to-env bridge
writes BUZZ_* values into os.environ, and every Buzz read gave that env
precedence over the secondary profile's PlatformConfig — so each secondary
adapter connected as the default identity, watched its channels, and
resolved its credentials file (#98738).

- Add _profile_scoped()/_scoped_platform_setting(): inside a secondary
  profile scope extra is authoritative and env is not consulted (a missing
  key fails closed to its default instead of borrowing the default
  profile's value); single-profile and unscoped/default-profile reads keep
  the legacy env-over-config precedence.
- Apply the scoped read to BuzzAdapter.__init__ (relay, CLI path, channels,
  home channel, poll interval, require_mention, transport, allowed users),
  _resolve_private_key (BUZZ_CREDENTIALS_FILE), validate_config,
  _standalone_send, and check_requirements (which now consults the
  profile's own config.yaml via the scoped home override).
- _env_enablement() returns None inside a profile scope and
  _apply_yaml_config() skips the env bridge there, so the default profile's
  env cannot fabricate Buzz for a profile that never configured it and a
  secondary profile's YAML cannot be pinned into the process env
  (first-writer-wins, #72348 Telegram/Discord mirror).
- Central authorization now consults a plugin platform's live-adapter
  config.extra.allowed_users (gated on the registry entry declaring
  allowed_users_env, with an optional normalize_user_id hook so Buzz npub
  entries match hex-pubkey user ids) — under multiplex only the default
  profile's list ever reached the env var, so listed secondary-profile
  users were default-denied (#82871). Empty/absent lists change nothing;
  default-deny is preserved.
2026-08-31 07:28:30 -07:00
Teknium a0a63a1bc2 fix(gateway): username-based DISCORD_ALLOWED_USERS no longer locks out the operator after one turn
The Discord adapter resolves username allowlist entries to numeric IDs at
connect and mirrors them into os.environ — but the gateway's per-turn .env
hot-reload (load_hermes_dotenv(override=True)) restores the raw usernames
from the file. From the second agent turn onward, _is_user_authorized
compared numeric user_ids against username strings and dropped every
message from the operator as 'Unauthorized user' while the adapter layer
still admitted them (bot reacted, never replied).

Fix: gateway authz unions the adapter's resolved numeric IDs
(DiscordAdapter.resolved_allowlist_user_ids()) into the env-derived
allowlist. Union only fires when an env allowlist is configured (never a
widening; fail-closed branch unchanged), is duck-typed + isinstance-guarded
against mock adapters, and filters non-numeric entries so unresolved
usernames and '*' can't leak through adapter memory.

Live repro: symptom fired on origin/main (authorized=False after reload),
passes with fix; stranger + empty-allowlist + raising-resolver negatives
hold. Sabotage run: incident test fails on unfixed authz_mixin.
2026-08-31 06:28:25 -07:00
Teknium 1cf3639813 test(bot-mode): pin grant-refresh reauthorization on execution-policy drift
Follow-ups on the salvaged #97797 transport:

- Blocker 2 from the #97681 exact-head review claimed near-expiry refresh
  silently mints against the target's CURRENT policy. On this head the
  handler DOES refuse drift (_require_unchanged_execution_policy -> 403
  room_reauthorization_required), but nothing pinned the handler-level
  behavior: removing the drift check still passed the entire grants suite
  (the check was only unit-tested in isolation). New HTTP-level regression
  test drives /v1/room-members/grants/refresh with a drifted-policy grant
  and requires the 403; sabotage-verified (check removed -> test fails).
- cancel() conflict resolution: keeps our race-retry routing loop from
  #99099 with this layer's peer-stop acknowledgement body inside it
  (peer receipt -> settle completion -> local interrupt escalation).
- docs: NAT one-way-reachability note in bot-mode.md — Desktop is a viewer,
  not a relay; put room authority on the host everyone can reach (field
  finding from /bin/bash on #97681).
2026-08-31 01:04:11 -07:00
David Dudok de Wit e7433910e9 feat(bot-mode): add scoped cross-gateway Group Chat transport 2026-08-31 01:04:11 -07:00