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.
231 lines
8.9 KiB
Python
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"
|