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.
178 lines
7.5 KiB
Python
178 lines
7.5 KiB
Python
"""Multiplexed gateway: module-level caches in tools/ and agent/ must not hand profile A's
|
|
config/.env-derived value to profile B. Real temp homes, real config.yaml/.env, real modules;
|
|
only HTTP transports are stubbed.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import threading
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
import hermes_yaml as yaml
|
|
|
|
from agent.secret_scope import build_profile_secret_scope, reset_secret_scope, set_secret_scope
|
|
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
|
|
|
|
|
|
def _make_home(root: Path, cfg: dict, env: str = "") -> Path:
|
|
root.mkdir(parents=True, exist_ok=True)
|
|
(root / "config.yaml").write_text(yaml.safe_dump(cfg), encoding="utf-8")
|
|
(root / ".env").write_text(env, encoding="utf-8")
|
|
(root / "cache").mkdir(exist_ok=True)
|
|
return root
|
|
|
|
|
|
class _scoped:
|
|
def __init__(self, home: Path):
|
|
self.home = home
|
|
|
|
def __enter__(self):
|
|
self._t1 = set_hermes_home_override(str(self.home))
|
|
self._t2 = set_secret_scope(build_profile_secret_scope(self.home))
|
|
|
|
def __exit__(self, *_):
|
|
reset_secret_scope(self._t2)
|
|
reset_hermes_home_override(self._t1)
|
|
|
|
|
|
@pytest.fixture
|
|
def two_homes(tmp_path, monkeypatch):
|
|
a = _make_home(tmp_path / "A", {}, "CAMOFOX_URL=http://camofox-a:9377\n")
|
|
b = _make_home(tmp_path / "A" / "profiles" / "B", {}, "CAMOFOX_URL=http://camofox-b:9377\n")
|
|
monkeypatch.setenv("HERMES_HOME", str(a))
|
|
monkeypatch.delenv("CAMOFOX_URL", raising=False)
|
|
return a, b
|
|
|
|
|
|
def test_camofox_vnc_memo_is_keyed_by_the_profiles_server_url(two_homes, monkeypatch):
|
|
"""The one-shot VNC probe must not answer B (own CAMOFOX_URL) with A's server address."""
|
|
import tools.browser_camofox as cam
|
|
|
|
class _Resp:
|
|
status_code = 200
|
|
|
|
def __init__(self, url):
|
|
self._port = 6001 if "camofox-a" in url else 6002
|
|
|
|
def json(self):
|
|
return {"ok": True, "vncPort": self._port}
|
|
|
|
monkeypatch.setattr(cam.requests, "get", lambda url, *a, **k: _Resp(url))
|
|
a, b = two_homes
|
|
with _scoped(a):
|
|
assert cam.check_camofox_available() is True
|
|
assert cam.get_vnc_url() == "http://camofox-a:6001"
|
|
with _scoped(b):
|
|
assert cam.get_vnc_url() == "http://camofox-b:6002"
|
|
with _scoped(a):
|
|
assert cam.get_vnc_url() == "http://camofox-a:6001"
|
|
|
|
|
|
def test_home_keyed_caches_serve_each_profile_its_own_config(tmp_path, monkeypatch):
|
|
"""One mechanism (dict keyed by home / override bypass) across the sites that read per-profile
|
|
config or per-home files: aux-vision routing, tirith binary path, learned image cost table, aux
|
|
semaphore, MCP lock."""
|
|
bin_a, bin_b = tmp_path / "binA" / "tirith", tmp_path / "binB" / "tirith"
|
|
for p in (bin_a, bin_b):
|
|
p.parent.mkdir()
|
|
p.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
|
|
p.chmod(0o755)
|
|
main = {"model": {"provider": "openai", "model": "gpt-4o"}}
|
|
a = _make_home(tmp_path / "A", {**main, "security": {"tirith_path": str(bin_a)},
|
|
"auxiliary": {"vision": {"provider": "auto"}, "summary": {"max_concurrency": 2}}})
|
|
b = _make_home(tmp_path / "A" / "profiles" / "B", {**main, "security": {"tirith_path": str(bin_b)},
|
|
"auxiliary": {"vision": {"provider": "openai", "model": "gpt-4o-mini"},
|
|
"summary": {"max_concurrency": 7}}})
|
|
monkeypatch.setenv("HERMES_HOME", str(a))
|
|
(a / "cache" / "image_token_costs.json").write_text(json.dumps({"m@gw.example": 1000}), encoding="utf-8")
|
|
(b / "cache" / "image_token_costs.json").write_text(json.dumps({"m@gw.example": 3000}), encoding="utf-8")
|
|
|
|
import agent.auxiliary_client as ac
|
|
import agent.image_token_cost as itc
|
|
import tools.computer_use.tool as cu
|
|
import tools.tirith_security as tir
|
|
from tools import mcp_tool_loop
|
|
|
|
ac._reset_aux_semaphores()
|
|
cu._AUX_VISION_ROUTE_CACHE.clear()
|
|
|
|
with _scoped(a):
|
|
assert cu._should_route_through_aux_vision() is False # no explicit aux vision: native path
|
|
assert tir._resolve_tirith_path(tir._load_security_config()["tirith_path"]) == str(bin_a)
|
|
assert itc.learned_image_token_cost("m", "http://gw.example/v1") == 1000
|
|
sem_a = ac._acquire_sync_aux_semaphore("summary")
|
|
sem_a.acquire()
|
|
cookie = mcp_tool_loop._try_acquire_mcp_discovery_lock()
|
|
lock_a = cookie._fh.name
|
|
cookie.release()
|
|
with _scoped(b):
|
|
assert cu._should_route_through_aux_vision() is True # B named a dedicated vision model
|
|
assert tir._resolve_tirith_path(tir._load_security_config()["tirith_path"]) == str(bin_b)
|
|
assert itc.learned_image_token_cost("m", "http://gw.example/v1") == 3000
|
|
sem_b = ac._acquire_sync_aux_semaphore("summary")
|
|
cookie = mcp_tool_loop._try_acquire_mcp_discovery_lock()
|
|
lock_b = cookie._fh.name
|
|
cookie.release()
|
|
assert Path(lock_a).parent == a and Path(lock_b).parent == b
|
|
assert sem_b is not sem_a
|
|
with _scoped(a):
|
|
# B's differently-sized lookup must not have rebuilt the semaphore A is holding.
|
|
assert ac._acquire_sync_aux_semaphore("summary") is sem_a
|
|
sem_a.release()
|
|
|
|
|
|
def test_debounced_sync_push_fires_in_the_scheduling_profiles_context(two_homes, monkeypatch):
|
|
"""Timer threads start with empty ContextVars: the push must run under the writing profile's
|
|
home, and B's write must not cancel A's pending push."""
|
|
import tools.skill_manager_tool as smt
|
|
import tools.skill_usage as su
|
|
import tools.skills_sync_client as ssc
|
|
from hermes_constants import get_hermes_home
|
|
|
|
a, b = two_homes
|
|
fired: dict[str, str] = {}
|
|
both = threading.Event()
|
|
|
|
def fake_push(*, message=""):
|
|
fired[message] = str(get_hermes_home())
|
|
if len(fired) == 2:
|
|
both.set()
|
|
|
|
monkeypatch.setattr(su, "is_sync_enabled", lambda name: True)
|
|
monkeypatch.setattr(ssc, "maybe_push_skills", fake_push)
|
|
monkeypatch.setattr(smt, "_SYNC_PUSH_DEBOUNCE_S", 0.05)
|
|
monkeypatch.setattr(smt, "_sync_push_timers", {})
|
|
with _scoped(a):
|
|
smt._maybe_debounced_sync_push("skill-a")
|
|
with _scoped(b):
|
|
smt._maybe_debounced_sync_push("skill-b")
|
|
assert both.wait(5), fired
|
|
assert fired == {"sync: skill-a": str(a), "sync: skill-b": str(b)}
|
|
|
|
|
|
def test_endpoint_model_catalog_memo_is_keyed_by_credential(two_homes, monkeypatch):
|
|
"""Two profiles, same base_url, different api_key: a per-key gateway's catalog fetched with A's
|
|
key must not be served to B from the in-memory memo (the disk memo already lives per home)."""
|
|
from contextlib import contextmanager
|
|
import httpx
|
|
import agent.model_metadata as mm
|
|
|
|
a, b = two_homes
|
|
mm._endpoint_model_metadata_cache.clear()
|
|
mm._endpoint_model_metadata_cache_time.clear()
|
|
|
|
@contextmanager
|
|
def stream(url, headers=None, **kwargs):
|
|
who = headers.get("Authorization", "").rsplit("-", 1)[-1]
|
|
yield httpx.Response(200, request=httpx.Request("GET", url),
|
|
json={"data": [{"id": f"model-for-{who}", "context_length": 1}]})
|
|
|
|
monkeypatch.setattr(mm.model_metadata_http, "stream", stream)
|
|
with _scoped(a):
|
|
assert set(mm.fetch_endpoint_model_metadata("http://gw.example/v1", api_key="key-A")) == {"model-for-A"}
|
|
with _scoped(b):
|
|
assert set(mm.fetch_endpoint_model_metadata("http://gw.example/v1", api_key="key-B")) == {"model-for-B"}
|
|
with _scoped(a):
|
|
assert set(mm.fetch_endpoint_model_metadata("http://gw.example/v1", api_key="key-A")) == {"model-for-A"}
|