#108319 made the release thread carry the caller's context and enter the owner's profile scope only when no scope was present. But the LRU-cap eviction runs inside the REQUESTING turn (run_turn_runner: _enforce_agent_cache_cap / _release_evicted_agent), and the LRU agent it evicts may belong to another profile — so with profile A's scope active, profile B's end-of-session memory commit ran under A's scope and A's home: B's transcript extracted into A's provider namespace with A's credentials. Before #108319 the same commit ran under the launch profile; the fix moved the leak, it did not close it. _run_release_in_profile_scope now resolves the owner from the session key first — a named profile's home, else the DEFAULT profile at the root Hermes dir (agent:main: keys), which is not the launch profile when the gateway runs under `hermes -p x` — and enters that profile's scope whenever the current one is not already the owner's (compared via hermes_home_key). Same-profile in-turn evictions and the unscoped housekeeping sweep behave as before. A failed owner lookup is logged at WARNING instead of silently swallowed, since it reassigns the commit to the default profile. Three invariant tests: A's turn evicting B's agent commits under B; a secondary's turn evicting a default-profile agent commits under the default home; the same with the gateway launched under a named profile still commits under the root. All red on main.
137 lines
6.1 KiB
Python
137 lines
6.1 KiB
Python
"""Multiplex invariant: agent-cache eviction commits end-of-session memory under the OWNING profile.
|
|
|
|
``_spawn_release_thread`` used to start a bare ``threading.Thread``; threads begin with an EMPTY
|
|
context, so provider ``on_session_end`` (credentials/home read at call time) ran under the launch
|
|
profile — a secondary's transcript was extracted into the default profile's memory namespace, or
|
|
failed closed (``UnscopedSecretError``) and the memories were lost.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import threading
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
from agent import secret_scope
|
|
from gateway.config import GatewayConfig
|
|
from gateway.run import GatewayRunner
|
|
from hermes_constants import get_hermes_home
|
|
|
|
|
|
def _runner(profile_homes: dict[str, Path]) -> GatewayRunner:
|
|
runner = GatewayRunner.__new__(GatewayRunner)
|
|
runner.config = GatewayConfig(multiplex_profiles=True)
|
|
runner.session_store = SimpleNamespace(_profile_home_for_key=lambda key: profile_homes.get(key))
|
|
return runner
|
|
|
|
|
|
def _seen_after_release(runner, key, *, wait_scope=None) -> dict:
|
|
seen: dict = {}
|
|
done = threading.Event()
|
|
|
|
def target(agent, k):
|
|
seen["scope"] = secret_scope.current_secret_scope()
|
|
seen["home"] = get_hermes_home()
|
|
done.set()
|
|
|
|
runner._spawn_release_thread(target, (None, key), f"t-{key}", inline_fallback=False, session_key=key)
|
|
assert done.wait(5)
|
|
return seen
|
|
|
|
|
|
def test_unscoped_housekeeping_sweep_enters_the_owning_profile_scope(tmp_path, monkeypatch):
|
|
default_home = tmp_path / ".hermes"
|
|
prof_b = default_home / "profiles" / "b"
|
|
prof_b.mkdir(parents=True)
|
|
(prof_b / ".env").write_text("HINDSIGHT_LLM_API_KEY=key-of-b\n")
|
|
monkeypatch.setenv("HERMES_HOME", str(default_home))
|
|
secret_scope.set_multiplex_active(True)
|
|
try:
|
|
# The housekeeping watcher runs with NO scope installed.
|
|
assert secret_scope.current_secret_scope() is None
|
|
seen = _seen_after_release(_runner({"agent:b:telegram:dm:1": prof_b}), "agent:b:telegram:dm:1")
|
|
finally:
|
|
secret_scope.set_multiplex_active(False)
|
|
assert seen["home"] == prof_b
|
|
assert seen["scope"] and seen["scope"].get("HINDSIGHT_LLM_API_KEY") == "key-of-b"
|
|
|
|
|
|
def test_in_turn_cap_eviction_keeps_the_callers_scope(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
secret_scope.set_multiplex_active(True)
|
|
token = secret_scope.set_secret_scope({"MARKER": "turn-scope"})
|
|
try:
|
|
seen = _seen_after_release(_runner({}), "agent:main:cli:1")
|
|
finally:
|
|
secret_scope.reset_secret_scope(token)
|
|
secret_scope.set_multiplex_active(False)
|
|
assert seen["scope"] == {"MARKER": "turn-scope"}
|
|
|
|
|
|
def test_in_turn_cap_eviction_of_another_profiles_agent_enters_the_owners_scope(tmp_path, monkeypatch):
|
|
"""The LRU cap is enforced inside the REQUESTING turn (profile A's scope), and the agent it
|
|
evicts may be profile B's. B's end-of-session commit must run under B, not under A."""
|
|
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
|
|
|
|
default_home = tmp_path / ".hermes"
|
|
prof_a, prof_b = default_home / "profiles" / "a", default_home / "profiles" / "b"
|
|
prof_a.mkdir(parents=True), prof_b.mkdir(parents=True)
|
|
(prof_b / ".env").write_text("HINDSIGHT_LLM_API_KEY=key-of-b\n")
|
|
monkeypatch.setenv("HERMES_HOME", str(default_home))
|
|
secret_scope.set_multiplex_active(True)
|
|
home_token = set_hermes_home_override(str(prof_a))
|
|
scope_token = secret_scope.set_secret_scope({"HINDSIGHT_LLM_API_KEY": "key-of-a"})
|
|
try:
|
|
seen = _seen_after_release(_runner({"agent:b:telegram:dm:1": prof_b}), "agent:b:telegram:dm:1")
|
|
finally:
|
|
secret_scope.reset_secret_scope(scope_token)
|
|
reset_hermes_home_override(home_token)
|
|
secret_scope.set_multiplex_active(False)
|
|
assert seen["home"] == prof_b
|
|
assert seen["scope"].get("HINDSIGHT_LLM_API_KEY") == "key-of-b"
|
|
|
|
|
|
def test_in_turn_cap_eviction_of_a_default_profile_agent_leaves_the_secondarys_scope(tmp_path, monkeypatch):
|
|
"""Inverse: secondary A's turn evicts a default-profile session (``agent:main:`` — no named
|
|
owner in the key). The commit runs under the default home, not under A."""
|
|
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
|
|
|
|
default_home = tmp_path / ".hermes"
|
|
prof_a = default_home / "profiles" / "a"
|
|
prof_a.mkdir(parents=True)
|
|
monkeypatch.setenv("HERMES_HOME", str(default_home))
|
|
secret_scope.set_multiplex_active(True)
|
|
home_token = set_hermes_home_override(str(prof_a))
|
|
scope_token = secret_scope.set_secret_scope({"MARKER": "profile-a"})
|
|
try:
|
|
seen = _seen_after_release(_runner({}), "agent:main:telegram:dm:9")
|
|
finally:
|
|
secret_scope.reset_secret_scope(scope_token)
|
|
reset_hermes_home_override(home_token)
|
|
secret_scope.set_multiplex_active(False)
|
|
assert seen["home"] == default_home
|
|
assert seen["scope"] is not None and seen["scope"].get("MARKER") is None
|
|
|
|
|
|
def test_default_profile_owner_is_the_root_even_when_launched_under_a_named_profile(tmp_path, monkeypatch):
|
|
"""``hermes -p x gateway`` sets HERMES_HOME to x's home and serves the default profile as a
|
|
secondary. An ``agent:main:`` session still belongs to the default profile at the ROOT, not to
|
|
the launch profile x — otherwise x's turn would commit default's transcript under x."""
|
|
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
|
|
|
|
root = tmp_path / ".hermes"
|
|
prof_x = root / "profiles" / "x"
|
|
prof_x.mkdir(parents=True)
|
|
(root / ".env").write_text("MARKER=default-root\n")
|
|
monkeypatch.setenv("HERMES_HOME", str(prof_x)) # launched under x
|
|
secret_scope.set_multiplex_active(True)
|
|
home_token = set_hermes_home_override(str(prof_x))
|
|
scope_token = secret_scope.set_secret_scope({"MARKER": "profile-x"})
|
|
try:
|
|
seen = _seen_after_release(_runner({}), "agent:main:telegram:dm:3")
|
|
finally:
|
|
secret_scope.reset_secret_scope(scope_token)
|
|
reset_hermes_home_override(home_token)
|
|
secret_scope.set_multiplex_active(False)
|
|
assert seen["home"] == root
|
|
assert seen["scope"].get("MARKER") == "default-root"
|