fix(secrets): scoped BLUEBUBBLES_PASSWORD + API_SERVER_KEY init read; add parametrized regression tests

api_server's __init__ API_SERVER_KEY read now matches the scoped
_expected_api_key path. tests/gateway/test_adapter_startup_secret_scope.py
asserts, for every migrated module: helper exists, scoped read wins, scoped
miss returns default (no environ borrow), unscoped-under-multiplex falls back
to environ without raising, and legacy single-profile reads still work.
This commit is contained in:
Teknium
2026-08-02 00:53:55 -07:00
parent f4b268b785
commit 7d4c8f9a54
3 changed files with 175 additions and 2 deletions

View File

@@ -95,6 +95,30 @@ from agent.redact import redact_sensitive_text
from agent.interrupt_compat import request_hard_interrupt
from gateway.readiness import collect_runtime_readiness
from agent.secret_scope import UnscopedSecretError as _UnscopedSecretError
from agent.secret_scope import get_secret as _scoped_get_secret
def _get_scoped_secret(name, default=None):
"""Scope-aware credential read with the default-profile startup fallback.
Secondary profiles construct their adapters under a profile secret
scope -- the scope is authoritative and a scoped miss returns ``default``
(no cross-profile borrow from ``os.environ``, which may hold another
profile's value). The DEFAULT profile's adapter constructs and sends
*unscoped* under multiplexing, where a bare ``get_secret`` would raise
``UnscopedSecretError`` and crash this path; there ``os.environ`` is that
profile's own value, so fall back to it. Same pattern as the Slack
``SLACK_APP_TOKEN`` read (#59739) and
``gateway/platforms/whatsapp_common.py::_get_wsecret``.
"""
try:
val = _scoped_get_secret(name, default)
except _UnscopedSecretError:
val = os.getenv(name)
return val if val is not None else default
logger = logging.getLogger(__name__)
@@ -1301,7 +1325,7 @@ class APIServerAdapter(BasePlatformAdapter):
if raw_port is None:
raw_port = os.getenv("API_SERVER_PORT", str(DEFAULT_PORT))
self._port: int = _coerce_port(raw_port, DEFAULT_PORT)
self._api_key: str = extra.get("key", os.getenv("API_SERVER_KEY", ""))
self._api_key: str = extra.get("key", _get_scoped_secret("API_SERVER_KEY", ""))
self._cors_origins: tuple[str, ...] = self._parse_cors_origins(
extra.get("cors_origins", os.getenv("API_SERVER_CORS_ORIGINS", "")),
)

View File

@@ -56,6 +56,30 @@ _BLUEBUBBLES_AUDIO_EXT_OVERRIDES = {
"audio/aac": ".m4a", # preserves historical bluebubbles mapping (shared table says .aac)
}
from agent.secret_scope import UnscopedSecretError as _UnscopedSecretError
from agent.secret_scope import get_secret as _scoped_get_secret
def _get_scoped_secret(name, default=None):
"""Scope-aware credential read with the default-profile startup fallback.
Secondary profiles construct their adapters under a profile secret
scope -- the scope is authoritative and a scoped miss returns ``default``
(no cross-profile borrow from ``os.environ``, which may hold another
profile's value). The DEFAULT profile's adapter constructs and sends
*unscoped* under multiplexing, where a bare ``get_secret`` would raise
``UnscopedSecretError`` and crash this path; there ``os.environ`` is that
profile's own value, so fall back to it. Same pattern as the Slack
``SLACK_APP_TOKEN`` read (#59739) and
``gateway/platforms/whatsapp_common.py::_get_wsecret``.
"""
try:
val = _scoped_get_secret(name, default)
except _UnscopedSecretError:
val = os.getenv(name)
return val if val is not None else default
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
@@ -148,7 +172,7 @@ class BlueBubblesAdapter(BasePlatformAdapter):
self.server_url = _normalize_server_url(
extra.get("server_url") or os.getenv("BLUEBUBBLES_SERVER_URL", "")
)
self.password = extra.get("password") or os.getenv("BLUEBUBBLES_PASSWORD", "")
self.password = extra.get("password") or _get_scoped_secret("BLUEBUBBLES_PASSWORD", "")
self.webhook_host = (
extra.get("webhook_host")
or os.getenv("BLUEBUBBLES_WEBHOOK_HOST", DEFAULT_WEBHOOK_HOST)

View File

@@ -0,0 +1,125 @@
"""Regression tests — Slack-pattern scoped credential reads at adapter startup.
Class-closure follow-up to the profile secret-scope cluster (#76462, Slack
pattern #59739, WhatsApp ``_get_wsecret``): the 13 platform adapters below
read credentials at ``__init__`` / availability-check / standalone-send time
with bare ``os.getenv`` (SMS even used ``os.environ[...]``, which KeyErrors).
Under ``gateway.multiplex_profiles`` those reads leak the default profile's
credential into secondary profiles' adapters.
Each migrated module now carries a module-level ``_get_scoped_secret`` helper
mirroring ``gateway/platforms/whatsapp_common.py::_get_wsecret``:
- scope installed → scope is authoritative; scoped miss returns the default
(NO borrow from ``os.environ``)
- unscoped under multiplex (the default profile's own startup loop) →
fall back to ``os.getenv`` without raising ``UnscopedSecretError``
The helpers are imported and exercised directly rather than constructing every
adapter — construction pulls heavy platform dependencies (slack-bolt-style SDK
imports, webhook servers, sidecars) that the test environment doesn't need.
"""
import importlib
import pytest
from agent import secret_scope as ss
# (module path, representative credential env var owned by that adapter)
MIGRATED_ADAPTER_MODULES = [
("plugins.platforms.irc.adapter", "IRC_SERVER_PASSWORD"),
("plugins.platforms.line.adapter", "LINE_CHANNEL_ACCESS_TOKEN"),
("plugins.platforms.teams.adapter", "TEAMS_CLIENT_SECRET"),
("plugins.platforms.mattermost.adapter", "MATTERMOST_TOKEN"),
("plugins.platforms.ntfy.adapter", "NTFY_TOKEN"),
("plugins.platforms.homeassistant.adapter", "HASS_TOKEN"),
("plugins.platforms.sms.adapter", "TWILIO_AUTH_TOKEN"),
("plugins.platforms.dingtalk.adapter", "DINGTALK_CLIENT_SECRET"),
("plugins.platforms.feishu.adapter", "FEISHU_APP_SECRET"),
("plugins.platforms.wecom.adapter", "WECOM_SECRET"),
("plugins.platforms.photon.adapter", "PHOTON_PROJECT_SECRET"),
("plugins.platforms.photon.auth", "PHOTON_PROJECT_SECRET"),
("plugins.platforms.buzz.adapter", "BUZZ_PRIVATE_KEY"),
("gateway.platforms.bluebubbles", "BLUEBUBBLES_PASSWORD"),
("gateway.platforms.api_server", "API_SERVER_KEY"),
]
MODULE_IDS = [m for m, _ in MIGRATED_ADAPTER_MODULES]
@pytest.fixture(autouse=True)
def _reset_multiplex():
"""Multiplex mode off before and after every test."""
ss.set_multiplex_active(False)
yield
ss.set_multiplex_active(False)
def _helper(module_name):
mod = importlib.import_module(module_name)
helper = getattr(mod, "_get_scoped_secret", None)
assert helper is not None, (
f"{module_name} must define the module-level _get_scoped_secret helper "
"(Slack pattern #59739 / whatsapp_common._get_wsecret)"
)
return helper
@pytest.mark.parametrize(("module_name", "var"), MIGRATED_ADAPTER_MODULES, ids=MODULE_IDS)
def test_helper_exists(module_name, var):
_helper(module_name)
@pytest.mark.parametrize(("module_name", "var"), MIGRATED_ADAPTER_MODULES, ids=MODULE_IDS)
def test_scoped_read_wins_over_environ(module_name, var, monkeypatch):
"""Scope installed under multiplex: the profile's value wins, not environ's."""
helper = _helper(module_name)
monkeypatch.setenv(var, "default-profile-value")
ss.set_multiplex_active(True)
tok = ss.set_secret_scope({var: "secondary-profile-value"})
try:
assert helper(var) == "secondary-profile-value"
finally:
ss.reset_secret_scope(tok)
@pytest.mark.parametrize(("module_name", "var"), MIGRATED_ADAPTER_MODULES, ids=MODULE_IDS)
def test_scoped_miss_returns_default_no_environ_borrow(module_name, var, monkeypatch):
"""Scope installed but key absent: return the default — never borrow the
default profile's os.environ value into a secondary profile."""
helper = _helper(module_name)
monkeypatch.setenv(var, "default-profile-value")
ss.set_multiplex_active(True)
tok = ss.set_secret_scope({"SOME_OTHER_KEY": "x"})
try:
assert helper(var) is None
assert helper(var, "") == ""
assert helper(var, "sentinel") == "sentinel"
finally:
ss.reset_secret_scope(tok)
@pytest.mark.parametrize(("module_name", "var"), MIGRATED_ADAPTER_MODULES, ids=MODULE_IDS)
def test_unscoped_under_multiplex_falls_back_to_environ(module_name, var, monkeypatch):
"""The DEFAULT profile constructs its adapters unscoped under multiplexing.
A bare get_secret would raise UnscopedSecretError and crash startup; the
helper must fall back to os.environ (that profile's own value) instead."""
helper = _helper(module_name)
monkeypatch.setenv(var, "default-profile-own-value")
ss.set_multiplex_active(True)
assert ss.current_secret_scope() is None
assert helper(var) == "default-profile-own-value"
monkeypatch.delenv(var, raising=False)
assert helper(var, "fallback-default") == "fallback-default"
@pytest.mark.parametrize(("module_name", "var"), MIGRATED_ADAPTER_MODULES, ids=MODULE_IDS)
def test_single_profile_legacy_environ_read(module_name, var, monkeypatch):
"""Multiplex off, no scope: legacy os.environ read keeps working."""
helper = _helper(module_name)
monkeypatch.setenv(var, "legacy-env-value")
assert helper(var) == "legacy-env-value"
monkeypatch.delenv(var, raising=False)
assert helper(var, "d") == "d"