fix(secrets): Slack-pattern scoped credential reads — ntfy, Home Assistant, SMS, DingTalk
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 "".
This commit is contained in:
@@ -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"))
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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": (
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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)"}
|
||||
|
||||
Reference in New Issue
Block a user