de114b3af1
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.