From 6333180c9ad4a4385966316fcfef46369bb7dff1 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 2 Aug 2026 00:53:55 -0700 Subject: [PATCH] =?UTF-8?q?fix(secrets):=20Slack-pattern=20scoped=20creden?= =?UTF-8?q?tial=20reads=20=E2=80=94=20ntfy,=20Home=20Assistant,=20SMS,=20D?= =?UTF-8?q?ingTalk?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NTFY_TOKEN, HASS_TOKEN, TWILIO_ACCOUNT_SID/TWILIO_AUTH_TOKEN and DINGTALK_CLIENT_SECRET now read through _get_scoped_secret. Also replaces the SMS adapter's bare os.environ["TWILIO_AUTH_TOKEN"]/["TWILIO_ACCOUNT_SID"] __init__ reads (KeyError-prone) with helper reads defaulting to "". --- plugins/platforms/dingtalk/adapter.py | 30 +++++++++++++++++-- plugins/platforms/homeassistant/adapter.py | 30 +++++++++++++++++-- plugins/platforms/ntfy/adapter.py | 30 +++++++++++++++++-- plugins/platforms/sms/adapter.py | 34 ++++++++++++++++++---- 4 files changed, 110 insertions(+), 14 deletions(-) diff --git a/plugins/platforms/dingtalk/adapter.py b/plugins/platforms/dingtalk/adapter.py index a564c08e69..9ea8330854 100644 --- a/plugins/platforms/dingtalk/adapter.py +++ b/plugins/platforms/dingtalk/adapter.py @@ -103,6 +103,30 @@ from gateway.platforms.base import ( SendResult, ) +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__) MAX_MESSAGE_LENGTH = 20000 @@ -166,7 +190,7 @@ def check_dingtalk_requirements() -> bool: httpx = _httpx DINGTALK_STREAM_AVAILABLE = True HTTPX_AVAILABLE = True - if not os.getenv("DINGTALK_CLIENT_ID") or not os.getenv("DINGTALK_CLIENT_SECRET"): + if not os.getenv("DINGTALK_CLIENT_ID") or not _get_scoped_secret("DINGTALK_CLIENT_SECRET"): return False return True @@ -213,7 +237,7 @@ class DingTalkAdapter(BasePlatformAdapter): self._client_id: str = extra.get("client_id") or os.getenv( "DINGTALK_CLIENT_ID", "" ) - self._client_secret: str = extra.get("client_secret") or os.getenv( + self._client_secret: str = extra.get("client_secret") or _get_scoped_secret( "DINGTALK_CLIENT_SECRET", "" ) @@ -1842,7 +1866,7 @@ def _is_connected(config) -> bool: extra = getattr(config, "extra", {}) or {} return bool( (extra.get("client_id") or os.getenv("DINGTALK_CLIENT_ID")) - and (extra.get("client_secret") or os.getenv("DINGTALK_CLIENT_SECRET")) + and (extra.get("client_secret") or _get_scoped_secret("DINGTALK_CLIENT_SECRET")) ) diff --git a/plugins/platforms/homeassistant/adapter.py b/plugins/platforms/homeassistant/adapter.py index 8e042133ee..6564460d47 100644 --- a/plugins/platforms/homeassistant/adapter.py +++ b/plugins/platforms/homeassistant/adapter.py @@ -36,6 +36,30 @@ from gateway.platforms.base import ( SendResult, ) +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__) @@ -46,7 +70,7 @@ def check_ha_requirements() -> bool: def validate_ha_config(config: PlatformConfig) -> bool: """Return True when Home Assistant has enough credential config to connect.""" - token = (getattr(config, "token", None) or os.getenv("HASS_TOKEN", "")).strip() + token = (getattr(config, "token", None) or _get_scoped_secret("HASS_TOKEN", "")).strip() return bool(token) @@ -76,7 +100,7 @@ class HomeAssistantAdapter(BasePlatformAdapter): # Configuration from extra extra = config.extra or {} - token = config.token or os.getenv("HASS_TOKEN", "") + token = config.token or _get_scoped_secret("HASS_TOKEN", "") url = extra.get("url") or os.getenv("HASS_URL", "http://homeassistant.local:8123") self._hass_url: str = url.rstrip("/") self._hass_token: str = token @@ -488,7 +512,7 @@ async def _standalone_send( extra = getattr(pconfig, "extra", {}) or {} hass_url = (extra.get("url") or os.getenv("HASS_URL", "")).rstrip("/") - token = (getattr(pconfig, "token", None) or os.getenv("HASS_TOKEN", "")).strip() + token = (getattr(pconfig, "token", None) or _get_scoped_secret("HASS_TOKEN", "")).strip() if not hass_url or not token: return { "error": ( diff --git a/plugins/platforms/ntfy/adapter.py b/plugins/platforms/ntfy/adapter.py index 88741aa62f..935610c320 100644 --- a/plugins/platforms/ntfy/adapter.py +++ b/plugins/platforms/ntfy/adapter.py @@ -68,6 +68,30 @@ from gateway.platforms.base import ( SendResult, ) +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__) @@ -173,7 +197,7 @@ class NtfyAdapter(BasePlatformAdapter): or os.getenv("NTFY_PUBLISH_TOPIC", "") or self._topic ) - self._token: str = extra.get("token") or os.getenv("NTFY_TOKEN", "") + self._token: str = extra.get("token") or _get_scoped_secret("NTFY_TOKEN", "") self._stream_task: Optional[asyncio.Task] = None self._http_client: Optional["httpx.AsyncClient"] = None @@ -472,7 +496,7 @@ def _env_enablement() -> dict | None: publish_topic = os.getenv("NTFY_PUBLISH_TOPIC", "").strip() if publish_topic: seed["publish_topic"] = publish_topic - token = os.getenv("NTFY_TOKEN", "").strip() + token = _get_scoped_secret("NTFY_TOKEN", "").strip() if token: seed["token"] = token markdown = os.getenv("NTFY_MARKDOWN", "").strip().lower() @@ -526,7 +550,7 @@ async def _standalone_send( if not publish_topic: return {"error": "ntfy standalone send: NTFY_TOPIC not configured"} - token = extra.get("token") or os.getenv("NTFY_TOKEN", "") + token = extra.get("token") or _get_scoped_secret("NTFY_TOKEN", "") markdown_env = os.getenv("NTFY_MARKDOWN", "").strip().lower() markdown_enabled = bool(extra.get("markdown")) or markdown_env in ("1", "true", "yes") diff --git a/plugins/platforms/sms/adapter.py b/plugins/platforms/sms/adapter.py index 3d794da592..0c081242d9 100644 --- a/plugins/platforms/sms/adapter.py +++ b/plugins/platforms/sms/adapter.py @@ -36,6 +36,30 @@ from gateway.platforms.base import ( ) from gateway.platforms.helpers import redact_phone, strip_markdown +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__) TWILIO_API_BASE = "https://api.twilio.com/2010-04-01/Accounts" @@ -51,7 +75,7 @@ def check_sms_requirements() -> bool: import aiohttp # noqa: F401 except ImportError: return False - return bool(os.getenv("TWILIO_ACCOUNT_SID") and os.getenv("TWILIO_AUTH_TOKEN")) + return bool(_get_scoped_secret("TWILIO_ACCOUNT_SID") and _get_scoped_secret("TWILIO_AUTH_TOKEN")) class SmsAdapter(BasePlatformAdapter): @@ -66,8 +90,8 @@ class SmsAdapter(BasePlatformAdapter): def __init__(self, config: PlatformConfig): super().__init__(config, Platform.SMS) - self._account_sid: str = os.environ["TWILIO_ACCOUNT_SID"] - self._auth_token: str = os.environ["TWILIO_AUTH_TOKEN"] + self._account_sid: str = _get_scoped_secret("TWILIO_ACCOUNT_SID", "") + self._auth_token: str = _get_scoped_secret("TWILIO_AUTH_TOKEN", "") self._from_number: str = os.getenv("TWILIO_PHONE_NUMBER", "") self._webhook_port: int = int( os.getenv("SMS_WEBHOOK_PORT", str(DEFAULT_WEBHOOK_PORT)) @@ -435,14 +459,14 @@ async def _standalone_send( ): """Out-of-process SMS delivery via the Twilio REST API. Implements the standalone_sender_fn contract; replaces the legacy _send_sms helper.""" - auth_token = getattr(pconfig, "api_key", None) or os.getenv("TWILIO_AUTH_TOKEN", "") + auth_token = getattr(pconfig, "api_key", None) or _get_scoped_secret("TWILIO_AUTH_TOKEN", "") try: import aiohttp except ImportError: return {"error": "aiohttp not installed. Run: pip install aiohttp"} import base64 - account_sid = os.getenv("TWILIO_ACCOUNT_SID", "") + account_sid = _get_scoped_secret("TWILIO_ACCOUNT_SID", "") from_number = os.getenv("TWILIO_PHONE_NUMBER", "") if not account_sid or not auth_token or not from_number: return {"error": "SMS not configured (TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_PHONE_NUMBER required)"}