diff --git a/plugins/platforms/irc/adapter.py b/plugins/platforms/irc/adapter.py index e78798adbe..030300b797 100644 --- a/plugins/platforms/irc/adapter.py +++ b/plugins/platforms/irc/adapter.py @@ -35,6 +35,30 @@ import ssl import time from typing import Any, Dict, List, Optional +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__) # --------------------------------------------------------------------------- @@ -118,8 +142,8 @@ class IRCAdapter(BasePlatformAdapter): if os.getenv("IRC_USE_TLS") else extra.get("use_tls", True) ) - self.server_password = os.getenv("IRC_SERVER_PASSWORD") or extra.get("server_password", "") - self.nickserv_password = os.getenv("IRC_NICKSERV_PASSWORD") or extra.get("nickserv_password", "") + self.server_password = _get_scoped_secret("IRC_SERVER_PASSWORD") or extra.get("server_password", "") + self.nickserv_password = _get_scoped_secret("IRC_NICKSERV_PASSWORD") or extra.get("nickserv_password", "") # Auth self.allowed_users: list = extra.get("allowed_users", []) @@ -685,10 +709,10 @@ def _env_enablement() -> dict | None: seed["use_tls"] = use_tls in {"1", "true", "yes"} # Passwords live in PlatformConfig.extra as well for back-compat with # existing config.yaml users; env-reads at construct time still win. - if os.getenv("IRC_SERVER_PASSWORD"): - seed["server_password"] = os.getenv("IRC_SERVER_PASSWORD") - if os.getenv("IRC_NICKSERV_PASSWORD"): - seed["nickserv_password"] = os.getenv("IRC_NICKSERV_PASSWORD") + if _get_scoped_secret("IRC_SERVER_PASSWORD"): + seed["server_password"] = _get_scoped_secret("IRC_SERVER_PASSWORD") + if _get_scoped_secret("IRC_NICKSERV_PASSWORD"): + seed["nickserv_password"] = _get_scoped_secret("IRC_NICKSERV_PASSWORD") # Optional home-channel (usually the same as IRC_CHANNEL, but can be a # dedicated reports channel). Defaults to IRC_CHANNEL so cron jobs # with ``deliver=irc`` have a sensible target without extra config. @@ -762,8 +786,8 @@ async def _standalone_send( else: use_tls = bool(extra.get("use_tls", True)) - server_password = os.getenv("IRC_SERVER_PASSWORD") or extra.get("server_password", "") - nickserv_password = os.getenv("IRC_NICKSERV_PASSWORD") or extra.get("nickserv_password", "") + server_password = _get_scoped_secret("IRC_SERVER_PASSWORD") or extra.get("server_password", "") + nickserv_password = _get_scoped_secret("IRC_NICKSERV_PASSWORD") or extra.get("nickserv_password", "") # Reject control characters in chat_id to block IRC command injection. raw_target = chat_id or channel diff --git a/plugins/platforms/line/adapter.py b/plugins/platforms/line/adapter.py index cf14782693..b3c9c6463e 100644 --- a/plugins/platforms/line/adapter.py +++ b/plugins/platforms/line/adapter.py @@ -80,6 +80,30 @@ from pathlib import Path from typing import Any, Dict, List, Optional, Set, Tuple from urllib.parse import quote as _urlquote +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__) # --------------------------------------------------------------------------- @@ -678,11 +702,11 @@ class LineAdapter(BasePlatformAdapter): # Credentials self.channel_access_token = ( - os.getenv("LINE_CHANNEL_ACCESS_TOKEN") + _get_scoped_secret("LINE_CHANNEL_ACCESS_TOKEN") or extra.get("channel_access_token", "") ) self.channel_secret = ( - os.getenv("LINE_CHANNEL_SECRET") + _get_scoped_secret("LINE_CHANNEL_SECRET") or extra.get("channel_secret", "") ) @@ -1561,9 +1585,9 @@ def _is_relative_to(child: Path, parent: Path) -> bool: def check_requirements() -> bool: """Plugin gate: require credentials AND aiohttp at runtime.""" - if not os.getenv("LINE_CHANNEL_ACCESS_TOKEN"): + if not _get_scoped_secret("LINE_CHANNEL_ACCESS_TOKEN"): return False - if not os.getenv("LINE_CHANNEL_SECRET"): + if not _get_scoped_secret("LINE_CHANNEL_SECRET"): return False try: import aiohttp # noqa: F401 @@ -1575,10 +1599,10 @@ def check_requirements() -> bool: def validate_config(config) -> bool: extra = getattr(config, "extra", {}) or {} has_token = bool( - os.getenv("LINE_CHANNEL_ACCESS_TOKEN") or extra.get("channel_access_token") + _get_scoped_secret("LINE_CHANNEL_ACCESS_TOKEN") or extra.get("channel_access_token") ) has_secret = bool( - os.getenv("LINE_CHANNEL_SECRET") or extra.get("channel_secret") + _get_scoped_secret("LINE_CHANNEL_SECRET") or extra.get("channel_secret") ) return has_token and has_secret @@ -1595,7 +1619,7 @@ def _env_enablement() -> Optional[Dict[str, Any]]: in ``.env`` without a ``platforms.line`` block in ``config.yaml``. Mirrors the IRC plugin's pattern. """ - if not (os.getenv("LINE_CHANNEL_ACCESS_TOKEN") and os.getenv("LINE_CHANNEL_SECRET")): + if not (_get_scoped_secret("LINE_CHANNEL_ACCESS_TOKEN") and _get_scoped_secret("LINE_CHANNEL_SECRET")): return None seeded: Dict[str, Any] = {} if os.getenv("LINE_PORT"): @@ -1635,7 +1659,7 @@ async def _standalone_send( """ extra = getattr(pconfig, "extra", {}) or {} token = ( - os.getenv("LINE_CHANNEL_ACCESS_TOKEN") + _get_scoped_secret("LINE_CHANNEL_ACCESS_TOKEN") or extra.get("channel_access_token", "") ) if not token or not chat_id: diff --git a/plugins/platforms/mattermost/adapter.py b/plugins/platforms/mattermost/adapter.py index 63e7cf266f..c1239b37de 100644 --- a/plugins/platforms/mattermost/adapter.py +++ b/plugins/platforms/mattermost/adapter.py @@ -30,6 +30,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__) # Mattermost post size limit (server default is 16383, but 4000 is the @@ -75,7 +99,7 @@ def check_mattermost_requirements() -> bool: def validate_mattermost_config(config: PlatformConfig) -> bool: """Return True when Mattermost has enough config to connect.""" extra = getattr(config, "extra", {}) or {} - token = (getattr(config, "token", None) or os.getenv("MATTERMOST_TOKEN", "")).strip() + token = (getattr(config, "token", None) or _get_scoped_secret("MATTERMOST_TOKEN", "")).strip() url = (extra.get("url", "") or os.getenv("MATTERMOST_URL", "")).strip() if not token: logger.debug("Mattermost: MATTERMOST_TOKEN not set") @@ -98,7 +122,7 @@ class MattermostAdapter(BasePlatformAdapter): config.extra.get("url", "") or os.getenv("MATTERMOST_URL", "") ).rstrip("/") - self._token: str = config.token or os.getenv("MATTERMOST_TOKEN", "") + self._token: str = config.token or _get_scoped_secret("MATTERMOST_TOKEN", "") self._bot_user_id: str = "" self._bot_username: str = "" @@ -1022,7 +1046,7 @@ async def _standalone_send( (getattr(pconfig, "extra", {}) or {}).get("url") or os.getenv("MATTERMOST_URL", "") ).rstrip("/") - token = (getattr(pconfig, "token", None) or os.getenv("MATTERMOST_TOKEN", "")).strip() + token = (getattr(pconfig, "token", None) or _get_scoped_secret("MATTERMOST_TOKEN", "")).strip() if not base_url or not token: return { "error": ( diff --git a/plugins/platforms/teams/adapter.py b/plugins/platforms/teams/adapter.py index d5e7035ae9..9ccbb8aaa5 100644 --- a/plugins/platforms/teams/adapter.py +++ b/plugins/platforms/teams/adapter.py @@ -91,6 +91,30 @@ from gateway.platforms.base import ( cache_media_bytes, ) +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__) _DEFAULT_PORT = 3978 @@ -200,7 +224,7 @@ class TeamsSummaryWriter: env_defaults = { "delivery_mode": os.getenv("TEAMS_DELIVERY_MODE", ""), "incoming_webhook_url": os.getenv("TEAMS_INCOMING_WEBHOOK_URL", ""), - "access_token": os.getenv("TEAMS_GRAPH_ACCESS_TOKEN", ""), + "access_token": _get_scoped_secret("TEAMS_GRAPH_ACCESS_TOKEN", ""), "team_id": os.getenv("TEAMS_TEAM_ID", ""), "channel_id": os.getenv("TEAMS_CHANNEL_ID", ""), "chat_id": os.getenv("TEAMS_CHAT_ID", ""), @@ -401,7 +425,7 @@ def validate_config(config) -> bool: """Return True when the config has the minimum required credentials.""" extra = getattr(config, "extra", {}) or {} client_id = os.getenv("TEAMS_CLIENT_ID") or extra.get("client_id", "") - client_secret = os.getenv("TEAMS_CLIENT_SECRET") or extra.get("client_secret", "") + client_secret = _get_scoped_secret("TEAMS_CLIENT_SECRET") or extra.get("client_secret", "") tenant_id = os.getenv("TEAMS_TENANT_ID") or extra.get("tenant_id", "") return bool(client_id and client_secret and tenant_id) @@ -423,7 +447,7 @@ def _env_enablement() -> dict | None: ``HomeChannel`` dataclass on the ``PlatformConfig`` via the core hook. """ client_id = os.getenv("TEAMS_CLIENT_ID", "").strip() - client_secret = os.getenv("TEAMS_CLIENT_SECRET", "").strip() + client_secret = _get_scoped_secret("TEAMS_CLIENT_SECRET", "").strip() tenant_id = os.getenv("TEAMS_TENANT_ID", "").strip() if not (client_id and client_secret and tenant_id): return None @@ -528,7 +552,7 @@ async def _standalone_send( """ extra = getattr(pconfig, "extra", {}) or {} client_id = os.getenv("TEAMS_CLIENT_ID") or extra.get("client_id", "") - client_secret = os.getenv("TEAMS_CLIENT_SECRET") or extra.get("client_secret", "") + client_secret = _get_scoped_secret("TEAMS_CLIENT_SECRET") or extra.get("client_secret", "") tenant_id = os.getenv("TEAMS_TENANT_ID") or extra.get("tenant_id", "") if not (client_id and client_secret and tenant_id): return {"error": "Teams standalone send: TEAMS_CLIENT_ID, TEAMS_CLIENT_SECRET, and TEAMS_TENANT_ID are all required"} @@ -728,7 +752,7 @@ class TeamsAdapter(BasePlatformAdapter): super().__init__(config, Platform("teams")) extra = config.extra or {} self._client_id = extra.get("client_id") or os.getenv("TEAMS_CLIENT_ID", "") - self._client_secret = extra.get("client_secret") or os.getenv("TEAMS_CLIENT_SECRET", "") + self._client_secret = extra.get("client_secret") or _get_scoped_secret("TEAMS_CLIENT_SECRET", "") self._tenant_id = extra.get("tenant_id") or os.getenv("TEAMS_TENANT_ID", "") self._port = _coerce_port( extra.get("port") or os.getenv("TEAMS_PORT", str(_DEFAULT_PORT))