File size: 11,013 Bytes
7acbd80
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
"""Plugin module-level caches must not hand profile A's state to profile B under a multiplexed
HERMES_HOME override (``hermes_constants.set_hermes_home_override``).

One invariant per mechanism: home-keyed slot with the unscoped module slot intact (router; yuanbao's
ClassVar twin), credential-fingerprinted catalog keys (openrouter), per-home registries (memory
provider skills), collect-all atexit (openviking), lru_cache keyed by the home (disk-cleanup).
Only HTTP transports are faked; the caches themselves are exercised for real.
"""

from __future__ import annotations

import contextlib
import importlib.util
import json
import sys
import threading
import time
from pathlib import Path

import pytest

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

REPO = Path(__file__).resolve().parents[2]


@pytest.fixture
def homes(tmp_path, monkeypatch):
    """Profile A (launch home, ``HERMES_HOME``) and profile B with different config/.env values."""
    root = tmp_path / ".hermes"
    a, b = root, root / "profiles" / "B"
    for home, tag in ((a, "A"), (b, "B")):
        home.mkdir(parents=True)
        (home / "config.yaml").write_text(f"memory:\n  provider: prov{tag}\n", encoding="utf-8")
        (home / ".env").write_text(
            f"RAMP_ROUTER_API_KEY=router-key-{tag}\nRAMP_ROUTER_BASE_URL=https://{tag.lower()}.router.test/v1\n",
            encoding="utf-8")
    monkeypatch.setenv("HERMES_HOME", str(a))
    for var in ("RAMP_ROUTER_API_KEY", "RAMP_ROUTER_BASE_URL", "PYTEST_CURRENT_TEST"):
        monkeypatch.delenv(var, raising=False)
    return a, b


@contextlib.contextmanager
def scoped(home: Path):
    t_home = set_hermes_home_override(str(home))
    t_secret = set_secret_scope(build_profile_secret_scope(home))
    try:
        yield
    finally:
        reset_secret_scope(t_secret)
        reset_hermes_home_override(t_home)


class _Resp:
    def __init__(self, payload, status=200):
        self._payload, self.status_code = payload, status

    def read(self):
        return json.dumps(self._payload).encode()

    def json(self):
        return self._payload

    def raise_for_status(self):
        if self.status_code >= 400:
            raise RuntimeError(f"HTTP {self.status_code}")

    def __enter__(self):
        return self

    def __exit__(self, *_exc):
        return False


def _router():
    from providers import get_provider_profile

    profile = get_provider_profile("router")
    return profile, sys.modules[type(profile).__module__]


def test_router_efforts_cache_and_base_url_follow_the_active_profile(homes, monkeypatch):
    """Efforts map + once-only flags are per home under an override (and the warm thread inherits the
    scope), while the unscoped path keeps using the module slots; the base URL comes from the
    profile's .env."""
    import hermes_cli.urllib_security as urllib_security

    a, b = homes
    profile, mod = _router()
    fetched: list[str] = []

    def fake_open(req, *, timeout, **_kw):
        tag = (req.get_header("Authorization") or "").rsplit("-", 1)[-1]
        fetched.append(req.full_url)
        return _Resp({"data": [{"id": f"model-{tag}", "router": {"capabilities": {"reasoning": {
            "supported": True, "efforts": [{"value": "low" if tag == "A" else "high"}]}}}}]})

    monkeypatch.setattr(urllib_security, "open_credentialed_url", fake_open)
    monkeypatch.setattr(mod, "_efforts_cache", None)
    monkeypatch.setattr(mod, "_disk_checked", False)
    monkeypatch.setattr(mod, "_warm_started", False)

    with scoped(a):
        assert mod._base_url() == "https://a.router.test/v1"
        profile.fetch_models()
        assert profile.supported_reasoning_efforts("model-A") == ("low",)
    with scoped(b):
        assert mod._base_url() == "https://b.router.test/v1"
        # B never fetched: A's verdicts must not be visible, and B's disk mirror (absent) is what it reads.
        assert profile.supported_reasoning_efforts("model-A") is None
        profile.fetch_models()
        assert profile.supported_reasoning_efforts("model-B") == ("high",)
    with scoped(a):
        assert profile.supported_reasoning_efforts("model-A") == ("low",)
    # Unscoped: the module slot is untouched by the scoped fetches.
    assert mod._efforts_cache is None

    # Warm thread launched from B's turn fetches with B's key/base URL. pytest re-sets
    # PYTEST_CURRENT_TEST per phase; the warmer's pytest guard reads it at call time.
    fetched.clear()
    monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False)
    with scoped(b):
        mod._warm_efforts_async()
    deadline = time.monotonic() + 5
    while time.monotonic() < deadline and not fetched:
        time.sleep(0.02)
    assert fetched and all(url.startswith("https://b.router.test/") for url in fetched)


def test_credentialed_catalog_probe_failure_is_not_cached_across_keys(monkeypatch):
    """A 401 under one key must not pin a sibling profile (same base URL, valid key) to the empty
    catalog for the TTL."""
    import requests

    import plugins.image_gen.openrouter as orp

    def fake_get(url, headers=None, timeout=None, **_kw):
        if (headers or {}).get("Authorization") == "Bearer good-key":
            return _Resp({"data": [{"id": "google/gemini-image"}]})
        return _Resp({"error": "unauthorized"}, status=401)

    monkeypatch.setattr(requests, "get", fake_get)
    orp._CATALOG_CACHE.clear()
    try:
        assert orp._fetch_image_api_catalog("https://openrouter.ai/api/v1", "bad-key") == frozenset()
        assert "google/gemini-image" in orp._fetch_image_api_catalog("https://openrouter.ai/api/v1", "good-key")
    finally:
        orp._CATALOG_CACHE.clear()


def test_memory_provider_skill_prune_only_touches_the_active_home(homes, monkeypatch):
    """Pruning under profile B (whose active provider differs) must leave profile A's registered
    provider skill in place; A's own later prune still retracts it."""
    import plugins.memory as mem
    from hermes_cli.plugins import _reset_plugin_managers_for_tests, get_plugin_manager

    a, b = homes
    _reset_plugin_managers_for_tests()
    mem._REGISTERED_MEMORY_PROVIDER_SKILLS.clear()
    skill_dir = a / "plugins" / "provA" / "skills" / "maint"
    skill_dir.mkdir(parents=True)
    (skill_dir / "SKILL.md").write_text("---\nname: maint\ndescription: x\n---\nbody\n", encoding="utf-8")
    try:
        with scoped(a):
            mem._ProviderCollector("provA").register_skill("maint", skill_dir)
            assert get_plugin_manager().find_plugin_skill("provA:maint") is not None
        with scoped(b):
            mem._prune_inactive_memory_provider_skills("provB")
        with scoped(a):
            assert get_plugin_manager().find_plugin_skill("provA:maint") is not None
            mem._prune_inactive_memory_provider_skills("provOther")
            assert get_plugin_manager().find_plugin_skill("provA:maint") is None
    finally:
        mem._REGISTERED_MEMORY_PROVIDER_SKILLS.clear()
        _reset_plugin_managers_for_tests()


def test_openviking_atexit_commits_every_profile_provider(homes):
    """Two profiles' providers initialized in one process both get the atexit commit."""
    import plugins.memory.openviking as ov

    a, b = homes
    committed: list[object] = []
    providers = []
    try:
        for home in (a, b):
            with scoped(home):
                provider = ov.OpenVikingMemoryProvider()
                provider.initialize(session_id=f"s-{home.name}", hermes_home=str(home))
                provider.on_session_end = lambda _msgs, _p=provider: committed.append(_p)
                providers.append(provider)
        ov._atexit_commit_sessions()
        assert committed == providers
    finally:
        for provider in providers:
            with contextlib.suppress(Exception):
                provider._release_run_lock()


def test_disk_cleanup_protected_cron_paths_follow_the_active_home(homes):
    """The protected-path guard must protect the ACTIVE profile's cron dir, not the first one asked."""
    spec = importlib.util.spec_from_file_location(
        "disk_cleanup_mux_scope", REPO / "plugins" / "disk-cleanup" / "disk_cleanup.py")
    dc = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(dc)
    a, b = homes
    for home in (a, b):
        (home / "cron").mkdir()
    with scoped(a):
        assert dc._is_protected_cron_path(a / "cron")
    with scoped(b):
        assert dc._is_protected_cron_path(b / "cron")
        assert not dc._is_protected_cron_path(a / "cron")


def test_yuanbao_active_adapter_resolves_per_profile(homes, monkeypatch):
    """Each profile's turn reads back its own adapter; the unscoped slot still serves single-profile."""
    from gateway.config import PlatformConfig
    from gateway.platforms.yuanbao import YuanbaoAdapter

    a, b = homes
    cfg = PlatformConfig(enabled=True, extra={"app_id": "x", "app_secret": "y"})
    monkeypatch.setattr(YuanbaoAdapter, "_active_instance", None)
    monkeypatch.setattr(YuanbaoAdapter, "_active_instances", {})
    with scoped(a):
        adapter_a = YuanbaoAdapter(cfg)
        YuanbaoAdapter.set_active(adapter_a)
    with scoped(b):
        adapter_b = YuanbaoAdapter(cfg)
        YuanbaoAdapter.set_active(adapter_b)
    with scoped(a):
        assert YuanbaoAdapter.get_active() is adapter_a
    with scoped(b):
        assert YuanbaoAdapter.get_active() is adapter_b
    assert YuanbaoAdapter.get_active() is None  # scoped adapters never claim the unscoped slot
    unscoped = YuanbaoAdapter(cfg)
    YuanbaoAdapter.set_active(unscoped)
    assert YuanbaoAdapter.get_active() is unscoped


def test_honcho_loopback_flow_status_is_per_profile(homes, monkeypatch):
    """Profile B's connect must not be refused as 'pending' because profile A's flow is running."""
    import plugins.memory.honcho.oauth_flow as flow

    a, b = homes
    gate = threading.Event()
    started: list[Path] = []

    def fake_authorize(**kwargs):
        started.append(kwargs["config_path"])
        gate.wait(5)

    monkeypatch.setattr(flow, "authorize_via_loopback", fake_authorize)
    monkeypatch.setattr(flow, "_status", flow.FlowStatus())
    monkeypatch.setattr(flow, "_flow_thread", None)
    for home in (a, b):
        (home / "honcho.json").write_text("{}", encoding="utf-8")
    try:
        with scoped(a):
            assert flow.start_loopback_flow_background()["state"] == "pending"
        with scoped(b):
            assert flow.get_flow_status()["state"] == "idle"
            assert flow.start_loopback_flow_background()["state"] == "pending"
        deadline = time.monotonic() + 5
        while time.monotonic() < deadline and len(started) < 2:
            time.sleep(0.02)
        assert sorted(started) == sorted([a / "honcho.json", b / "honcho.json"])
    finally:
        gate.set()
        getattr(flow, "_flows_by_target", {}).clear()