Scoped secrets — `gateway.platforms._shared.get_scoped_secret` is the single implementation of the "scope authoritative, unscoped default-profile falls back to os.environ" read: - plugins/platforms/buzz/adapter.py::_get_scoped_secret (113 LOC, ~100 of which were one docstring paragraph pasted 16x) -> 3-line forwarder over the canonical with `external_fallback=True`. Its one genuine extra rung (one-shot profile-scope build so a Bitwarden-managed key is visible to the startup gate, #95216) moves into `_shared` as that keyword plus `_unscoped_profile_secrets`. - weixin::_wx_secret, matrix::_startup_env_secret, the inline try/except copies in slack (SLACK_APP_TOKEN) and telegram (TELEGRAM_WEBHOOK_SECRET/_URL) -> canonical. - The "extra-first, then scoped env" reader written 11x under 6 names (weixin._extra_or_env, bluebubbles/ntfy/photon/wecom `_setting`, dingtalk `_extra_get`, mattermost `_extra_or_env`, slack `_extra_or_env_flag/_channel_set`, feishu closures) -> `_shared.extra_or_secret`. - `authz_mixin._platform_gate_env` -> `_shared.platform_gate_env`; discord/telegram drop their `_scoped_gate_env` twins; run.py / run_config_loaders.py / slack import it directly. Boilerplate — three table-driven helpers in `_shared` replace the pasted docs template: - `seed_extra_from_env(spec, home_env=)` replaces 8 `_env_enablement` bodies (buzz, google_chat, irc, line, ntfy, photon, simplex, teams; raft is a one-liner and untouched). - `apply_yaml_bridge(cfg, spec)` replaces 7 `_apply_yaml_config` bodies (buzz, dingtalk, feishu, matrix, mattermost, slack, whatsapp); discord/telegram keep bespoke bridges (alias keys, nested `platforms.*.extra`, generic-key exclusions). buzz and mattermost previously bypassed `yaml_env_setter` with hand-rolled `os.environ` writes. - `env_is_connected(*vars)` replaces 5 identical `_is_connected` (discord, homeassistant, mattermost, slack, sms). - 8 identity `_build_adapter` wrappers deleted; `adapter_factory=<Class>`. Behavior change: - buzz `_apply_yaml_config` returned None, so under multiplex a secondary Buzz profile got neither env (correctly skipped) nor `extra` for relay_url/channels/allow_all_users/...; it now seeds `extra` like every other hook. It also wrote reply_in_thread/reply_to_mode to the process env even inside a secondary profile's scope (first-writer-wins leak, #80099 class); it no longer does. BUZZ_POLL_INTERVAL is bridged through the same table. - `home_channel.name` default when `<X>_HOME_CHANNEL_NAME` is unset is now the literal "Home" for all plugins (irc/ntfy/buzz used the chat id; simplex/teams/photon/google_chat already used "Home", as do the built-in platforms in gateway/config_env.py). - weixin's non-secret tunables (send_chunk_*, rate_limit_circuit_*) now read through the scoped reader instead of raw os.getenv — a secondary profile no longer inherits the default's values. - `extra_or_secret` treats a blank string in extra as unset (falls to env) and an explicit False as a real value, the strictest of the merged copies. - slack `reaction_trigger_target` bridges via str(); `reaction_triggers` comma-joins any list-ish value (was list/tuple/set only) — same env text for every real YAML shape. Docs: website/docs/developer-guide/adding-platform-adapters.md (the template the copies were pasted from) and gateway/platforms/ADDING_A_PLATFORM.md now show the helpers and the scoped reader; gateway/AGENTS.md points at the one implementation. Tests: tests/gateway/test_shared_platform_boilerplate.py — every plugin `_env_enablement` reads only through the scoped getter (parametrized over the 8 plugins, spy on the seam, raw `os.getenv`/`get_env_value` asserted untouched); buzz bridge seeds `extra` for a secondary profile and still bridges env for the default; one home-name rule; extra_or_secret contract; external_fallback rung. Existing tests repointed: tests/agent/test_secret_scope_tier1_migration.py, tests/plugins/platforms/buzz/test_buzz_unscoped_requirement_gate.py.
11 KiB
gateway/ — messaging gateway, adapters, delivery
Applies on top of the root AGENTS.md. Long-form: website/docs/developer-guide/gateway-internals.md.
New platform adapter: follow gateway/platforms/ADDING_A_PLATFORM.md step by step.
Shape
gateway/run.py is the facade; phases live in run_*.py (startup, adapters, inbound, turn, busy,
goals, notifications, shutdown, ...), sessions in session*.py, slash handlers in
slash_commands_*.py mixins, authorization in authz_mixin.py, adapters in platforms/<name>.py
over platforms/base.py. builtin_hooks/ is the extension point for always-registered gateway
hooks (none shipped). The gateway reads user YAML raw (run.py + config.py), not through
DEFAULT_CONFIG — a key the CLI sees but the gateway doesn't means you're on the wrong loader
(hermes_cli/AGENTS.md). Each adapter picks a base toolset (Telegram → "messaging").
Slash commands: handlers are looked up by name through _command_handler_table; a command is
listed in _IDLE_COMMANDS or _PLAIN_COMMANDS (works mid-run) in run_busy.py. No
if canonical == ... chains. Registry + adding a command: hermes_cli/AGENTS.md.
The gateway has TWO message guards — both must bypass approval/control commands
While an agent is running, an inbound message passes two sequential guards: (1) the base
adapter (platforms/base.py) queues it in _pending_messages when session_key in self._active_sessions; (2) the runner (run.py/run_busy.py) intercepts /stop, /new,
/queue, /status, /approve, /deny before they reach running_agent.interrupt(). Any new
command that must reach the runner while the agent is blocked (approval prompts) MUST bypass BOTH
guards and be dispatched inline — never via _process_message_background(), which races session
lifecycle.
Streaming delivery contract (stream-is-the-message adapters)
Adapters with draft_stream_is_message = True (relay Slack native streaming) keep ONE cumulative
native stream per turn; the stream IS the final message. Four invariants, each from a live
duplicate-final incident (NS-658 canary ledger, hermes#85796 / gateway-gateway#210); violating any
re-creates a duplicate or frozen stream:
- Draft frames are prefix-stable. Frame N must be a string prefix of frame N+1. Never mutate
drafts per tick — no fence-closing (
ensure_closed_code_fences), cursor suffix, segment-state resets at tool boundaries, or mrkdwn conversion. A non-prefix frame forces a whole-snapshot re-append ("stacked copies"). The finalize path may still transform the real final. - The consumer declares the final; the adapter never guesses.
finish(final_text)carries the completedfinal_response(verifier footer, completion explainer included). New post-stream augmentation MUST ride this payload — mutatingfinal_responseafter the seal re-opens thedelivered_final_matchesmismatch → corrective duplicate send. - Interim sends carry
metadata["_interim_send"] = True. Any consumer-sideadapter.send()that is not the turn-final (commentary, segment-tail flushes) must set it or seal-interception seals the live stream with interim text. Seal-interception exists at BOTH egress doors (send()andsend_for_platform()); a new egress door needs the same two checks. - Reconcile by edit, never by plain send. A lane delivering a final beside a sealed stream
(queued follow-ups, media-accompanied finals) first tries
edit_messageon the consumer'smessage_id; plainsend()only when no editable message exists. A sealed native stream is a regular message —chat.updateworks (live-verified).
Contract tests: tests/gateway/test_stream_final_contract.py (mutation-checked). Slack ground
truth: chat.*Stream speaks STANDARD markdown, not mrkdwn; stopStream.markdown_text APPENDS;
startStream/stopStream are Tier 2 (~20/min). Check draft_stream_is_message is True —
MagicMock adapters in older tests auto-create truthy attributes.
Background process notifications
terminal(background=true, notify_on_complete=true) starts a gateway watcher that detects
completion and triggers a new agent turn. Verbosity: display.background_process_notifications
(or HERMES_BACKGROUND_NOTIFICATIONS): concise (default; one line, failures append an output
tail), all (running updates + final raw output), result (final raw output only), error
(final raw output only on non-zero exit), off.
The idle completion watcher also drains watch_match / watch_disabled; no user follow-up is
required. Notify-off drains these without waking. Transport failures are retried; unavailable
durable completion owners/transports do not spend delivery attempts. Profile-namespaced process
events keep their owning adapter, including raw progress/final notices. Adapter acceptance is
at-least-once admission, not proof that a model turn or final outbound reply completed. API-server
async completions remain durable delivery rows, never autonomous new model turns. Require the
admit_internal_event receipt for completion/watch injection: a handler returning None is not
acceptance. Refused admission refunds every claimed batch sibling without spending an attempt;
actual delivery errors keep their bounded retry policy. Recognized raw API routes resolve after
persisted messaging origins and defer quietly when unavailable; malformed routes still warn.
Cron execution has its own session. Eligible continuable deliveries may mirror or seed the
reply-facing conversation: origin, origin-less home fallback, user-written bare-platform home,
or opted-in explicit targets. all expansions do not gain home mirror eligibility. Mirrored
briefs are labelled user turns appended at a turn boundary, preserving role alternation
(cron/AGENTS.md).
/login (off-turn, paired DM only)
/login is registered in hermes_cli/commands.py with busy_policy="dispatch" and
desktop="settings", listed in run_busy.py::_PLAIN_COMMANDS, and handled by
GatewayLoginCommandsMixin (gateway/slash_commands_login.py). It refuses outside a paired DM:
chat_type in {"dm","private"}, a truthy chat_id, and a platform whose "dm" really is a paired
conversation — ntfy, raft and a2a all report chat_type="dm" for a broadcast topic, a channel and
an agent peer, so posting a consent link there would publish it.
It binds the whole install. The sign-in writes the singleton providers.nous, so whoever
approves the code owns this gateway's inference and connectors for every chat it serves. Slash
gating is opt-in (gateway/slash_access.py): with no allow_admin_from set, every user allowed to
DM the bot can run it. Operators of shared gateways must set it.
The handler returns its ack at once and drains anon_auth.run_sign_in on a private single-worker
executor (_login_executor), never the shared 10-thread gateway pool — a promotion wait can last
the code's full expiry, and a live worker in the shared pool makes shutdown skip the SessionDB
close/checkpoint. One attempt per process, stamped with the identity that started it: the same
identity's second /login supersedes (the loser ends with the superseded copy pushed into its own
chat); a different identity is refused. task.cancel() cannot interrupt a blocking poll inside a
worker thread, so shutdown and supersede both work through attempt.cancelled, which
wait_for_promotion now polls on a ≤1 s tick. A shutdown-cancelled attempt pushes nothing — the
task is dying and its adapters may already be gone — but if the server had already completed the
transfer it still persists, because that transfer is irreversible.
On completion the handler evicts every cached agent still on nous/welcome and clears any
session model override pinned to it. It does not switch agents in place and does not write a model
override: settle_after_upgrade already moved model.default/model.base_url in the config, every
turn re-resolves the config and the credentials, and _agent_config_signature already forces a
rebuild when the route changes — so an in-place swap buys no cache warmth and an override would pin
an expiring access token that nothing refreshes.
Gateway lifecycle vs. the Desktop app
hermes serve (control plane, desktop-spawned child) dies with the app — by design. The messaging
gateway (gateway run) SURVIVES the app: the serve backend's /api/gateway/* endpoints spawn it
detached (_spawn_hermes_action — start_new_session / DETACHED_PROCESS), so before-quit's
SIGTERM never reaches it and bots keep running. The known breach is the Windows shim-unlock
teardown (taskkill /T /F on venv-shim holders, #85265), which exists to let updates proceed and is
replaced by #92091's pause-for-update. Do NOT "fix" gateway-dies-with-app by re-parenting the
gateway under the backend, and do NOT "fix" update locks by widening the tree-kill. Gateways stamp
code_sha/code_version into gateway_state.json (status.py) so the updater can verify a fleet.
Profiles and secrets in adapters
- Token locks. An adapter that connects with a unique credential (bot token, API key) calls
acquire_scoped_lock()fromgateway.statusinconnect()/start()andrelease_scoped_lock()indisconnect()/stop(), so two profiles cannot share one credential. Canonical:plugins/platforms/irc/adapter.py. - Multiplex profile-scoped env reads MUST fail closed — never borrow from
os.environ(agent/secret_scope.py; #72348, #86905). Undergateway.multiplex_profiles,os.environholds the DEFAULT profile's values; a secondary profile's.envexists only in its secret scope, installed per turn by_profile_runtime_scope. All profile-level env config — credentials (app_secret, tokens) AND authorization (FEISHU_ALLOWED_USERS,{PLATFORM}_ALLOW_ALL_USERS,GATEWAY_ALLOW_ALL_USERS,group_policy,allow_bots) — is read scope-aware: adapters viagateway.platforms._shared.get_scoped_secret(the ONE implementation; adapters import it as_get_scoped_secret;extra_or_secret/seed_extra_from_env/env_is_connectedbuild on it), gateway authz via_shared.platform_gate_env(imported byauthz_mixin.pyas_auth_env). Scope installed + multiplex active → a scoped miss returns the default, NEVERos.environ(a leaked allowlist skips the allow-all check and silently rejects every secondary-profile sender, #86905). The unscoped default-profile path (UnscopedSecretError) and single-profile deployments keep theos.environread — there it IS the profile's own value. Never re-implement the reader in an adapter (thetry get_secret / except UnscopedSecretError: os.getenvshape drifts into a fallback-after-miss leak); import the shared one.tests/gateway/test_shared_platform_boilerplate.pyasserts every plugin's_env_enablementreads only through it.
Tests
tests/gateway/. Adapter tests exercise the real delivery path with a fake transport; never
assert on hardcoded platform lists or command counts (root: no change-detectors). Session-key and
guard behaviour are invariants worth a test; platform API quirks belong in connector comments +
tests, not in prose.