"""Multi-profile client isolation tests. Pin the cross-tenant bleed class (#69123 multiplexed gateway, #74065 dashboard): a process-wide first-config-wins client singleton baked the first profile's workspace_id and bearer into one shared client, so every later profile's memory landed in the first profile's workspace. The tests drive the REAL resolution chain — HonchoClientConfig.from_global_config against real honcho.json files under temp HERMES_HOMEs, with the same ContextVar override the gateway multiplexer / dashboard use — and assert client identity, not internals. The two-profile repro mirrors issue #69123's minimal in-process repro; per-config-identity caching was first proposed in #69142 (NaMinhyeok) and extended in #81401 (angel12). """ import json import threading import pytest import plugins.memory.honcho.client as client_mod from hermes_constants import reset_hermes_home_override, set_hermes_home_override from plugins.memory.honcho.client import ( HonchoClientConfig, get_honcho_client, reset_honcho_client, ) pytestmark = pytest.mark.skipif( not pytest.importorskip("honcho", reason="honcho SDK not installed"), reason="honcho SDK not installed", ) @pytest.fixture(autouse=True) def _clean_client_cache(): reset_honcho_client() yield reset_honcho_client() def _make_profile(tmp_path, name: str, workspace: str, api_key: str, host: str | None = None, oauth: dict | None = None): home = tmp_path / name home.mkdir(parents=True, exist_ok=True) host = host or "hermes" block: dict = {"apiKey": api_key, "workspace": workspace} if oauth: block["oauth"] = oauth (home / "honcho.json").write_text(json.dumps({"hosts": {host: block}})) return home class _FakeHoncho: """Stands in for honcho.Honcho; records constructor kwargs.""" instances: list = [] def __init__(self, **kwargs): self.kwargs = kwargs _FakeHoncho.instances.append(self) @pytest.fixture def fake_honcho(monkeypatch): _FakeHoncho.instances = [] import honcho monkeypatch.setattr(honcho, "Honcho", _FakeHoncho) return _FakeHoncho class TestTwoProfileIsolation: def test_profiles_get_distinct_clients_and_workspaces(self, tmp_path, fake_honcho): """#69123's minimal repro: override -> client -> reset -> override -> client.""" home_a = _make_profile(tmp_path, "profiles/a", "tenant-a", "key-a") home_b = _make_profile(tmp_path, "profiles/b", "tenant-b", "key-b") token = set_hermes_home_override(home_a) try: cfg_a = HonchoClientConfig.from_global_config() client_a = get_honcho_client(cfg_a) finally: reset_hermes_home_override(token) token = set_hermes_home_override(home_b) try: cfg_b = HonchoClientConfig.from_global_config() client_b = get_honcho_client(cfg_b) finally: reset_hermes_home_override(token) assert client_a is not client_b assert client_a.kwargs["workspace_id"] == "tenant-a" assert client_b.kwargs["workspace_id"] == "tenant-b" assert client_a.kwargs["api_key"] == "key-a" assert client_b.kwargs["api_key"] == "key-b" def test_same_profile_reuses_client(self, tmp_path, fake_honcho): home_a = _make_profile(tmp_path, "profiles/a", "tenant-a", "key-a") token = set_hermes_home_override(home_a) try: cfg1 = HonchoClientConfig.from_global_config() c1 = get_honcho_client(cfg1) cfg2 = HonchoClientConfig.from_global_config() c2 = get_honcho_client(cfg2) finally: reset_hermes_home_override(token) assert c1 is c2 assert len(fake_honcho.instances) == 1 class TestBackgroundThreadIsolation: def test_bound_config_wins_on_bare_thread(self, tmp_path, fake_honcho): """A manager's bound config must acquire ITS profile's client even from a thread that cannot see the profile ContextVar — the pattern of every plugin daemon thread (async writer, prefetch, sync).""" home_a = _make_profile(tmp_path, "profiles/a", "tenant-a", "key-a") home_b = _make_profile(tmp_path, "profiles/b", "tenant-b", "key-b") # Default-profile client exists first (the "pinning" client). token = set_hermes_home_override(home_a) try: cfg_a = HonchoClientConfig.from_global_config() get_honcho_client(cfg_a) finally: reset_hermes_home_override(token) # Profile B's config resolved inside its scope (as initialize() does). token = set_hermes_home_override(home_b) try: cfg_b = HonchoClientConfig.from_global_config() finally: reset_hermes_home_override(token) # A bare thread (empty context — no profile override visible) # acquires via the bound config, as manager.honcho now does. box: dict = {} def _worker(): box["client"] = get_honcho_client(cfg_b) t = threading.Thread(target=_worker) t.start() t.join(timeout=10) assert box["client"].kwargs["workspace_id"] == "tenant-b" assert box["client"].kwargs["api_key"] == "key-b" def test_spawn_context_thread_sees_profile_override(self, tmp_path): """spawn_context_thread must carry the caller's HERMES_HOME override.""" from hermes_constants import get_hermes_home from plugins.memory.honcho.client import spawn_context_thread home_b = tmp_path / "profiles" / "b" home_b.mkdir(parents=True) seen: dict = {} def _probe(): seen["home"] = get_hermes_home() token = set_hermes_home_override(home_b) try: t = spawn_context_thread(_probe, name="probe") t.start() t.join(timeout=10) finally: reset_hermes_home_override(token) assert seen["home"] == home_b def test_plain_thread_does_not_see_override(self, tmp_path): """Control: documents WHY propagation is needed — a plain thread resolves the process home, not the caller's profile override.""" from hermes_constants import get_hermes_home home_b = tmp_path / "profiles" / "b" home_b.mkdir(parents=True) seen: dict = {} def _probe(): seen["home"] = get_hermes_home() token = set_hermes_home_override(home_b) try: t = threading.Thread(target=_probe) t.start() t.join(timeout=10) finally: reset_hermes_home_override(token) assert seen["home"] != home_b class TestCredentialIdentity: def test_account_swap_creates_new_client_and_evicts_old(self, tmp_path, fake_honcho): """Switching accounts via setup (same path/host, new apiKey) must not keep serving the old account's client — the collision a provenance-only cache key cannot close.""" home = _make_profile(tmp_path, "profiles/a", "tenant-a", "key-account-1") token = set_hermes_home_override(home) try: cfg1 = HonchoClientConfig.from_global_config() c1 = get_honcho_client(cfg1) # Operator re-runs setup: same file, new account credentials. (home / "honcho.json").write_text(json.dumps({ "hosts": {"hermes": {"apiKey": "key-account-2", "workspace": "tenant-a"}}, })) cfg2 = HonchoClientConfig.from_global_config() c2 = get_honcho_client(cfg2) finally: reset_hermes_home_override(token) assert c1 is not c2 assert c2.kwargs["api_key"] == "key-account-2" # Old slot evicted: the stale client is no longer reachable via the map. with client_mod._client_slots_lock: cached_clients = [ s.peek() for s in client_mod._client_slots.values() ] assert c1 not in cached_clients def test_oauth_refresh_token_is_fingerprint_basis(self, tmp_path): """Fingerprint must survive access-token rotation (in-place bearer swap) but change when the refresh token (re-auth) changes.""" home = tmp_path / "p" home.mkdir() oauth_block = { "refreshToken": "refresh-1", "tokenEndpoint": "https://auth.example/token", "clientId": "cid", "expiresAt": 9999999999, } (home / "honcho.json").write_text(json.dumps({ "hosts": {"hermes": {"apiKey": "access-token-1", "workspace": "w", "oauth": oauth_block}}, })) token = set_hermes_home_override(home) try: cfg1 = HonchoClientConfig.from_global_config() fp1 = client_mod._credential_fingerprint(cfg1) # Access token rotates in place; refresh token unchanged. cfg_rotated = HonchoClientConfig.from_global_config() cfg_rotated.api_key = "access-token-2" fp_rotated = client_mod._credential_fingerprint(cfg_rotated) # Re-auth: new refresh token. oauth_block2 = dict(oauth_block, refreshToken="refresh-2") (home / "honcho.json").write_text(json.dumps({ "hosts": {"hermes": {"apiKey": "access-token-3", "workspace": "w", "oauth": oauth_block2}}, })) cfg2 = HonchoClientConfig.from_global_config() fp2 = client_mod._credential_fingerprint(cfg2) finally: reset_hermes_home_override(token) assert fp1 == fp_rotated, "access-token rotation must not change identity" assert fp1 != fp2, "re-auth must change identity" def test_timeout_change_rebuilds_via_key(self, tmp_path, fake_honcho): """The old singleton had an explicit timeout-staleness check; with timeout in the key, a change produces a new identity + eviction.""" home = _make_profile(tmp_path, "profiles/a", "w", "k") token = set_hermes_home_override(home) try: cfg1 = HonchoClientConfig.from_global_config() c1 = get_honcho_client(cfg1) raw = json.loads((home / "honcho.json").read_text()) raw["hosts"]["hermes"]["timeout"] = 77 (home / "honcho.json").write_text(json.dumps(raw)) cfg2 = HonchoClientConfig.from_global_config() c2 = get_honcho_client(cfg2) finally: reset_hermes_home_override(token) assert c1 is not c2 assert c2.kwargs["timeout"] == 77.0 class TestProvenance: def test_from_global_config_captures_provenance(self, tmp_path): home = _make_profile(tmp_path, "profiles/a", "w", "k") token = set_hermes_home_override(home) try: cfg = HonchoClientConfig.from_global_config() finally: reset_hermes_home_override(token) assert cfg.config_path == home / "honcho.json" assert cfg.hermes_home == home assert cfg.bound_config_path() == home / "honcho.json" def test_bound_path_stable_outside_scope(self, tmp_path): """The captured path must not drift when read outside the profile scope (the daemon-thread situation).""" home = _make_profile(tmp_path, "profiles/a", "w", "k") token = set_hermes_home_override(home) try: cfg = HonchoClientConfig.from_global_config() finally: reset_hermes_home_override(token) # Now OUTSIDE the scope — bound path still points at profile a. assert cfg.bound_config_path() == home / "honcho.json"