Files
hermes-agent/tests/gateway/test_mcp_shutdown_profile_passes.py
teknium1 4aa9baf139 fix(multiplex): a profile whose home does not resolve must not borrow the launch profile's secrets
_profile_home_or_none answered None both for "this body is the launch profile's own
work" and for "this NAMED profile's home no longer resolves" (deleted or renamed
mid-run, invalid name, transient OSError), and the handler factories captured that
answer once. With the launch-profile fallback added earlier on this branch, an inbound
message on secondary B's own bot was then handled with the LAUNCH profile's .env and
frozen env — a silent cross-tenant credential fallback where origin/main raised.

_routed_profile_home now returns an UNRESOLVED_PROFILE_HOME sentinel and logs a
WARNING; that case binds nothing, so an unscoped get_secret raises UnscopedSecretError
under multiplexing. None keeps exactly one meaning: launch-owned.
2026-09-21 02:58:40 -07:00

76 lines
2.7 KiB
Python

"""MCP teardown at shutdown: the caller's budget is divided, and the wildcard pass is not poisoned.
Each ``shutdown_mcp_servers`` call waits up to its own timeout on the MCP loop, so N per-profile
passes at the 15s default consumed the whole 5s caller budget and the trailing WILDCARD pass — the
only one that stops the shared loop — never ran. And the teardown worker must start from a FRESH
context: a caller sitting inside a served profile's scope would otherwise hand its HERMES_HOME
override to the launch-profile pass, which documents that it has none.
"""
import pytest
from gateway import run as gateway_run
class _Cfg:
multiplex_profiles = True
@pytest.fixture
def homes(tmp_path, monkeypatch):
homes = []
for name in ("a", "b", "c"):
home = tmp_path / name
home.mkdir()
homes.append((name, home))
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "launch"))
(tmp_path / "launch").mkdir()
monkeypatch.setattr(gateway_run, "_multiplex_profile_homes", lambda _cfg: homes)
return homes
@pytest.mark.asyncio
async def test_every_pass_shares_the_callers_budget(homes, monkeypatch):
import tools.mcp_tool_lifecycle as lifecycle
seen: list[tuple] = []
def _fake(*, scope=None, names=None, timeout=15.0):
seen.append((scope, timeout))
monkeypatch.setattr(lifecycle, "shutdown_mcp_servers", _fake)
assert await gateway_run._shutdown_mcp_servers_nonblocking(timeout=8.0, config=_Cfg())
assert len(seen) == 4, f"the wildcard pass did not run: {seen}"
assert seen[-1][0] is None, "the last pass must be the process-wide wildcard"
budget_per_pass = 8.0 / 4
assert all(t <= budget_per_pass + 0.01 for _scope, t in seen), (
f"a pass could outlive the caller's whole budget: {seen}")
@pytest.mark.asyncio
async def test_teardown_thread_does_not_inherit_the_callers_home_override(tmp_path, monkeypatch):
import tools.mcp_tool_lifecycle as lifecycle
from hermes_constants import (
get_hermes_home_override, reset_hermes_home_override, set_hermes_home_override)
launch = tmp_path / "launch"
poison = tmp_path / "profiles" / "poison"
for path in (launch, poison):
path.mkdir(parents=True)
monkeypatch.setenv("HERMES_HOME", str(launch))
seen: list = []
monkeypatch.setattr(
lifecycle, "shutdown_mcp_servers",
lambda **_kw: seen.append(get_hermes_home_override()))
token = set_hermes_home_override(str(poison))
try:
await gateway_run._shutdown_mcp_servers_nonblocking(timeout=5.0, config=None)
finally:
reset_hermes_home_override(token)
assert seen == [None], f"the wildcard teardown ran inside {seen} instead of a fresh context"