Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,314 @@
|
||||
"""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"
|
||||
Reference in New Issue
Block a user