A root-level `webhook:` block (the pre-`platforms:` spelling, still
supported by platform_section) is never copied into platforms_data, so
the removed _PORT_BRIDGE_KEYS table was its only route to `extra` and
the previous commit regressed it (port fell back to 8644). Bridge every
non-typed key of a root block in _bridged_keys with the same typed-key
exclusion and explicit-extra precedence as PlatformConfig.from_dict.
Found by independent review before merge.
`platforms.webhook.port: 9100` (and `routes`, `secret`, api_server `key`/
`cors_origins`, any adapter setting) was silently dropped unless nested
under `extra:` — PlatformConfig.from_dict only read a fixed set of typed
fields. Two partial bridges (a per-platform port/host/secret table in the
loader and an api_server-only block) covered a few keys and had to be
extended for every new one.
from_dict now promotes every non-typed top-level key into `extra`, with an
explicit `extra:` value winning on a clash and typed fields never leaking
into `extra` on a to_dict/from_dict roundtrip. Both hand-written bridges
are removed.
Same direction as PRs #10208/#10211/#10453 (rainow's #10206 diagnosis) and
#20506; those targeted the pre-loader layout.
Fixes#10206
The multiplexing default gateway now serves default + every live named profile
under profiles/. profiles_to_serve(multiplex=True) is a pure directory read
(tombstoned profiles skipped, never mkdir); every reader — gateway served set,
/p/<profile>/ prefixes for api_server + webhook, the named-profile standalone
guard, the Desktop cron ticker (its #108428 standdown for a profile owned by a
running gateway is unchanged) — drops the allowlist parameter.
Config v43 migration deletes the key from user config.yaml; DEFAULT_CONFIG,
GatewayConfig and the top-level yaml bridge no longer carry it.
BREAKING: anyone who set an allowlist now has their excluded profiles served.
Archive or delete a profile you do not want served (Teknium approved).
Under gateway.multiplex_profiles every secondary profile's config loads inside
_profile_runtime_scope, yet every apply_yaml_config_fn hook (feishu, matrix,
whatsapp, slack, dingtalk, discord non-gate keys, telegram non-gate keys) and
gateway/config_loader.py::bridge_core_env_settings still wrote os.environ there.
First-writer-wins: the first secondary with a require_mention / allowlist /
allow_bots / reactions block made that policy the DEFAULT profile's (live:
TELEGRAM_REQUIRE_MENTION written from a secondary load), and secondaries read
the default's env for the same keys.
- gateway/platforms/_shared.py::yaml_env_setter: the one env-write shape for
YAML->env bridges — env wins, skipped under an active secondary scope.
- Every hook now seeds its values into the profile's PlatformConfig.extra and
uses yaml_env_setter; bridge_core_env_settings seeds telegram/signal
require_mention into extra and skips the env write under scope.
- Readers that bypassed extra/scope now consult extra first (matrix flags +
session_scope, slack reactions, telegram reactions/mention_patterns/_extra_bool,
discord reactions/auto_thread/history_backfill/approval_mentions/allow_mentions,
feishu allow_bots, dingtalk mention_patterns, signal require_mention).
Salvages the shape of PR #100604 (whatsapp, earliest report #80099), #100435
(discord) and #100448 (telegram) by @nftpoetrist on top of current main.
Fixes#80099
Co-authored-by: nftpoetrist <264138787+nftpoetrist@users.noreply.github.com>
relay_explicitly_disabled() went through hermes_cli.config.read_raw_config,
which turns a malformed config.yaml into {} and then applies the managed
overlay. load_gateway_config() raises on the same file and falls back to
env + gateway.json WITHOUT the managed layer. With a managed
relay.enabled: false and an injected URL the two readers disagreed: the
loader kept relay enabled and suppressed native Slack, while startup
refused to register relay — a profile with no route at all.
Extract the loader's file selection into config_loader.read_yaml_layers()
and have both consumers use it; the predicate mirrors the loader's
fallback (no YAML layer) on a parse error. Regression test fails on the
previous predicate.
Narrow the PR's load_yaml_layer rewrite to the one behavior the relay
fix needs: an absent user file is an empty layer, so administrator-
pinned values (a managed relay opt-out among them) still reach the
gateway on a host with no config.yaml.
Everything else returns to the loader's existing contract. A MALFORMED
user file still raises so load_gateway_config() keeps emitting its
'Failed to process config.yaml — falling back to .env / gateway.json'
warning instead of silently applying the managed layer over the broken
one; the loader keeps its own yaml read rather than importing the CLI's
raw reader. Changing the malformed-file contract touches every platform
and belongs in its own PR.
The loader stamps _enabled_explicit only from YAML blocks; a gateway.json
`enabled: false` is re-enabled by env presence for slack, telegram and
everyone else. Tagging the marker for platforms.relay alone in the legacy
reader made relay the one platform whose gateway.json verdict beat the
env — a per-platform pin nobody would find later. If legacy opt-outs
should become authoritative, that is one rule for all platforms in its
own PR.
relay_explicitly_disabled() now reads the marker the loader itself sets
instead of re-deriving the verdict from 'enabled' in block, so the
standalone predicate and load_gateway_config() cannot disagree.
Every relay object (adapter, media client, WS transport) is constructed
inside the registry factory that register_relay_adapter() installs, and
that call plus self_provision_relay()/send_relay_policy() all resolve
through relay_url(). So the opt-out has three reachable boundaries:
relay_url(), relay_fronted_platforms() (env-sourced, standalone cron)
and the force=/url= bypass in register_relay_adapter().
The guards on RelayAdapter.connect, _get_media_client,
RelayMediaClient.enabled/download and every WS dial/redial were
unreachable unless config flipped on a RUNNING gateway — which the docs
in this PR say is unsupported. Half-honoring that flip is worse than not
honoring it: inbound kept flowing over the open socket while media
downloads silently returned None and the next redial terminated.
Drop those five guards, the _RelayDisabled terminal exception they
needed, and the legacy-JSON lru_cache that existed only to make a full
config merge affordable on the per-message path. The activation-time
predicate runs a handful of times per boot; the shared raw-config and
managed-scope readers already cache on mtime.
Read an empty raw user layer on absent, invalid or unreadable YAML before applying the existing managed overlay. Preserve raw defaults and platform precedence so managed relay opt-out prevents env-exclusive native suppression.
Prove absent-YAML startup/native routing, managed enabled and URL-only controls, managed leaf precedence, and invalid-file siblings. Mutation: 17 failed/30 passed. Canonical 50-file suite: 682 passed.
Carry legacy relay enabled intent through the canonical env sweep without changing other platforms. Read opt-out config through the cached non-bootstrap reader and existing managed overlay. Reproduce both Salt findings before fixes, cover native startup/delivery and isolated cold imports, and mutation-check the regressions.
For each issue anchor present in BASE 63279301bc non-test .py and absent on HEAD, the BASE comment/docstring block was re-attached at the HEAD location of the code it explained (matched by the distinctive code line / enclosing def). Sentences already covered by an existing HEAD comment were deduped; the issue number always survives. Insert-only: no code lines changed.
- load_gateway_config (536 LOC) is now a thin orchestrator: legacy gateway.json
-> config_loader.load_yaml_layer -> GatewayConfig.from_dict -> env overrides
-> validation. The yaml phase lives in gateway/config_loader.py as small
functions driven by tables: _TOPLEVEL_BRIDGE (23 top-level/nested
gateway.<key> bridges with 5 fallback modes), _SHARED_KEYS (28 per-platform
keys copied into extra, with per-platform restrictions and transforms) and
_PORT_BRIDGE_KEYS. Logger name kept as "gateway.config".
- GatewayConfig.from_dict: shared pick()/key_label() helpers replace the
repeated "top-level key present else nested gateway.<key>" blocks; warning
order preserved.
- _normalize_unauthorized_dm_behavior / _normalize_notice_delivery unified into
_normalize_choice; _ensure_platform_extra_dict -> _dict_slot (also used by
persist_home_channel); _getenv_int removed (dead since the env pass moved to
config_env, which has its own _int_or).
- Platform._missing_: one _add_pseudo_member helper for both branches.
- Single warning sites in _coerce_optional_positive_int and
coerce_systemd_watchdog_seconds; _validate_gateway_config placeholder pass
flattened; small to_dict/getter collapses. Ruff F401/SIM102 clean.
- tests/hermes_cli/test_config_read_guard.py: allowlist gateway/config_loader.py
(same owner as gateway/config.py — the extracted load_gateway_config phase).
gateway/config.py 2684 -> 1319 LOC (-50.9%); largest function now
GatewayConfig.from_dict at 119 LOC. Resolved-config parity: 160 cells
(16 yaml fixtures x 10 env sets) byte-identical to the integration base,
including captured log records and stderr.