mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-08 13:12:08 +00:00
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.
104 lines
4.8 KiB
Python
104 lines
4.8 KiB
Python
"""Phase 4: lifecycle guard + per-profile observability."""
|
|
import pytest
|
|
|
|
|
|
class TestServedProfilesStatus:
|
|
def test_write_and_read_served_profiles(self, tmp_path, monkeypatch):
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
import importlib
|
|
import gateway.status as status
|
|
importlib.reload(status)
|
|
try:
|
|
status.write_runtime_status(
|
|
gateway_state="running", served_profiles=["default", "coder"]
|
|
)
|
|
rec = status.read_runtime_status()
|
|
assert rec.get("served_profiles") == ["default", "coder"]
|
|
finally:
|
|
importlib.reload(status)
|
|
|
|
def test_served_profiles_absent_by_default(self, tmp_path, monkeypatch):
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
import importlib
|
|
import gateway.status as status
|
|
importlib.reload(status)
|
|
try:
|
|
status.write_runtime_status(gateway_state="running")
|
|
rec = status.read_runtime_status()
|
|
assert "served_profiles" not in rec
|
|
finally:
|
|
importlib.reload(status)
|
|
|
|
|
|
class TestNamedProfileMultiplexerGuard:
|
|
"""_guard_named_profile_under_multiplexer is inert unless all conditions hold."""
|
|
|
|
def test_inert_for_default_profile(self, monkeypatch):
|
|
from hermes_cli import gateway as gw
|
|
monkeypatch.setattr(gw, "_profile_suffix", lambda: "")
|
|
# Should return without raising (default profile => guard N/A).
|
|
gw._guard_named_profile_under_multiplexer(force=False)
|
|
|
|
def test_force_bypasses(self, monkeypatch):
|
|
from hermes_cli import gateway as gw
|
|
# Even if it looks like a named profile, force returns immediately.
|
|
monkeypatch.setattr(gw, "_profile_suffix", lambda: "coder")
|
|
gw._guard_named_profile_under_multiplexer(force=True)
|
|
|
|
def test_inert_when_no_default_gateway_running(self, monkeypatch, tmp_path):
|
|
from hermes_cli import gateway as gw
|
|
monkeypatch.setattr(gw, "_profile_suffix", lambda: "coder")
|
|
monkeypatch.setattr(
|
|
"hermes_constants.get_default_hermes_root", lambda: tmp_path
|
|
)
|
|
# 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)
|