Merge fallout (my resolution errors, all caught by CI): - hermes_cli/backup.py + gateway.py: `theirs` on those hunks re-imported clusters HEAD had already moved to backup_restore.py / kept in the facade. backup.py loses the 349-line duplicate (main's #110179 fix is ported into backup_restore._import_db_member); the systemd service-unit cluster returns to gateway.py (PM's _prepare_service_launcher / _pm_managed_node_dirs / _systemd_command have no home in main's extraction) with main's utf-8-sig read. gateway_service_unit.py is dropped. - gateway/run.py: main's plugin-update chore is not profile-scoped (the housekeeping ordering test pins the scope/drain sequence). - pyproject + 30 test files: `import yaml` -> `import hermes_yaml as yaml` (pm-clean has no pyyaml); gateway/config._bundled_platform_manifest_name reads through hermes_yaml. - tests re-seamed onto pm-clean's shape: residency admission (installed_engine), supervisor child env (binary is a constructor argument), update import guard (update_cmd_deps is gone; our probe already scrubs PYTHONPATH — both #115032 invariants pass), shallow-count git responses (stash path asks `status --porcelain -z`); dropped tests for retired code (_run_node_bootstrap/_ensure_tui_node, Windows resume demotion). - tests/tools/test_local_env_blocklist.py: restore the two helpers the suite-reduction commit dropped and the blocklist import. Real fixes: - pm: classify_uv_failure/ResolutionConflict move beside the uv runner (pm.environment, stdlib-only). pm.workspace imports tomllib at module level and cannot load on the 3.10 bootstrap python that streams uv output in the Docker arm64 image. - tools/browser_tool.warm_agent_browser_npx_cache: back as a permanent definition — it is on the frozen old-updater surface, and the revert-scheduled compat pointer does not count. - hermes_cli/memory_setup: the dashboard's pip row uses pm.environments. running_from_selected_environment for installed vs restart_required. - scripts/windows-build-deps.ps1: export DISTUTILS_USE_SDK/MSSdk so setuptools trusts the primed MSVC environment instead of asking vswhere (`env -i` test runner on win32-arm64 compiling ruamel-yaml-clib); run_tests.sh forwards them. - tests/pm/test_windows_build_deps.py: start the protocol test from a parent env without the toolchain variables the runner job already exports. - tests/conftest.py scrubs HERMES_BUNDLED_PLUGINS (Nix-wrapped hermes on the dev host); tests/home_io_guard.py treats sys.path site-packages under the real home as the interpreter's installation (PM-activated developer shell). - tests-js: four `curly` lint errors from main's new scripts.
124 lines
5.0 KiB
Python
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 hermes_yaml as 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"
|