Files
hermes-agent/tests/gateway/test_multiplex_process_memo_scope.py
T
Teknium 819517fbac fix(gateway): routed profiles get their own max_turns, fallback chain, hooks, aux auth and media policy
One multiplexed gateway process serves every profile, but several per-turn
reads still went through state frozen from the LAUNCH profile:

- `_current_max_iterations` re-bridged `agent.max_turns`/`sessions.*` from the
  module constant `_hermes_home` into one process-wide HERMES_MAX_ITERATIONS,
  so every secondary ran with the default profile's turn budget. A routed turn
  (HERMES_HOME override) now resolves `agent.max_turns` from its own config.
- `_refresh_fallback_model` read `_hermes_home/config.yaml` into one runner-wide
  slot, so secondaries fell back through the default's provider/model with their
  own keys. It now reads the active gateway home and keeps a last-known-good
  chain per home.
- `_load_prefill_messages` resolved relative paths against the launch home.
- `agent/auxiliary_client._AUTH_JSON_PATH` was an import-time constant, so a
  secondary's compression/title/vision calls authenticated to Nous with the
  default profile's token when it had no pool entry. Resolved per call via
  `hermes_cli.auth._auth_file_path()` (patched constant still wins in tests).
- `gateway/hooks.HOOKS_DIR` was frozen at import and one `HookRegistry` was
  loaded outside any profile scope, so secondaries' `hooks/` never ran and the
  default profile's handlers received every profile's messages, responses and
  user ids. `HOOKS_DIR` now resolves per call (salvaged from #56508) and the
  runner holds one registry per served home, picked from the active scope at
  emit time and front-loaded under each secondary's startup scope.
- Shell-hook subprocesses inherited the launch `os.environ` (default HERMES_HOME
  and the default profile's secrets). They now get the routed HERMES_HOME via
  `build_subprocess_env`, scrubbed under multiplexing, and the stdin payload
  carries `profile` so one script can tell which profile fired it.
- Media-delivery policy (`gateway.strict`, `media_delivery_allow_dirs`,
  `trust_recent_files*`) was bridged once into env at startup and read from env
  per delivery; under a HERMES_HOME override the validator now reads the routed
  profile's config. Single-profile runs keep the env-bridge contract.

Audit: /tmp/mux_audit F3, F4, F6 (auth.json half), F7, F12 (media). Live repro
(temp HERMES_HOME A with profiles/B): before, B saw max_iterations 7,
fallback A/fallback, TOKEN_A, A's hooks, strict=A; after, all B's values.
2026-09-11 15:44:00 -07:00

124 lines
5.0 KiB
Python

"""Multiplexed-gateway invariants: per-turn config, credentials and hooks follow the ROUTED profile
(HERMES_HOME override), not the launch home the module constants were frozen from."""
from __future__ import annotations
import json
from pathlib import Path
from types import SimpleNamespace
import pytest
import yaml
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
@pytest.fixture
def two_homes(tmp_path, monkeypatch):
"""Launch home A (HERMES_HOME) and a routed profile B with different config everywhere."""
a = tmp_path / ".hermes"
b = a / "profiles" / "b"
b.mkdir(parents=True)
monkeypatch.setenv("HERMES_HOME", str(a))
monkeypatch.delenv("HERMES_MAX_ITERATIONS", raising=False)
for home, turns, model in ((a, 7, "A/fallback"), (b, 99, "B/fallback")):
(home / "config.yaml").write_text(yaml.safe_dump({
"agent": {"max_turns": turns},
"fallback_providers": [{"provider": "openrouter", "model": model}],
}), encoding="utf-8")
return a, b
def _under(home: Path, fn):
token = set_hermes_home_override(str(home))
try:
return fn()
finally:
reset_hermes_home_override(token)
def test_max_turns_and_fallback_chain_follow_routed_profile(two_homes, monkeypatch):
a, b = two_homes
from gateway import run as gateway_run
from gateway.run import GatewayRunner
monkeypatch.setattr(gateway_run, "_hermes_home", a)
monkeypatch.setattr("agent.secret_scope.is_multiplex_active", lambda: True)
runner = SimpleNamespace(_fallback_model=None)
refresh = GatewayRunner._refresh_fallback_model.__get__(runner)
# Default profile turn warms the process-wide bridge/slot with A's values...
assert gateway_run._current_max_iterations() == 7
assert refresh() == [{"provider": "openrouter", "model": "A/fallback"}]
# ...and a routed turn for B must still see B's config, not the launch home's.
assert _under(b, gateway_run._current_max_iterations) == 99
assert _under(b, refresh) == [{"provider": "openrouter", "model": "B/fallback"}]
# Back on the default profile the chain is A's again (per-home last-known-good, not last writer).
assert refresh() == [{"provider": "openrouter", "model": "A/fallback"}]
def test_aux_nous_auth_reads_routed_profile_auth_json(two_homes, monkeypatch):
a, b = two_homes
import agent.auxiliary_client as aux
for home, token in ((a, "TOKEN_A"), (b, "TOKEN_B")):
(home / "auth.json").write_text(json.dumps({
"version": 1, "active_provider": "nous",
"providers": {"nous": {"agent_key": token, "access_token": token}},
}), encoding="utf-8")
monkeypatch.setattr(aux, "_select_pool_entry", lambda _provider: (False, None))
assert (aux._read_nous_auth() or {}).get("access_token") == "TOKEN_A"
assert (_under(b, aux._read_nous_auth) or {}).get("access_token") == "TOKEN_B"
def _write_hook(home: Path, name: str) -> None:
hook_dir = home / "hooks" / name
hook_dir.mkdir(parents=True)
(hook_dir / "HOOK.yaml").write_text(f"name: {name}\nevents: ['agent:start']\n", encoding="utf-8")
(hook_dir / "handler.py").write_text(
"def handle(event_type, context):\n context.setdefault('seen', []).append(__name__)\n",
encoding="utf-8")
@pytest.mark.asyncio
async def test_gateway_hooks_fire_per_routed_profile(two_homes):
"""Profile B's hooks/ runs for B's turns and A's handlers never see B's context (and vice versa)."""
a, b = two_homes
_write_hook(a, "hook-a")
_write_hook(b, "hook-b")
from gateway.hooks import ProfileHookRegistries
hooks = ProfileHookRegistries()
ctx_a: dict = {}
await hooks.emit("agent:start", ctx_a)
token = set_hermes_home_override(str(b))
try:
ctx_b: dict = {}
await hooks.emit("agent:start", ctx_b)
assert [h["name"] for h in hooks.loaded_hooks] == ["hook-b"]
finally:
reset_hermes_home_override(token)
assert ctx_a.get("seen") == ["hermes_hook_hook-a"]
assert ctx_b.get("seen") == ["hermes_hook_hook-b"]
def test_media_policy_reads_routed_profile_config_not_env(two_homes, monkeypatch):
a, b = two_homes
from gateway import media_policy
(a / "config.yaml").write_text(yaml.safe_dump(
{"gateway": {"strict": True, "media_delivery_allow_dirs": ["/srv/a"]}}), encoding="utf-8")
(b / "config.yaml").write_text(yaml.safe_dump(
{"gateway": {"strict": False, "media_delivery_allow_dirs": ["/srv/b"]}}), encoding="utf-8")
# Gateway startup bridges the LAUNCH profile's policy into the process env.
for var in ("HERMES_MEDIA_DELIVERY_STRICT", "HERMES_MEDIA_ALLOW_DIRS"):
monkeypatch.delenv(var, raising=False)
from hermes_cli.config import load_config
media_policy.apply_media_policy_env(load_config())
assert media_policy.media_delivery_strict() is True
assert media_policy.media_delivery_allow_dirs() == "/srv/a"
assert _under(b, media_policy.media_delivery_strict) is False
assert _under(b, media_policy.media_delivery_allow_dirs) == "/srv/b"