Files
hermes-agent/tests/gateway/test_launch_profile_scope_fallback.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

100 lines
4.2 KiB
Python

"""Who owns a body with no routed profile home — and who must NOT.
``GatewayAdapterLifecycleMixin._scope_or_null`` used to return ``contextlib.nullcontext()`` whenever
the profile home was ``None`` — the launch profile's own handoff reclaims, reconnect attention
flags and platform events therefore ran completely unscoped on a multiplexing host: a legitimate
launch-profile ``get_secret`` failed closed, and anything reading process env picked up whatever a
secondary context had left there.
``None`` must mean exactly one thing. A NAMED profile whose home no longer resolves answers the
:data:`UNRESOLVED_PROFILE_HOME` sentinel instead, and binds nothing: handing it the launch
profile's scope would serve a secondary's inbound message with the LAUNCH profile's credentials.
"""
from types import SimpleNamespace
import pytest
from agent import secret_scope
from agent.secret_scope import UnscopedSecretError, get_secret
from gateway.run_adapters import UNRESOLVED_PROFILE_HOME, GatewayAdapterLifecycleMixin
from tui_gateway import launch_profile_policy
POISON = "LAUNCHSCOPE_TEST_KEY"
@pytest.fixture
def multiplexing_host(tmp_path, monkeypatch):
launch = tmp_path / "launch"
launch.mkdir()
(launch / ".env").write_text(f"{POISON}=launch-dotenv\n", encoding="utf-8")
monkeypatch.setenv("HERMES_HOME", str(launch))
monkeypatch.setattr(secret_scope, "_MULTIPLEX_ACTIVE", False)
monkeypatch.setattr(launch_profile_policy, "_snapshot", None)
launch_profile_policy.activate_multi_profile_hosting()
# A secondary's context poisons the process env AFTER activation; the frozen snapshot must win.
monkeypatch.setenv(POISON, "secondary-poison")
return launch
def test_scope_or_null_binds_launch_profile_when_no_routed_home(multiplexing_host):
with GatewayAdapterLifecycleMixin._scope_or_null(lambda home: None, None):
# Base: nullcontext -> UnscopedSecretError on a legitimate launch-profile read.
assert get_secret(POISON) == "launch-dotenv"
@pytest.mark.asyncio
async def test_async_scope_or_null_binds_launch_profile_when_no_routed_home(multiplexing_host):
async with GatewayAdapterLifecycleMixin._async_scope_or_null(lambda home: None, None):
assert get_secret(POISON) == "launch-dotenv"
def test_single_profile_host_keeps_ambient_precedence(monkeypatch):
"""Never activated -> no binding at all, so ``os.environ`` precedence is byte-identical."""
monkeypatch.setattr(secret_scope, "_MULTIPLEX_ACTIVE", False)
monkeypatch.setenv(POISON, "ambient")
with GatewayAdapterLifecycleMixin._scope_or_null(lambda home: None, None):
assert get_secret(POISON) == "ambient"
class _Runner(GatewayAdapterLifecycleMixin):
"""Just enough runner for the production handler factory."""
def __init__(self, seen):
self.seen = seen
async def _handle_message(self, _event):
try:
self.seen.append(get_secret(POISON))
except UnscopedSecretError:
self.seen.append("<fail-closed>")
@pytest.mark.asyncio
async def test_named_secondary_with_unresolvable_home_cannot_read_the_launch_secret(
multiplexing_host, monkeypatch):
"""The invariant: a profile never borrows another profile's credential value.
Base resolved the missing home to ``None`` and the handler ran under the LAUNCH profile's
scope, so an inbound message on profile B's own bot read ``launch-dotenv``.
"""
from hermes_cli import profiles as profiles_mod
def _gone(_name):
raise FileNotFoundError("profile deleted mid-run")
monkeypatch.setattr(profiles_mod, "get_profile_dir", _gone)
seen: list[str] = []
handler = _Runner(seen)._make_profile_message_handler("b")
await handler(SimpleNamespace(source=None))
assert seen == ["<fail-closed>"], f"secondary's handler resolved a credential to {seen}"
def test_unresolvable_home_is_a_sentinel_not_none(multiplexing_host, monkeypatch):
"""Overloading ``None`` is what made the fallback silent; keep the two answers distinct."""
from hermes_cli import profiles as profiles_mod
monkeypatch.setattr(profiles_mod, "get_profile_dir", lambda _n: (_ for _ in ()).throw(OSError()))
assert GatewayAdapterLifecycleMixin._routed_profile_home("b") is UNRESOLVED_PROFILE_HOME