Files
hermes-agent/tests/gateway/test_multiplex_pairing_stores.py
beardthelion 0cbe552888 fix(scope): failed profile-scoped secret reads must not borrow os.environ
Eight secret readers wrapped the scoped get_secret() call in a broad
except Exception / contextlib.suppress and fell through to os.environ.
Under multiplex that env holds the default profile's value, so a bound
scope whose resolution fails silently borrowed another profile's
credential: the pairing allowlist reader could then persist the foreign
list into the served profile's .env, and the proxy key, tool gateway
token, OpenRouter and aux provider keys, ElevenLabs key, and Slack token
probe had the same shape.

Keep the deliberate UnscopedSecretError -> os.environ fallback (the
unscoped default-profile path legitimately reads its own env) and let
every other scoped-read failure propagate; the two availability probes
fail closed instead. config._scoped_environ_get now propagates as its
docstring already claimed.
2026-09-23 06:46:02 -07:00

231 lines
8.9 KiB
Python

"""Regression: per-profile PairingStore creation in _start_secondary_profile_adapters.
``gateway/run.py`` referenced ``PairingStore`` at method scope in
``_start_secondary_profile_adapters`` while the class's only import was
method-local inside ``__init__`` — a ``NameError`` at runtime, silently
swallowed by the enclosing ``try/except``, so multiplexing gateways never
created per-profile pairing stores and authz pairing checks for secondary
profiles fell through to the global whitelist.
These tests drive the REAL method (bound onto a bare runner) with the
profile-enumeration and adapter-startup collaborators stubbed, and assert
the per-profile stores actually materialize.
"""
import asyncio
from unittest.mock import MagicMock, patch
from gateway.run import GatewayRunner
def _bare_runner(multiplex: bool = True):
runner = object.__new__(GatewayRunner)
runner.config = MagicMock(multiplex_profiles=multiplex)
runner.adapters = {}
runner._profile_adapters = {}
runner.pairing_store = MagicMock()
runner.pairing_stores = {}
return runner
def test_secondary_profile_pairing_stores_created(tmp_path, monkeypatch):
"""The served-profiles loop must create a PairingStore per profile.
Pre-fix this silently did nothing: the ``PairingStore(profile=name)``
reference raised NameError inside the swallowed try/except.
"""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
(tmp_path / ".hermes").mkdir()
runner = _bare_runner()
async def _no_secondary(profile_name, profile_home, claimed):
return 0
runner._start_one_profile_adapters = _no_secondary
runner._adapter_credential_fingerprint = lambda adapter: None
with patch("hermes_cli.profiles.profiles_to_serve", return_value=[
("coder", tmp_path / ".hermes" / "profiles" / "coder"),
]), patch("hermes_cli.profiles.get_active_profile_name", return_value="default"):
runner._profile_adapters["coder"] = {}
asyncio.run(runner._start_secondary_profile_adapters())
# Both the active profile and the served secondary get a store.
assert "default" in runner.pairing_stores, (
"active profile PairingStore missing — the NameError swallow is back"
)
assert runner.pairing_stores["default"] is runner.pairing_store
assert "coder" in runner.pairing_stores, (
"secondary profile PairingStore missing — the NameError swallow is back"
)
def test_pairing_store_scoped_to_profile_dir(tmp_path, monkeypatch):
"""The created store must live under the profile's pairing directory."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
(tmp_path / ".hermes").mkdir()
runner = _bare_runner()
async def _no_secondary(profile_name, profile_home, claimed):
return 0
runner._start_one_profile_adapters = _no_secondary
runner._adapter_credential_fingerprint = lambda adapter: None
with patch("hermes_cli.profiles.profiles_to_serve", return_value=[
("ops", tmp_path / ".hermes" / "profiles" / "ops"),
]), patch("hermes_cli.profiles.get_active_profile_name", return_value="default"):
runner._profile_adapters["ops"] = {}
asyncio.run(runner._start_secondary_profile_adapters())
store = runner.pairing_stores["ops"]
assert store.profile == "ops"
assert "profiles/ops/platforms/pairing" in str(store._dir).replace("\\", "/"), (
f"store not profile-scoped: {store._dir}"
)
def test_routed_pairing_grant_mirror_stays_in_profile_scope(tmp_path, monkeypatch):
"""A /pair grant mirrored under a routed profile scope must update THAT
profile's .env and installed scope, never the shared os.environ (#88441,
#77490). Outside multiplex the legacy os.environ publish is unchanged."""
import os
from agent import secret_scope as ss
from gateway.pairing import _sync_allowlist_add
from gateway.run import _profile_runtime_scope
from hermes_cli.config import save_env_value
root = tmp_path / ".hermes"
prof = root / "profiles" / "b"
prof.mkdir(parents=True)
(root / ".env").write_text("DISCORD_ALLOWED_USERS=default-admin\n")
(prof / ".env").write_text("DISCORD_ALLOWED_USERS=b-admin\n")
monkeypatch.setenv("HERMES_HOME", str(root))
monkeypatch.setenv("DISCORD_ALLOWED_USERS", "default-admin")
was_active = ss.is_multiplex_active()
ss.set_multiplex_active(True)
try:
with _profile_runtime_scope(prof):
_sync_allowlist_add("discord", "111")
assert ss.get_secret("DISCORD_ALLOWED_USERS") == "b-admin,111"
finally:
ss.set_multiplex_active(was_active)
assert (prof / ".env").read_text().strip() == "DISCORD_ALLOWED_USERS=b-admin,111"
assert (root / ".env").read_text().strip() == "DISCORD_ALLOWED_USERS=default-admin"
assert os.environ["DISCORD_ALLOWED_USERS"] == "default-admin"
# Single-profile: no multiplex -> save still publishes to the process env.
save_env_value("DISCORD_ALLOWED_USERS", "default-admin,222")
assert os.environ["DISCORD_ALLOWED_USERS"] == "default-admin,222"
class _ExplodingScope(dict):
"""A bound secret scope whose resolution fails (resolver/backend error)."""
def get(self, name, default=None):
raise RuntimeError("resolver boom")
def test_allowlist_env_read_never_borrows_on_scope_failure(tmp_path, monkeypatch):
"""A bound-scope allowlist read that fails must propagate -- never borrow the
default profile's ``os.environ`` value. The unscoped path under multiplex keeps
the deliberate env read (launch profile's own value, the "Slack pattern").
Single-profile deployments keep the legacy ``os.environ`` read.
"""
import os
import pytest
from agent import secret_scope as ss
from gateway import pairing
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
monkeypatch.setenv("DISCORD_ALLOWED_USERS", "default-admin")
was_active = ss.is_multiplex_active()
ss.set_multiplex_active(True)
try:
# Deliberate: unscoped under multiplex is the launch profile's own env.
assert pairing._read_allowlist_env("DISCORD_ALLOWED_USERS") == "default-admin"
# Defect arm: a bound scope that errors must propagate, not borrow.
token = ss.set_secret_scope(_ExplodingScope())
try:
with pytest.raises(RuntimeError, match="resolver boom"):
pairing._read_allowlist_env("DISCORD_ALLOWED_USERS")
finally:
ss.reset_secret_scope(token)
finally:
ss.set_multiplex_active(was_active)
# Control: unscoped single-profile reads still see the process env.
assert pairing._read_allowlist_env("DISCORD_ALLOWED_USERS") == "default-admin"
def test_allowlist_sync_does_not_persist_foreign_allowlist(tmp_path, monkeypatch):
"""End-to-end: ``_sync_allowlist_add`` under a bound scope whose read fails
must propagate rather than borrow ``os.environ`` and persist the DEFAULT
profile's allowlist into a profile ``.env`` / the process env.
"""
import os
import pytest
from agent import secret_scope as ss
from gateway import pairing
root = tmp_path / ".hermes"
root.mkdir(parents=True)
monkeypatch.setenv("HERMES_HOME", str(root))
monkeypatch.setenv("DISCORD_ALLOWED_USERS", "default-admin")
was_active = ss.is_multiplex_active()
ss.set_multiplex_active(True)
token = ss.set_secret_scope(_ExplodingScope())
try:
with pytest.raises(RuntimeError, match="resolver boom"):
pairing._sync_allowlist_add("discord", "111")
finally:
ss.reset_secret_scope(token)
ss.set_multiplex_active(was_active)
# The foreign allowlist was neither persisted nor merged into the process env.
env_file = root / ".env"
assert "111" not in (env_file.read_text() if env_file.exists() else "")
assert os.environ["DISCORD_ALLOWED_USERS"] == "default-admin"
def test_allowlist_scoped_miss_configures_nothing(tmp_path, monkeypatch):
"""A bound scope that lacks the var returns "" -- the allowlist is
unconfigured for this profile, so the sync is a no-op and nothing is
written (never the default profile's ``os.environ`` value).
"""
import os
from agent import secret_scope as ss
from gateway import pairing
from gateway.run import _profile_runtime_scope
root = tmp_path / ".hermes"
prof = root / "profiles" / "b"
prof.mkdir(parents=True)
(prof / ".env").write_text("OTHER_KEY=x\n")
monkeypatch.setenv("HERMES_HOME", str(root))
monkeypatch.setenv("DISCORD_ALLOWED_USERS", "default-admin")
was_active = ss.is_multiplex_active()
ss.set_multiplex_active(True)
try:
with _profile_runtime_scope(prof):
assert pairing._read_allowlist_env("DISCORD_ALLOWED_USERS") == ""
pairing._sync_allowlist_add("discord", "111")
finally:
ss.set_multiplex_active(was_active)
assert (prof / ".env").read_text().strip() == "OTHER_KEY=x"
assert os.environ["DISCORD_ALLOWED_USERS"] == "default-admin"