#75017: Telegram polling conflict retry used drop_pending_updates=False,
starting a new getUpdates session that immediately got 409'd by the
previous still-expiring session — creating the very conflict it was
trying to recover from. Switch to drop_pending_updates=True so Telegram
terminates stale sessions. Also add a recovery-generation guard so the
first transient getUpdates success after a retry doesn't reset the
conflict counter back to 0 (defense-in-depth from PR #75096).
#75153: The WAL-reset warning always said 'hermes update can repair'
even for git/pip/system Python installs where it can't. Now uses
detect_install_method() + recommended_update_command_for_method() to
give a context-appropriate hint (hermes update for git, docker pull for
docker, nix message for nix, generic install hint as fallback).
`DiscordAdapter.disconnect()` cancelled the bot task before tearing down voice
clients. `leave_voice_channel()` ends in `await vc.disconnect()`, and discord.py
sends a voice state update over the main gateway websocket and then waits for the
voice socket to close. The bot task is the loop running that gateway connection,
so cancelling it first left the handshake with no transport: it could never
complete and blocked until the caller's shutdown timeout fired.
The effect was a fixed ~5s penalty on every shutdown with a voice connection
open, ending in "discord disconnect timed out after 5.0s - forcing continue",
with the voice disconnect abandoned rather than completed.
Measured on a live gateway with a voice connection open in both cases:
before: timed out after 5.0s, all adapters disconnected at +5.29s
after: discord disconnected (0.12s), all adapters disconnected at +0.46s
Moving the voice-cleanup loop above `_cancel_bot_task()` preserves the
zombie-client protection its comment describes: the bot task is still cancelled
before `client.close()`, just after voice teardown rather than before it. Voice
teardown is the one step that still requires a live gateway.
Adds a regression test asserting the ordering. It fails on the previous ordering
at index 1 with `cancel_bot_task != leave_voice_channel:111`.
Fixes#76044
The no-SDK fallback guards check '"Name" in globals()' — correct on
main where a failed module-level import leaves those names undefined,
but the deferred-import port pre-binds every SDK name to None, so the
guard was always true and the fallback paths called .builder() on None
(AttributeError) wherever lark_oapi isn't installed. Local runs passed
because lark IS installed here; CI's default env has no feishu extra.
Rewrote all 14 guards to 'is not None', which is correct under both
conditions. Verified by simulating CI with a lark-blocking meta_path
hook: 74 passed, 18 skipped (the skipUnless set), zero failures.
Salvage of #57657, ported onto the plugin layout (the adapter moved
from gateway/platforms/feishu.py to plugins/platforms/feishu/adapter.py
since the PR's base). lark_oapi takes seconds to import and holds the
GIL doing it; the module-level import made every gateway boot pay that
cost even with Feishu unconfigured.
- _load_lark_oapi() with double-checked locking binds the SDK globals
on first use; connect() and _standalone_send() call it via
asyncio.to_thread so the loop never blocks on the import.
- probe_bot() also calls _load_lark_oapi() (sync context) so the SDK
probe path is preserved rather than silently degrading to the HTTP
fallback before a first connect.
- check_feishu_requirements() is install-only and no longer rebinds
globals; test_feishu.py gets a setUpModule that binds them eagerly
for tests that inject fake clients.
Includes the dedicated lazy-import test file (check-does-not-import,
connect-loads-on-worker-thread).
_adapter_config_interactive() imported get_env_var and set_env_var from
hermes_cli.config, but these do not exist — the actual functions are
get_env_value and save_env_value. This caused an ImportError at runtime,
breaking the entire LINE platform adapter setup.
Pain before: Any user who ran the LINE adapter setup function would get:
ImportError: cannot import name 'get_env_var' from 'hermes_cli.config'
Fix: Import the correct functions with aliased local names:
from hermes_cli.config import get_env_value as _get_env, save_env_value as _set_env
Also fixed an indentation bug introduced during the fix: the 'if value: _set_env()'
block was incorrectly nested inside the except clause.
PR: N32 (hermes-agent audit)
Wrap the masked-link destination in angle brackets so Discord does not
unfurl an OG-preview embed under every tool progress bubble. quote()
percent-encodes any <> inside the URL itself, so the wrapper cannot be
broken out of.
Follow-up fixes for the combined Matrix crypto salvage (#71073,
#71543, #71547):
1. Construct _pickle_key from client.device_id (resolved from whoami)
instead of self._device_id (the configured value). Without this, when
#71543 makes the token's real device win over a stale
MATRIX_DEVICE_ID, the pickle key is built from the stale value and
the Olm account is stored under a key that can never be looked up
again — perpetuating the same decryption failure the PRs aim to fix.
2. Skip _migrate_legacy_crypto_pickle when the store was just deleted
by _reset_crypto_store_if_device_changed — there is no account to
migrate. Also check the migration return value and log a warning on
failure instead of proceeding to olm.load() which fails with a
cryptic BAD_ACCOUNT_KEY.
3. Add a local dict cache (_enc_info_cache) to _CryptoStateStore so the
homeserver fallback in get_encryption_info() does not make a network
round-trip on every is_encrypted() call. MemoryStateStore does not
implement set_encryption_info, so the existing cache-back is a no-op.
4. Log homeserver encryption-info query failures at DEBUG level instead
of silently returning None (which would cause OlmMachine to treat an
encrypted room as unencrypted).
5. Update _CryptoStateStore docstring to mention the homeserver fallback.
The account was written under the new pickle key before sessions were
re-pickled. The account is effectively the migration's commit marker —
once it reads under the current key, the fast path short-circuits every
later startup — so a sweep that errored or was interrupted left the
remaining legacy-key sessions stranded permanently with no retry.
Sweep first, commit the account last, and return False on sweep failure
so the migration is retried on the next start.
Also corrects the unreadable-row log: it claimed rows were being dropped
while no DELETE was ever issued. Such rows are left in place (already
unusable; deleting crypto material on a guess is not worth it) and the
message now says so.
Adds session-sweep coverage, which was previously absent: rows rewritten
under the current key, rows already current left alone, unreadable rows
left in place, and a failed sweep that leaves the account uncommitted.
The existing migration test now fakes the olm C-extension so the suite
no longer requires libolm.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Olm account pickle key is derived from the account ID plus the
configured device ID (acct:device_id). If the crypto store's account was
created before MATRIX_DEVICE_ID was set — e.g. the very first password
login, where the device ID is only known after connecting — it gets
pickled under "<acct>:default". Setting MATRIX_DEVICE_ID afterwards (a
reasonable thing to do once you know the device ID you want to pin)
changes the derived pickle key, and every subsequent unpickle attempt
fails with BAD_ACCOUNT_KEY. In optional-E2EE mode that failure is
swallowed and encryption silently stays disabled instead of surfacing an
actionable error.
_migrate_legacy_crypto_pickle() detects the BAD_ACCOUNT_KEY failure,
tries the known legacy pickle keys, and re-pickles the account (plus
every stored olm/megolm session — sessions share the same pickle key, so
migrating only the account would leave them unreadable on the next
decrypt and silently break key sharing with peers) under the current
key. It only reports failure when no known key can unpickle the
account, with a log message pointing at what changed.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
connect() resolved client.device_id as `self._device_id or resolved_device_id`,
so a configured MATRIX_DEVICE_ID masked the live whoami() device. With
persisted A, configured A and a rotated token reporting B, the reset
compared A to A and never fired — exactly the token-rotation case this PR
claims to handle.
An access token is bound to one device and the homeserver only accepts key
uploads for that device, so a configured value naming a different one
cannot work. The live whoami() device now wins on conflict and logs an
error naming both. The configured value is still preferred when whoami()
reports no device.
Adds a connect()-level regression for persisted A + configured A +
whoami B, and corrects test_connect_uses_configured_device_id_over_whoami,
whose stated premise this inverts.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The crypto store is keyed by Matrix user ID, not device ID, so swapping in
a new access token (which mints a new device_id) silently inherits the
previous device's Olm account. That account's identity keys can never be
published under the new device ID, and the pickle key embeds the old
device ID anyway — the result is stale-key mismatches and cross-signing
signatures the homeserver refuses to replace, degrading E2EE in ways that
are hard to diagnose (peers silently withhold room keys).
_reset_crypto_store_if_device_changed() compares the store's persisted
device ID against the live one at connect time and wipes the store on
mismatch, so a fresh Olm account is generated for the new device instead
of reusing stale key material.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
_CryptoStateStore.get_encryption_info only consulted mautrix's in-memory
MemoryStateStore, which has no record of m.room.encryption for rooms the
bot joined in the past (the raw-sync path never feeds those state events
through set_encryption_info). On a fresh crypto store this returns None
for all previously-joined rooms, so OlmMachine reports them as unencrypted,
never tracks peer devices, and silently drops all inbound messages.
Fix: pass the mautrix Client into _CryptoStateStore so get_encryption_info
can fall back to a live GET /_matrix/client/v3/rooms/{room_id}/state/
m.room.encryption query when the in-memory store returns None. The result
is cached back via set_encryption_info so subsequent lookups (and
OlmMachine device tracking) hit the fast path.
- New website/docs/user-guide/messaging/a2a.md: when/where to use A2A
(cross-machine, specialist peers, being callable) vs delegation/kanban
for same-machine multi-agent; enable, outbound tools, inbound surface,
security model, env reference, quick test, troubleshooting. Registered
in sidebars.ts and the messaging index.
- README/DESIGN/plugin.yaml/protocol.py prose updated to name the A2A
v1.0 canonical discovery path /.well-known/agent-card.json (the code
already served both; only the docs lagged).
A2A authenticates every inbound request via bearer token in do_POST
(401 before dispatch). Without overriding authorization_is_upstream=True,
the gateway's per-platform user allow-list ({PLATFORM}_ALLOWED_USERS)
rejects A2A peers because their identity is a token-derived name or
pod IP, not a platform account in any configured allow-list. Messages
never reach the agent and callers get empty replies.
This is authorization delegated to the bearer-token transport, not a
fail-open: every request is 401'd if the credential is wrong.
Reported by kuangmi-bit (PR #41711 comment, Jun 27).
Attribution: gfdsa's a2a-hermes repro fixture (LOCAL PATCH triad-hermoperator).
The other two patches from gfdsa's fixture were already in our branch:
- k8s Agent Card URL derivation (_request_public_url, commit ea59b85c2)
- send() gating on metadata['notify'] (reply-capture fix, commit ea59b85c2)
All 168 tests pass (151 unit + 17 integration).
Two bugs reported by gfdsa (PR #41711 comment, Jul 12) that break the
official a2a-sdk 1.1.0 Python client:
1. Task objects serialized non-spec createdAt/lastModified fields.
The A2A v1.0 Task proto (lf.a2a.v1.Task) only has id, contextId,
status, artifacts, history, metadata. Strict ProtoJSON parsers
reject unknown fields with ParseError. Removed both fields from
build_task(); created_at param kept for call-site compatibility.
2. SSE streaming frames were not JSON-RPC wrapped. A2A v1.0 §9.4
requires data: {"jsonrpc":"2.0","id":...,"result":{StreamResponse}}.
sse_data() now accepts req_id and wraps in JSON-RPC envelope.
sse_done() changed from 'data: {}' to SSE comment ': done' so
SDK doesn't try to parse an empty JSON-RPC response.
All call sites in adapter.py (_emit_terminal, _rpc_message_stream,
_rpc_tasks_subscribe) updated to thread req_id through.
Tests updated: 153 pass (151 unit + 17 integration, including 2 new
tests for JSON-RPC envelope wrapping and fallback behavior).
Refs: gfdsa/a2a-hermes reproduction repo
## File/data Parts (v1.0 unified Part)
- file_part(url=, raw=, filename=, media_type=) builds v1.0 file Parts
- data_part(data, media_type=) builds v1.0 data Parts
- message_with_parts(role, parts, context_id=) builds Messages with mixed Part types
- extract_text now renders file/data Parts into the text stream:
- File with URL: '[file: name] https://url (mediaType)'
- File with raw: '[file: name] N bytes base64-encoded (mediaType)'
- Data: '[data (mediaType)]\n{json}'
- v0.3 file (file.fileWithUri) and data (kind=data) still accepted
- Outbound replies stay text-only (agent produces text)
## Push notification config full CRUD
- get_push_config(task_id, config_id) — retrieve by task, optionally by configId
- list_push_configs(task_id) — list all configs for a task (max 1 per task)
- delete_push_config(task_id, config_id) — remove a config
- New JSON-RPC methods: tasks/pushNotificationConfig/get, /list, /delete
- New adapter handlers: _rpc_push_config_get, _list, _delete
- All return spec-shaped PushNotificationConfig with configId + createdAt
## Tests
- 6 new unit tests for Part builders + extract_text with file/data
- 13 new unit tests for push config get/list/delete (happy + error paths)
- 2 new integration tests over real HTTP:
- test_mixed_parts_delivered_to_agent: file URL + data JSON reach agent
- test_push_config_crud_over_http: full create→get→list→delete cycle
- Old test_extract_text_skips_non_text_parts replaced (now renders, not skips)
Total: 151 tests (134 unit + 17 integration), 0 failed.
DESIGN.md updated: file/data Parts and push config CRUD removed from
out-of-scope list.
Consolidates 5 follow-up PRs onto the a2a-work branch:
1. Reply-capture fix (#56437): adapter.send() now only resolves the
blocked RPC Future when metadata['notify'] is True (the gateway's
final-reply marker). Interim sends no longer short-circuit the
response. Also accepts **kwargs in connect() for reconnect compat.
2. Slash command passthrough (#53743): wrap_inbound() passes /-prefixed
text through unwrapped so the gateway command processor sees it.
Fixes /sethome deadlock during A2A onboarding. Documented security
trade-off (bearer auth at network layer compensates).
3. Routable URL in Agent Card (#53736): _build_card() now derives URL
from A2A_PUBLIC_URL env > X-Forwarded-Host/Host header > bind host.
Fixes k8s bug where Agent Card advertised 0.0.0.0.
4. contextId multi-turn memory (#53756): _handle_inbound_task() now
checks top-level params.contextId first (A2A spec), falls back to
params.message.contextId (legacy). Outbound a2a_call also sends
contextId at both top-level and inside message.
5. Type checker fixes (#53759): TypedDict for _SCHEMAS, _FunctionSchema,
_ToolSchema. Removes str() band-aid casts.
All 45 tests pass including new tests for each fix.
Zero core files modified — only plugins/platforms/a2a/ and tests/.
Credits: @davidrobertson (#56437), @knoal (#53736, #53743, #53756,
#53759), @kuangmi-bit (slash command bug report), @gfdsa (k8s URL bug
report), @shivasymbl (#45996 userContext OBO).
Live Tier-3 testing (CLI agent -> a2a tools -> live peer gateway -> model)
surfaced two bugs the kwarg-style unit tests masked:
1. registry.dispatch calls handlers as handler(args, **kwargs) — args is the
whole dict positional. The handlers used keyword params (url=, agent=), so
the dict bound to the first param and .strip() raised
'dict object has no attribute strip'. Rewrote all three handlers to take
args: dict (matching the spotify/google_meet convention). Added a
registry-dispatch regression test that exercises the real call path the
direct-kwarg tests never hit.
2. The model repeatedly reached for agent_name= instead of agent= (6 retries
before success). Accept agent_name/name and message/text/task aliases so a
reasonable guess succeeds first try.
Verified live: client agent discovers the peer's Agent Card, calls it, and
gets the reply back (PONG round-trip confirmed on both client audit log and
peer conversation log). 39 plugin tests pass.
Single platform-adapter plugin under plugins/platforms/a2a/ — zero core
edits — that supersedes the entire A2A PR/issue cluster. Built on the
ctx.register_platform + ctx.register_tool surface the codebase now exposes.
Outbound (a2a toolset): a2a_discover / a2a_call / a2a_list let the agent
call any A2A-compliant peer over JSON-RPC message/send. Inbound (platform
adapter): a stdlib http.server serves an Agent Card at
/.well-known/agent.json and routes incoming tasks into the agent's LIVE
gateway session (the #11025 insight) — same agent, full memory — returning
the reply over A2A.
Security on by default: no bearer token => 127.0.0.1-only bind; constant-
time bearer auth; inbound prompt-injection filtering + untrusted-peer
framing; outbound credential redaction; append-only audit log; per-context
conversation persistence outside the compaction pipeline.
Stdlib only (no a2a-sdk). 37 tests incl. a live HTTP round-trip
(card + message/send + reply) and a bearer-auth 401 path.
Builds on the adapter list_channels() hook (cherry-picked from #43545 by
@Guoen0):
- plugins/platforms/simplex: implement list_channels() — enumerates
contacts (/contacts) and groups (/groups) over the live daemon
WebSocket into the channel directory. Returns None when the WS is
down so the directory falls back to session discovery instead of
wiping known targets.
- hermes send --list: merge configured-but-undiscovered platforms into
the listing. Previously a platform configured only via env (e.g. a
fresh SimpleX setup used for outbound sends) was silently omitted,
leaving users guessing at platform names.
- format_directory_for_display(): accept an explicit platforms view and
render empty platforms with a targeting hint instead of hiding them.
- docs: simplex hermes-send section.
Reported by Fedpostoffice on Discord (simplex missing from
hermes send --list; guessed platform names simplex-chat/simplex-relay).
Simplify-pass follow-up on the #68157 salvage (regression-neutral \u2014 the
old temp-file version was equally bare): capture ffmpeg's -loglevel
error output so a CalledProcessError carries the real message, and log
it at the voice-input catch site. Parity with transcription_tools'
ffmpeg call sites. Live-verified: forced ffmpeg failure produces the
captured message in the exception.
pcm_to_wav staged every captured utterance in a NamedTemporaryFile just to
hand ffmpeg an input path, then unlinked it. Feed the PCM to ffmpeg's stdin
instead: one fewer file created, written, read back and removed per voice
utterance, and the try/finally cleanup goes away with it.
The WAV output deliberately still goes to output_path rather than being
captured from stdout. ffmpeg cannot seek on a pipe, so a piped WAV is
written with placeholder 0xFFFFFFFF RIFF/data chunk sizes -- Python's wave
module then reports 2147483647 frames for a 1s clip, and strict readers
misjudge the length. Writing to the real path lets ffmpeg seek back and
patch the header.
Tests cover both halves: that the PCM goes over stdin with no temp file,
and (when ffmpeg is installed) that the resulting header reports the true
frame count.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- discord/telegram/slack/matrix standalone senders read bot tokens via
get_secret (in-turn: they run inside an installed scope — cron scheduler
and delegate spawns propagate it), never borrowing another profile's
env-bridged token under multiplex.
- telegram webhook-secret and matrix access-token/password startup reads
use the Slack pattern (#59739): get_secret, falling back to os.environ
only on UnscopedSecretError.
Follow-up to the salvaged #59076 commit:
- Replace the bare get_secret import with a module-level Slack-pattern
helper (_get_esecret): try get_secret, on UnscopedSecretError fall back
to os.getenv. The DEFAULT profile's email adapter constructs UNSCOPED
under multiplexing, where a bare get_secret raises and would crash the
email path on startup — the exact WhatsApp defect fixed in 5438e9c629
(whatsapp_common._get_wsecret).
- Extend scope coverage to the remaining scope-blind reads:
EMAIL_IMAP_PORT / EMAIL_SMTP_PORT / EMAIL_POLL_INTERVAL (_esecret_int
replacing utils.env_int) and EMAIL_TRUST_FROM_HEADER (_esecret_bool
replacing utils.env_bool).
- Add tests: default-profile unscoped-under-multiplex construction, and
scoped ports/trust-flag no-environ-inheritance.
The email adapter (plugins/platforms/email/adapter.py) read
EMAIL_ADDRESS, EMAIL_PASSWORD, EMAIL_IMAP_HOST, EMAIL_SMTP_HOST,
EMAIL_ALLOWED_USERS, and EMAIL_ALLOW_ALL_USERS via os.getenv()
directly. In a multiplexed gateway, os.environ holds the default
profile's .env values, so every secondary profile inherited the
default profile's email credentials instead of its own.
This was a sibling of the api_server env-leak bug (#52307/#50051):
the same os.getenv→get_secret migration that PR #50094 applies to
gateway/config.py, but for the email adapter itself, which neither
PR #50094 nor #51374 covers.
Changes:
- plugins/platforms/email/adapter.py: replace os.getenv with
agent.secret_scope.get_secret for all EMAIL_* credential reads
(adapter __init__, check_email_requirements, _allowlist_in_effect,
_dispatch_message allowlist gate, _send_email SMTP helper).
- gateway/config.py: add _getenv/_getenv_str/_getenv_int helpers
(from PR #50094) and replace os.getenv with _getenv for the email
block in _apply_env_overrides, so config.platforms[EMAIL].extra
is populated from the scoped value.
- tests/gateway/test_email_secret_scope.py: 5 new tests covering
scoped credential reads, environ fallback without scope, missing-
key-no-leak, allowlist scoping, and check_email_requirements scoping.
Related: #50051, #52307, PR #50094, PR #51374
FEISHU_APP_SECRET/FEISHU_ENCRYPT_KEY/FEISHU_VERIFICATION_TOKEN, WECOM_SECRET,
PHOTON_PROJECT_SECRET/PHOTON_SIDECAR_TOKEN (adapter + auth.load_project_credentials)
and BUZZ_PRIVATE_KEY now read through _get_scoped_secret.
NTFY_TOKEN, HASS_TOKEN, TWILIO_ACCOUNT_SID/TWILIO_AUTH_TOKEN and
DINGTALK_CLIENT_SECRET now read through _get_scoped_secret. Also replaces
the SMS adapter's bare os.environ["TWILIO_AUTH_TOKEN"]/["TWILIO_ACCOUNT_SID"]
__init__ reads (KeyError-prone) with helper reads defaulting to "".
Route IRC_SERVER_PASSWORD/IRC_NICKSERV_PASSWORD, LINE_CHANNEL_ACCESS_TOKEN/
LINE_CHANNEL_SECRET, TEAMS_GRAPH_ACCESS_TOKEN/TEAMS_CLIENT_SECRET and
MATTERMOST_TOKEN reads at __init__/availability/standalone-send time through
a module-level _get_scoped_secret helper (get_secret, UnscopedSecretError ->
os.getenv fallback), mirroring whatsapp_common._get_wsecret / Slack #59739.
Scoped miss returns the default — no cross-profile environ borrow.
Review follow-ups on the #76357 salvage:
- _message_reference_from_ids + _reply_reference_for_send collapse the
3x duplicated MessageReference construction (naming mirrors telegram's
_reply_to_message_id_for_send).
- The overflow elif's comment claimed PartialMessage has no to_reference;
discord.py 2.7.1's PartialMessage does (message.py L1901) — the branch
is belt-and-suspenders for duck-typed priors, now labeled as such.
Every reply paid one extra Discord API round trip: the text send path,
the voice send path, and the edit path each called fetch_message() just
to obtain a reference or an editable handle. Discord resolves
message_reference payloads from ids alone, and PartialMessage.edit()
works without a fetch — so build MessageReference directly (with
fail_if_not_exists=False, preserving the deleted-target behavior the
existing send-side 10008 retry already covered) and use
channel.get_partial_message() for edits. Overflow continuations keep
threading via an ids-built reference fallback for PartialMessage.
Measured by call count (deterministic): reply sends and edits now make
ZERO fetch_message calls where they made 1 per reply and 1 per edit
(including every streaming edit tick).
Tests: pin that first-mode replies construct the reference without any
fetch, deleted-target retry test updated to assert fetch await_count==0
(retry now happens purely send-side), overflow/edit mocks retargeted
from fetch_message to get_partial_message (any fetch regression breaks
all five). Note: 4 discord-suite failures are pre-existing ordering
flakes — identical with the change stashed on clean main.
Follow-ups on the #75382 salvage (review findings):
- _wenv/_get_wsecret now catch UnscopedSecretError and fall back to
os.getenv for the DEFAULT profile's adapter, which constructs and sends
outside any _profile_runtime_scope under multiplexing — a bare
get_secret would crash its WhatsApp path (fixing one profile by
breaking another). Same pattern as Slack SLACK_APP_TOKEN (#59739) and
the Matrix recovery key. Scoped misses still return the default — no
cross-profile borrow.
- bridge_env overlay extended to the full WHATSAPP_* set bridge.js
consumes (DEBUG, FORWARD_OWNER_MESSAGES, REPLY_PREFIX,
MAX_MESSAGE_LENGTH, CHUNK_DELAY_MS, SEND_TIMEOUT_MS).
- Removed the always-true conditional on WHATSAPP_MODE injection.
Fix#75349
Root cause:
Under multiplex_profiles, secondary profiles run inside
_profile_runtime_scope which installs a per-profile secret scope via
set_secret_scope. The WhatsApp adapter (and the shared
WhatsAppBehaviorMixin + Cloud API adapter) read WHATSAPP_MODE,
WHATSAPP_DM_POLICY, etc. via raw os.getenv(), bypassing the secret
scope. Since os.environ doesn't contain secondary profile .env values,
the bridge silently falls back to 'self-chat' and rejects all inbound
messages with self_chat_mode_rejects_non_self.
Fix:
- Add _wenv() helper in adapter.py that reads WHATSAPP_* vars through
get_secret() (agent.secret_scope), which honors the active scope.
- Replace all os.getenv('WHATSAPP_*') calls in adapter.py,
whatsapp_common.py, and whatsapp_cloud.py with get_secret()-based
equivalents.
- Inject resolved WHATSAPP_* values into the bridge subprocess
environment so the Node.js bridge (which reads process.env) sees the
profile's own configuration.
Changes:
- plugins/platforms/whatsapp/adapter.py: 37 lines (+ helper, bridge_env
injection, 2 os.getenv→_wenv)
- gateway/platforms/whatsapp_common.py: 13 lines (6 os.getenv→_get_wsecret)
- gateway/platforms/whatsapp_cloud.py: 21 lines (9 os.getenv→_get_wsecret)
- New regression test: 6 test cases covering scope isolation, fallback,
and cross-profile non-leakage.
The Matrix adapter read MATRIX_RECOVERY_KEY via os.getenv, so under
gateway.multiplex_profiles every profile resolved the default profile's
key. That produced "recovery key verification failed: Key MAC does not
match" and broke E2EE for secondary profiles (#69090).
Route the read through agent.secret_scope.get_secret, which honors the
active profile's scope, with an os.getenv fallback for an unscoped read
under multiplex (default-profile startup loop) — mirroring the Slack
app-token pattern (#59739). Applied to both the startup verification
site and the status diagnostic.
Fixes#69090
Track which source seeded the DM allowlist so live intake does not let a
stale env carrier override explicit config, while env-seeded adapters still
reread pairing mutations.
Teknium review on #62947: os.environ.clear()/update around deferred
loaders is unsafe under concurrency and misses teams_pipeline's direct
adapter import.
Defer microsoft_teams binding in the Teams adapter, no-op
dotenv.load_dotenv while the SDK imports, keep api_server explicit
disable, and add SDK-import + load_gateway_config canaries.
Fixes#62935
Under gateway.multiplex_profiles, Discord and Telegram authorization gates
(allowed/ignored channels, allowed users/roles, allow-all flags) were read
from process-global os.environ, populated first-writer-wins by the YAML->env
bridge in each adapter's _apply_yaml_config. The first profile to initialize
pinned its allow/deny lists — and its ALLOW_ALL flags — for every other
profile in the process (issue #72348, incl. the Telegram mirror reported in
the thread).
Fix (per-adapter-instance gate reads, whole class):
- gateway/authz_mixin.py: new _platform_gate_env — scope-authoritative gate
read: under an installed profile secret scope with multiplex active, a
missing key returns the default instead of falling through to os.environ
(which may hold another profile's value). Single-profile behavior is
byte-identical to os.getenv.
- Discord adapter:
- connect() snapshots all gate env vars (_GATE_ENV_KEYS) inside the owning
profile's runtime scope into a per-adapter dict; new accessors
(_get_allowed_channels/_get_ignored_channels/_get_allowed_users/
_get_allowed_roles/_get_no_thread_channels/_discord_allow_all_users/
_gateway_allow_all_users/_get_allow_bots) resolve snapshot -> config.extra
-> scope-aware env, replacing every raw os.getenv gate read: on_message
channel gates, _is_allowed_user allow-all flags, slash authorization,
fail-closed diagnostics, missed-message backfill, bot-message gating,
and _component_check_auth (component buttons).
- _apply_yaml_config always seeds gate values into PlatformConfig.extra
(incl. new allowed_roles / allow_all_users keys) and SKIPS the
process-global env writes when loading a profile-scoped config under
multiplex; the legacy first-writer env bridge is preserved verbatim for
single-profile deployments.
- _resolve_allowed_usernames no longer unconditionally rewrites
os.environ[DISCORD_ALLOWED_USERS] — under multiplex the resolved IDs stay
adapter-local (snapshot refresh); single-profile keeps the env rewrite.
- Telegram adapter (mirror of the same class): intake prefilter and
callback-auth fallbacks, _telegram_auth_env_configured, and the
allowed/ignored chats-topics-threads getters now read via the scoped gate
reader; _apply_yaml_config skips authorization env writes for
profile-scoped loads and seeds free_response_chats/ignored_threads extras.
Regression tests (tests/plugins/platforms/test_discord_gate_isolation.py):
two adapter instances with different allow-lists enforce their OWN lists
order-independently across message, slash, and component gates; negative
allow-all case proves profile A's open-access flag cannot authorize profile
B; username-resolution env-clobber; YAML-bridge seeding/skip matrix; and the
Telegram scoped-reader matrix. Sabotage-verified: reverting either the
Discord snapshot accessors or the Telegram scoped reader fails 12/2 tests
respectively.
Credit: builds on the per-adapter accessor direction of PR #72427
(@JonthanaHanh) and the scope-aware-reader approach validated live on v0.19.0
by @yournetworkplug-ctrl for the Telegram mirror; scope corrections from
jackjin1997's and cal88's analysis in the issue thread (allow-all flags,
unguarded username-resolution env write, per-site channel reads).
Fixes#72348
The #40863 intake prefilter rejected unauthorized DMs whenever an allowlist
existed, so gateway pairing never ran even when the operator set
telegram.unauthorized_dm_behavior: pair (which must win over the #9337
allowlist silence default). Pass those DMs through; groups stay blocked.