mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-08 13:12:08 +00:00
feat(gateway): GATEWAY_MULTIPLEX_PROFILES env override for multiplex flag (#60589)
The connector now depends on the single multiplexed gateway for per-profile
relay routing, so hosted deployments need to FORCE multiplexing on regardless
of the image's config.yaml. gateway.multiplex_profiles was config.yaml-only,
which a user could leave unset or flip off.
Add GATEWAY_MULTIPLEX_PROFILES as a standard operator override on top of the
existing config key — the same 'config.yaml is canonical, env is the operator
override' pattern the Telegram/Signal require_mention bridges use:
env (recognized token) > config.yaml (top-level or nested gateway.*) > False
- gateway/config.py: _env_multiplex_profiles_override() resolves the env var
tri-state — recognized truthy/falsy token → bool; unset/blank/unrecognized
→ None (fall through to config). Blank is deliberately None, not False, so a
provisioned-but-unpopulated Fly secret ('') can't shadow a config.yaml opt-in
(the empty-secret trap). Wired into GatewayConfig.from_dict so every consumer
(run.py, session.py via self.config) sees the resolved value.
- hermes_cli/gateway.py: the named-profile-start guard
(_guard_named_profile_under_multiplexer) reads config.yaml directly, so it
gets the SAME env precedence — otherwise env-forced multiplex would leave the
guard blind and someone could start a conflicting per-profile gateway that
double-binds a bot token. Env-forced-on trips the guard even with no
config.yaml key; env-forced-off disables it over a config opt-in.
Tests: full 3-tier precedence in test_config.py (incl. the discriminating
env-overrides-config cases + the empty/whitespace/unrecognized fall-through
trap + resolver tri-state), mutation-verified (flipping precedence fails
exactly the two env-wins tests); guard env cases in test_multiplex_lifecycle.py.
Force-on is safe on a single-profile instance: session keys stay byte-identical
(agent:main) and the _run_agent wrapper installs the per-turn secret scope, so
the fail-closed get_secret() path is satisfied.
This commit is contained in:
parent
d9a4b5a5e5
commit
75de0057bc
4 changed files with 214 additions and 10 deletions
|
|
@ -37,6 +37,43 @@ def _coerce_bool(value: Any, default: bool = True) -> bool:
|
|||
return is_truthy_value(value, default=default)
|
||||
|
||||
|
||||
# Recognized truthy / falsy tokens for the GATEWAY_MULTIPLEX_PROFILES operator
|
||||
# override. Anything not in either set — and a blank/whitespace value — is
|
||||
# treated as "unset" so it falls through to config.yaml rather than silently
|
||||
# forcing the flag off.
|
||||
_MULTIPLEX_TRUTHY_STRINGS = frozenset({"1", "true", "yes", "on"})
|
||||
_MULTIPLEX_FALSY_STRINGS = frozenset({"0", "false", "no", "off"})
|
||||
|
||||
|
||||
def _env_multiplex_profiles_override() -> "bool | None":
|
||||
"""Resolve the GATEWAY_MULTIPLEX_PROFILES operator override.
|
||||
|
||||
Returns ``True``/``False`` when the env var is set to a recognized truthy/
|
||||
falsy token, or ``None`` when it is unset, blank, or unrecognized — in which
|
||||
case the caller keeps the config.yaml value (env > config > default). Blank
|
||||
is deliberately ``None``, not ``False``: a provisioned-but-unpopulated Fly
|
||||
secret arrives as ``""`` and must NOT shadow a config.yaml opt-in.
|
||||
"""
|
||||
raw = os.getenv("GATEWAY_MULTIPLEX_PROFILES")
|
||||
if raw is None:
|
||||
return None
|
||||
token = raw.strip().lower()
|
||||
if not token:
|
||||
return None
|
||||
if token in _MULTIPLEX_TRUTHY_STRINGS:
|
||||
return True
|
||||
if token in _MULTIPLEX_FALSY_STRINGS:
|
||||
return False
|
||||
logger.warning(
|
||||
"Ignoring unrecognized GATEWAY_MULTIPLEX_PROFILES=%r "
|
||||
"(expected one of %s or %s); falling back to config.yaml.",
|
||||
raw,
|
||||
sorted(_MULTIPLEX_TRUTHY_STRINGS),
|
||||
sorted(_MULTIPLEX_FALSY_STRINGS),
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _coerce_float(value: Any, default: float) -> float:
|
||||
"""Coerce numeric config values, falling back on malformed input."""
|
||||
if value is None:
|
||||
|
|
@ -837,6 +874,18 @@ class GatewayConfig:
|
|||
# Also honor gateway.multiplex_profiles written by
|
||||
# ``hermes config set gateway.multiplex_profiles true``.
|
||||
multiplex_profiles = nested_gateway.get("multiplex_profiles")
|
||||
# Operator override: GATEWAY_MULTIPLEX_PROFILES wins over config.yaml when
|
||||
# set to a recognized value. Hosted deployments (Nous Portal / Fly) stamp
|
||||
# it on the container so the single multiplexed gateway — which the
|
||||
# connector now depends on for per-profile relay routing — is forced on at
|
||||
# every boot regardless of the image's config.yaml, while self-hosted
|
||||
# users keep setting gateway.multiplex_profiles in config.yaml. A blank or
|
||||
# unrecognized env value falls through to config (the empty-secret trap:
|
||||
# a provisioned-but-unpopulated Fly secret must not shadow config), so
|
||||
# this is a genuine 3-tier chain: env > config.yaml > default False.
|
||||
env_multiplex = _env_multiplex_profiles_override()
|
||||
if env_multiplex is not None:
|
||||
multiplex_profiles = env_multiplex
|
||||
if "max_concurrent_sessions" in data:
|
||||
max_concurrent_raw = data.get("max_concurrent_sessions")
|
||||
max_concurrent_key = "max_concurrent_sessions"
|
||||
|
|
|
|||
|
|
@ -4488,16 +4488,29 @@ def _guard_named_profile_under_multiplexer(force: bool = False) -> None:
|
|||
if not pid or not _pid_exists(pid):
|
||||
return
|
||||
|
||||
# (c) default config has multiplexing on
|
||||
cfg_path = default_root / "config.yaml"
|
||||
if not cfg_path.exists():
|
||||
return
|
||||
with open(cfg_path, encoding="utf-8") as f:
|
||||
cfg = _yaml.safe_load(f) or {}
|
||||
multiplex = bool(
|
||||
cfg.get("multiplex_profiles")
|
||||
or (cfg.get("gateway", {}) or {}).get("multiplex_profiles")
|
||||
)
|
||||
# (c) multiplexing is on for the default gateway. Precedence mirrors
|
||||
# gateway.config: the GATEWAY_MULTIPLEX_PROFILES env override wins over
|
||||
# config.yaml when set to a recognized value, so a hosted gateway that
|
||||
# forces multiplex on via env (with no multiplex_profiles in config.yaml)
|
||||
# still trips this guard. A blank/unrecognized env value falls through
|
||||
# to config.yaml.
|
||||
from gateway.config import _env_multiplex_profiles_override
|
||||
|
||||
env_multiplex = _env_multiplex_profiles_override()
|
||||
if env_multiplex is False:
|
||||
return # explicitly forced OFF by the operator env override
|
||||
if env_multiplex is True:
|
||||
multiplex = True
|
||||
else:
|
||||
cfg_path = default_root / "config.yaml"
|
||||
if not cfg_path.exists():
|
||||
return
|
||||
with open(cfg_path, encoding="utf-8") as f:
|
||||
cfg = _yaml.safe_load(f) or {}
|
||||
multiplex = bool(
|
||||
cfg.get("multiplex_profiles")
|
||||
or (cfg.get("gateway", {}) or {}).get("multiplex_profiles")
|
||||
)
|
||||
if not multiplex:
|
||||
return
|
||||
except Exception:
|
||||
|
|
|
|||
|
|
@ -1315,3 +1315,96 @@ class TestHomeChannelEnvOverrides:
|
|||
home = config.platforms[platform].home_channel
|
||||
assert home is not None, f"{platform.value}: home_channel should not be None"
|
||||
assert (home.chat_id, home.name) == expected, platform.value
|
||||
|
||||
|
||||
class TestMultiplexProfilesEnvOverride:
|
||||
"""GATEWAY_MULTIPLEX_PROFILES env override — the 3-tier precedence chain.
|
||||
|
||||
env (recognized token) > config.yaml (top-level or nested gateway.*) >
|
||||
default False. A blank / unrecognized env value is treated as UNSET and
|
||||
falls through to config (the empty-secret trap: a provisioned-but-empty Fly
|
||||
secret arrives as "" and must not shadow a config.yaml opt-in).
|
||||
"""
|
||||
|
||||
def _load(self, tmp_path, monkeypatch, config_text=None):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir(exist_ok=True)
|
||||
if config_text is not None:
|
||||
(hermes_home / "config.yaml").write_text(config_text, encoding="utf-8")
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
return load_gateway_config()
|
||||
|
||||
# ── Tier 1: env wins ──────────────────────────────────────────────────
|
||||
def test_env_true_forces_on_with_no_config(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("GATEWAY_MULTIPLEX_PROFILES", "true")
|
||||
config = self._load(tmp_path, monkeypatch, config_text=None)
|
||||
assert config.multiplex_profiles is True
|
||||
|
||||
def test_env_true_overrides_config_false(self, tmp_path, monkeypatch):
|
||||
# THE discriminating test: env-set wins over an explicit config value.
|
||||
monkeypatch.setenv("GATEWAY_MULTIPLEX_PROFILES", "1")
|
||||
config = self._load(
|
||||
tmp_path, monkeypatch, config_text="multiplex_profiles: false\n"
|
||||
)
|
||||
assert config.multiplex_profiles is True
|
||||
|
||||
def test_env_false_overrides_config_true(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("GATEWAY_MULTIPLEX_PROFILES", "off")
|
||||
config = self._load(
|
||||
tmp_path, monkeypatch, config_text="multiplex_profiles: true\n"
|
||||
)
|
||||
assert config.multiplex_profiles is False
|
||||
|
||||
# ── Tier 2: config.yaml when env unset ────────────────────────────────
|
||||
def test_config_true_when_env_unset(self, tmp_path, monkeypatch):
|
||||
monkeypatch.delenv("GATEWAY_MULTIPLEX_PROFILES", raising=False)
|
||||
config = self._load(
|
||||
tmp_path, monkeypatch, config_text="multiplex_profiles: true\n"
|
||||
)
|
||||
assert config.multiplex_profiles is True
|
||||
|
||||
# ── The empty / unrecognized env trap: fall through, don't force off ──
|
||||
def test_empty_env_does_not_shadow_config_true(self, tmp_path, monkeypatch):
|
||||
# Provisioned-but-unpopulated Fly secret arrives as "". It must NOT
|
||||
# turn OFF a config.yaml opt-in.
|
||||
monkeypatch.setenv("GATEWAY_MULTIPLEX_PROFILES", "")
|
||||
config = self._load(
|
||||
tmp_path, monkeypatch, config_text="multiplex_profiles: true\n"
|
||||
)
|
||||
assert config.multiplex_profiles is True
|
||||
|
||||
def test_whitespace_env_does_not_shadow_config_true(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("GATEWAY_MULTIPLEX_PROFILES", " ")
|
||||
config = self._load(
|
||||
tmp_path, monkeypatch, config_text="multiplex_profiles: true\n"
|
||||
)
|
||||
assert config.multiplex_profiles is True
|
||||
|
||||
def test_unrecognized_env_falls_through_to_config(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("GATEWAY_MULTIPLEX_PROFILES", "maybe")
|
||||
config = self._load(
|
||||
tmp_path, monkeypatch, config_text="multiplex_profiles: true\n"
|
||||
)
|
||||
assert config.multiplex_profiles is True
|
||||
|
||||
# ── Tier 3: default False ─────────────────────────────────────────────
|
||||
def test_default_false_when_neither_set(self, tmp_path, monkeypatch):
|
||||
monkeypatch.delenv("GATEWAY_MULTIPLEX_PROFILES", raising=False)
|
||||
config = self._load(tmp_path, monkeypatch, config_text=None)
|
||||
assert config.multiplex_profiles is False
|
||||
|
||||
# ── The resolver in isolation ─────────────────────────────────────────
|
||||
def test_resolver_tristate(self, monkeypatch):
|
||||
from gateway.config import _env_multiplex_profiles_override
|
||||
|
||||
monkeypatch.delenv("GATEWAY_MULTIPLEX_PROFILES", raising=False)
|
||||
assert _env_multiplex_profiles_override() is None
|
||||
for truthy in ("1", "true", "TRUE", "yes", "on"):
|
||||
monkeypatch.setenv("GATEWAY_MULTIPLEX_PROFILES", truthy)
|
||||
assert _env_multiplex_profiles_override() is True, truthy
|
||||
for falsy in ("0", "false", "FALSE", "no", "off"):
|
||||
monkeypatch.setenv("GATEWAY_MULTIPLEX_PROFILES", falsy)
|
||||
assert _env_multiplex_profiles_override() is False, falsy
|
||||
for noise in ("", " ", "maybe", "2"):
|
||||
monkeypatch.setenv("GATEWAY_MULTIPLEX_PROFILES", noise)
|
||||
assert _env_multiplex_profiles_override() is None, repr(noise)
|
||||
|
|
|
|||
|
|
@ -53,3 +53,52 @@ class TestNamedProfileMultiplexerGuard:
|
|||
)
|
||||
# No gateway.pid in tmp_path => no running default gateway => no raise.
|
||||
gw._guard_named_profile_under_multiplexer(force=False)
|
||||
|
||||
def _fake_running_default_gateway(self, monkeypatch, tmp_path):
|
||||
"""Make the guard believe a live default gateway exists at tmp_path."""
|
||||
from hermes_cli import gateway as gw
|
||||
import gateway.status as status
|
||||
|
||||
monkeypatch.setattr(gw, "_profile_suffix", lambda: "coder")
|
||||
monkeypatch.setattr(
|
||||
"hermes_constants.get_default_hermes_root", lambda: tmp_path
|
||||
)
|
||||
(tmp_path / "gateway.pid").write_text("12345", encoding="utf-8")
|
||||
monkeypatch.setattr(status, "_read_pid_record", lambda p: {"pid": 12345})
|
||||
monkeypatch.setattr(status, "_pid_from_record", lambda rec: 12345)
|
||||
monkeypatch.setattr(status, "_pid_exists", lambda pid: True)
|
||||
|
||||
def test_env_forces_guard_even_without_config(self, monkeypatch, tmp_path):
|
||||
"""GATEWAY_MULTIPLEX_PROFILES=true must trip the guard even when the
|
||||
default profile's config.yaml has no multiplex_profiles key — the hosted
|
||||
case where multiplex is forced purely by the env stamp."""
|
||||
from hermes_cli import gateway as gw
|
||||
self._fake_running_default_gateway(monkeypatch, tmp_path)
|
||||
# No config.yaml written → the only signal is the env override.
|
||||
monkeypatch.setenv("GATEWAY_MULTIPLEX_PROFILES", "true")
|
||||
with pytest.raises(SystemExit):
|
||||
gw._guard_named_profile_under_multiplexer(force=False)
|
||||
|
||||
def test_env_false_disables_guard_over_config_true(self, monkeypatch, tmp_path):
|
||||
"""GATEWAY_MULTIPLEX_PROFILES=false wins over a config.yaml opt-in, so
|
||||
the guard stays inert (symmetric with the config precedence)."""
|
||||
from hermes_cli import gateway as gw
|
||||
self._fake_running_default_gateway(monkeypatch, tmp_path)
|
||||
(tmp_path / "config.yaml").write_text(
|
||||
"multiplex_profiles: true\n", encoding="utf-8"
|
||||
)
|
||||
monkeypatch.setenv("GATEWAY_MULTIPLEX_PROFILES", "false")
|
||||
# Env forces OFF → guard must NOT raise.
|
||||
gw._guard_named_profile_under_multiplexer(force=False)
|
||||
|
||||
def test_blank_env_falls_through_to_config_and_raises(self, monkeypatch, tmp_path):
|
||||
"""A blank env value must not shadow a config.yaml opt-in: the guard
|
||||
still trips on the config value."""
|
||||
from hermes_cli import gateway as gw
|
||||
self._fake_running_default_gateway(monkeypatch, tmp_path)
|
||||
(tmp_path / "config.yaml").write_text(
|
||||
"multiplex_profiles: true\n", encoding="utf-8"
|
||||
)
|
||||
monkeypatch.setenv("GATEWAY_MULTIPLEX_PROFILES", "")
|
||||
with pytest.raises(SystemExit):
|
||||
gw._guard_named_profile_under_multiplexer(force=False)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue