de114b3af1
Scoped secrets — `gateway.platforms._shared.get_scoped_secret` is the single implementation of the "scope authoritative, unscoped default-profile falls back to os.environ" read: - plugins/platforms/buzz/adapter.py::_get_scoped_secret (113 LOC, ~100 of which were one docstring paragraph pasted 16x) -> 3-line forwarder over the canonical with `external_fallback=True`. Its one genuine extra rung (one-shot profile-scope build so a Bitwarden-managed key is visible to the startup gate, #95216) moves into `_shared` as that keyword plus `_unscoped_profile_secrets`. - weixin::_wx_secret, matrix::_startup_env_secret, the inline try/except copies in slack (SLACK_APP_TOKEN) and telegram (TELEGRAM_WEBHOOK_SECRET/_URL) -> canonical. - The "extra-first, then scoped env" reader written 11x under 6 names (weixin._extra_or_env, bluebubbles/ntfy/photon/wecom `_setting`, dingtalk `_extra_get`, mattermost `_extra_or_env`, slack `_extra_or_env_flag/_channel_set`, feishu closures) -> `_shared.extra_or_secret`. - `authz_mixin._platform_gate_env` -> `_shared.platform_gate_env`; discord/telegram drop their `_scoped_gate_env` twins; run.py / run_config_loaders.py / slack import it directly. Boilerplate — three table-driven helpers in `_shared` replace the pasted docs template: - `seed_extra_from_env(spec, home_env=)` replaces 8 `_env_enablement` bodies (buzz, google_chat, irc, line, ntfy, photon, simplex, teams; raft is a one-liner and untouched). - `apply_yaml_bridge(cfg, spec)` replaces 7 `_apply_yaml_config` bodies (buzz, dingtalk, feishu, matrix, mattermost, slack, whatsapp); discord/telegram keep bespoke bridges (alias keys, nested `platforms.*.extra`, generic-key exclusions). buzz and mattermost previously bypassed `yaml_env_setter` with hand-rolled `os.environ` writes. - `env_is_connected(*vars)` replaces 5 identical `_is_connected` (discord, homeassistant, mattermost, slack, sms). - 8 identity `_build_adapter` wrappers deleted; `adapter_factory=<Class>`. Behavior change: - buzz `_apply_yaml_config` returned None, so under multiplex a secondary Buzz profile got neither env (correctly skipped) nor `extra` for relay_url/channels/allow_all_users/...; it now seeds `extra` like every other hook. It also wrote reply_in_thread/reply_to_mode to the process env even inside a secondary profile's scope (first-writer-wins leak, #80099 class); it no longer does. BUZZ_POLL_INTERVAL is bridged through the same table. - `home_channel.name` default when `<X>_HOME_CHANNEL_NAME` is unset is now the literal "Home" for all plugins (irc/ntfy/buzz used the chat id; simplex/teams/photon/google_chat already used "Home", as do the built-in platforms in gateway/config_env.py). - weixin's non-secret tunables (send_chunk_*, rate_limit_circuit_*) now read through the scoped reader instead of raw os.getenv — a secondary profile no longer inherits the default's values. - `extra_or_secret` treats a blank string in extra as unset (falls to env) and an explicit False as a real value, the strictest of the merged copies. - slack `reaction_trigger_target` bridges via str(); `reaction_triggers` comma-joins any list-ish value (was list/tuple/set only) — same env text for every real YAML shape. Docs: website/docs/developer-guide/adding-platform-adapters.md (the template the copies were pasted from) and gateway/platforms/ADDING_A_PLATFORM.md now show the helpers and the scoped reader; gateway/AGENTS.md points at the one implementation. Tests: tests/gateway/test_shared_platform_boilerplate.py — every plugin `_env_enablement` reads only through the scoped getter (parametrized over the 8 plugins, spy on the seam, raw `os.getenv`/`get_env_value` asserted untouched); buzz bridge seeds `extra` for a secondary profile and still bridges env for the default; one home-name rule; extra_or_secret contract; external_fallback rung. Existing tests repointed: tests/agent/test_secret_scope_tier1_migration.py, tests/plugins/platforms/buzz/test_buzz_unscoped_requirement_gate.py.
267 lines
11 KiB
Python
267 lines
11 KiB
Python
"""Regression tests for the Tier-1 core-gateway secret-scope migration.
|
|
|
|
Class-closure follow-up to the profile secret-scope cluster (#76462 /
|
|
#76574): representative call sites from each migrated cluster are exercised
|
|
against the three canonical scope semantics:
|
|
|
|
- scoped value wins (the installed profile's secret is used),
|
|
- scoped miss does NOT borrow the process env under multiplex (no-borrow),
|
|
- unscoped-under-multiplex behavior per pattern:
|
|
* in-turn sites (get_secret direct) propagate/honor UnscopedSecretError
|
|
semantics via get_secret's verdict,
|
|
* startup sites (Slack pattern) fall back to os.environ on
|
|
UnscopedSecretError.
|
|
"""
|
|
|
|
import pytest
|
|
|
|
from agent import secret_scope as ss
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _reset_multiplex():
|
|
ss.set_multiplex_active(False)
|
|
yield
|
|
ss.set_multiplex_active(False)
|
|
|
|
|
|
class _Scope:
|
|
"""Context manager installing a secret scope."""
|
|
|
|
def __init__(self, mapping):
|
|
self.mapping = mapping
|
|
self.token = None
|
|
|
|
def __enter__(self):
|
|
self.token = ss.set_secret_scope(self.mapping)
|
|
return self
|
|
|
|
def __exit__(self, *exc):
|
|
ss.reset_secret_scope(self.token)
|
|
|
|
|
|
# ── Cluster A: gateway/pairing.py allowlist reads ─────────────────────────
|
|
|
|
class TestPairingAllowlistRead:
|
|
def test_scoped_value_wins(self, monkeypatch):
|
|
from gateway.pairing import _read_allowlist_env
|
|
|
|
monkeypatch.setenv("TELEGRAM_ALLOWED_USERS", "111")
|
|
ss.set_multiplex_active(True)
|
|
with _Scope({"TELEGRAM_ALLOWED_USERS": "222"}):
|
|
assert _read_allowlist_env("TELEGRAM_ALLOWED_USERS") == "222"
|
|
|
|
def test_scoped_miss_no_borrow(self, monkeypatch):
|
|
from gateway.pairing import _read_allowlist_env
|
|
|
|
monkeypatch.setenv("TELEGRAM_ALLOWED_USERS", "other-profile")
|
|
ss.set_multiplex_active(True)
|
|
with _Scope({"UNRELATED": "x"}):
|
|
assert _read_allowlist_env("TELEGRAM_ALLOWED_USERS") == ""
|
|
|
|
def test_unscoped_multiplex_falls_back_to_env(self, monkeypatch):
|
|
# Slack pattern: unscoped read under multiplex uses the process env.
|
|
from gateway.pairing import _read_allowlist_env
|
|
|
|
monkeypatch.setenv("TELEGRAM_ALLOWED_USERS", "own-env")
|
|
ss.set_multiplex_active(True)
|
|
assert _read_allowlist_env("TELEGRAM_ALLOWED_USERS") == "own-env"
|
|
|
|
|
|
# ── Cluster A: gateway/authz_mixin.py gate reads ───────────────────────────
|
|
|
|
class TestAuthzPlatformGateEnv:
|
|
def test_scoped_value_wins(self, monkeypatch):
|
|
from gateway.platforms._shared import platform_gate_env as _platform_gate_env
|
|
|
|
monkeypatch.setenv("DISCORD_ALLOW_BOTS", "none")
|
|
ss.set_multiplex_active(True)
|
|
with _Scope({"DISCORD_ALLOW_BOTS": "all"}):
|
|
assert _platform_gate_env("DISCORD_ALLOW_BOTS", "none") == "all"
|
|
|
|
def test_scoped_miss_returns_default_not_env(self, monkeypatch):
|
|
from gateway.platforms._shared import platform_gate_env as _platform_gate_env
|
|
|
|
monkeypatch.setenv("DISCORD_ALLOW_BOTS", "all") # another profile's bridge
|
|
ss.set_multiplex_active(True)
|
|
with _Scope({"UNRELATED": "x"}):
|
|
assert _platform_gate_env("DISCORD_ALLOW_BOTS", "none") == "none"
|
|
|
|
def test_single_profile_legacy_env(self, monkeypatch):
|
|
from gateway.platforms._shared import platform_gate_env as _platform_gate_env
|
|
|
|
monkeypatch.setenv("GATEWAY_ALLOWED_USERS", "42")
|
|
assert _platform_gate_env("GATEWAY_ALLOWED_USERS") == "42"
|
|
|
|
|
|
class TestAuthzAuthEnv:
|
|
"""_auth_env must follow platform_gate_env isolation (no os.environ
|
|
fallthrough on a scoped miss under multiplex)."""
|
|
|
|
def test_scoped_value_wins(self, monkeypatch):
|
|
from gateway.authz_mixin import _auth_env
|
|
|
|
monkeypatch.setenv("TELEGRAM_ALLOWED_USERS", "111")
|
|
ss.set_multiplex_active(True)
|
|
with _Scope({"TELEGRAM_ALLOWED_USERS": "222"}):
|
|
assert _auth_env("TELEGRAM_ALLOWED_USERS") == "222"
|
|
|
|
def test_scoped_miss_returns_default_not_env(self, monkeypatch):
|
|
from gateway.authz_mixin import _auth_env
|
|
|
|
monkeypatch.setenv("TELEGRAM_ALLOWED_USERS", "profile-A")
|
|
monkeypatch.setenv("GATEWAY_ALLOW_ALL_USERS", "true")
|
|
monkeypatch.setenv("TELEGRAM_ALLOW_ALL_USERS", "true")
|
|
ss.set_multiplex_active(True)
|
|
with _Scope({"UNRELATED": "x"}):
|
|
assert _auth_env("TELEGRAM_ALLOWED_USERS") == ""
|
|
assert _auth_env("GATEWAY_ALLOW_ALL_USERS") == ""
|
|
assert _auth_env("TELEGRAM_ALLOW_ALL_USERS") == ""
|
|
|
|
def test_single_profile_legacy_env(self, monkeypatch):
|
|
from gateway.authz_mixin import _auth_env
|
|
|
|
monkeypatch.setenv("GATEWAY_ALLOWED_USERS", "42")
|
|
assert _auth_env("GATEWAY_ALLOWED_USERS") == "42"
|
|
|
|
|
|
# ── Cluster B: matrix startup reads (Slack pattern) ────────────────────────
|
|
|
|
class TestMatrixStartupSecret:
|
|
def _helper(self):
|
|
pytest.importorskip("plugins.platforms.matrix.adapter")
|
|
from gateway.platforms._shared import get_scoped_secret
|
|
|
|
return lambda name: (get_scoped_secret(name, "") or "").strip()
|
|
|
|
def test_scoped_value_wins(self, monkeypatch):
|
|
helper = self._helper()
|
|
monkeypatch.setenv("MATRIX_ACCESS_TOKEN", "env-token")
|
|
ss.set_multiplex_active(True)
|
|
with _Scope({"MATRIX_ACCESS_TOKEN": "scoped-token"}):
|
|
assert helper("MATRIX_ACCESS_TOKEN") == "scoped-token"
|
|
|
|
def test_scoped_miss_no_borrow(self, monkeypatch):
|
|
helper = self._helper()
|
|
monkeypatch.setenv("MATRIX_ACCESS_TOKEN", "other-profile")
|
|
ss.set_multiplex_active(True)
|
|
with _Scope({"UNRELATED": "x"}):
|
|
assert helper("MATRIX_ACCESS_TOKEN") == ""
|
|
|
|
def test_unscoped_multiplex_falls_back(self, monkeypatch):
|
|
helper = self._helper()
|
|
monkeypatch.setenv("MATRIX_PASSWORD", "own-env-pass")
|
|
ss.set_multiplex_active(True)
|
|
assert helper("MATRIX_PASSWORD") == "own-env-pass"
|
|
|
|
|
|
# ── Cluster C: managed tool gateway token override ─────────────────────────
|
|
|
|
class TestToolGatewayUserToken:
|
|
def test_scoped_value_wins(self, monkeypatch):
|
|
from tools.managed_tool_gateway import _read_user_token_override
|
|
|
|
monkeypatch.setenv("TOOL_GATEWAY_USER_TOKEN", "env-tok")
|
|
ss.set_multiplex_active(True)
|
|
with _Scope({"TOOL_GATEWAY_USER_TOKEN": "scoped-tok"}):
|
|
assert _read_user_token_override() == "scoped-tok"
|
|
|
|
def test_scoped_miss_no_borrow(self, monkeypatch):
|
|
from tools.managed_tool_gateway import _read_user_token_override
|
|
|
|
monkeypatch.setenv("TOOL_GATEWAY_USER_TOKEN", "other-profile-tok")
|
|
ss.set_multiplex_active(True)
|
|
with _Scope({"UNRELATED": "x"}):
|
|
assert _read_user_token_override() is None
|
|
|
|
def test_unscoped_multiplex_falls_back(self, monkeypatch):
|
|
from tools.managed_tool_gateway import _read_user_token_override
|
|
|
|
monkeypatch.setenv("TOOL_GATEWAY_USER_TOKEN", "own-env-tok")
|
|
ss.set_multiplex_active(True)
|
|
assert _read_user_token_override() == "own-env-tok"
|
|
|
|
|
|
class TestOpenRouterCheckApiKey:
|
|
def test_scoped_value_wins(self, monkeypatch):
|
|
from tools.openrouter_client import check_api_key
|
|
|
|
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
|
ss.set_multiplex_active(True)
|
|
with _Scope({"OPENROUTER_API_KEY": "sk-or-scoped"}):
|
|
assert check_api_key() is True
|
|
|
|
def test_scoped_miss_no_borrow(self, monkeypatch):
|
|
from tools.openrouter_client import check_api_key
|
|
|
|
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-other-profile")
|
|
ss.set_multiplex_active(True)
|
|
with _Scope({"UNRELATED": "x"}):
|
|
assert check_api_key() is False
|
|
|
|
|
|
# ── Cluster D: auxiliary client key resolution ──────────────────────────────
|
|
|
|
class TestAuxiliaryScopedKeyEnv:
|
|
def test_scoped_value_wins(self, monkeypatch):
|
|
from agent.auxiliary_client import _scoped_key_env
|
|
|
|
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-env")
|
|
ss.set_multiplex_active(True)
|
|
with _Scope({"OPENROUTER_API_KEY": "sk-scoped"}):
|
|
assert _scoped_key_env("OPENROUTER_API_KEY") == "sk-scoped"
|
|
|
|
def test_scoped_miss_no_borrow(self, monkeypatch):
|
|
from agent.auxiliary_client import _scoped_key_env
|
|
|
|
monkeypatch.setenv("OPENAI_API_KEY", "sk-other-profile")
|
|
ss.set_multiplex_active(True)
|
|
with _Scope({"UNRELATED": "x"}):
|
|
assert _scoped_key_env("OPENAI_API_KEY") == ""
|
|
|
|
def test_unscoped_multiplex_falls_back(self, monkeypatch):
|
|
from agent.auxiliary_client import _scoped_key_env
|
|
|
|
monkeypatch.setenv("OPENAI_API_KEY", "sk-own-env")
|
|
ss.set_multiplex_active(True)
|
|
assert _scoped_key_env("OPENAI_API_KEY") == "sk-own-env"
|
|
|
|
def test_empty_name_returns_empty(self):
|
|
from agent.auxiliary_client import _scoped_key_env
|
|
|
|
assert _scoped_key_env("") == ""
|
|
|
|
|
|
# ── Cluster E: azure identity presence reads ────────────────────────────────
|
|
|
|
class TestAzureIdentityPresence:
|
|
def _describe(self):
|
|
azure = pytest.importorskip("agent.azure_identity_adapter")
|
|
if not azure.has_azure_identity_installed():
|
|
pytest.skip("azure-identity not installed")
|
|
return azure.describe_active_credential
|
|
|
|
def test_scoped_client_secret_detected(self, monkeypatch):
|
|
describe = self._describe()
|
|
monkeypatch.setenv("AZURE_CLIENT_ID", "cid")
|
|
monkeypatch.setenv("AZURE_TENANT_ID", "tid")
|
|
monkeypatch.delenv("AZURE_CLIENT_SECRET", raising=False)
|
|
monkeypatch.delenv("AZURE_FEDERATED_TOKEN_FILE", raising=False)
|
|
ss.set_multiplex_active(True)
|
|
with _Scope({"AZURE_CLIENT_SECRET": "scoped-secret"}):
|
|
info = describe(timeout_seconds=0.01, allow_install=False)
|
|
assert any("EnvironmentCredential" in s for s in info.get("env_sources", []))
|
|
|
|
def test_scoped_miss_hides_env_secret(self, monkeypatch):
|
|
describe = self._describe()
|
|
monkeypatch.setenv("AZURE_CLIENT_ID", "cid")
|
|
monkeypatch.setenv("AZURE_TENANT_ID", "tid")
|
|
monkeypatch.setenv("AZURE_CLIENT_SECRET", "other-profile-secret")
|
|
monkeypatch.delenv("AZURE_FEDERATED_TOKEN_FILE", raising=False)
|
|
ss.set_multiplex_active(True)
|
|
with _Scope({"UNRELATED": "x"}):
|
|
info = describe(timeout_seconds=0.01, allow_install=False)
|
|
assert not any(
|
|
"EnvironmentCredential" in s for s in info.get("env_sources", [])
|
|
)
|