diff --git a/gateway/config.py b/gateway/config.py index 1c7735a6ed707..b2a843333ae2a 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -17,11 +17,46 @@ from enum import Enum from hermes_cli.config import get_hermes_home +from agent.secret_scope import current_secret_scope, get_secret as _get_secret from utils import env_int, env_var_enabled, is_truthy_value logger = logging.getLogger(__name__) +def _getenv(name: str, default: Optional[str] = None) -> Optional[str]: + """Read env vars through the active profile secret scope when present. + + ``load_gateway_config()`` runs in many contexts, including multiplexed + profile startup where ``_profile_runtime_scope`` installs per-profile + secrets. In that scope we must prefer the scoped value; outside it we keep + legacy ``os.getenv`` behavior for single-profile callers and unscoped + gateway reads. + """ + if current_secret_scope() is not None: + scope_val = _get_secret(name, None) + return scope_val if scope_val is not None else default + env_val = os.environ.get(name) + if env_val is not None: + return env_val + return default + + +def _getenv_str(name: str, default: str = "") -> str: + val = _getenv(name, default) + return val if val is not None else default + + +def _getenv_int(name: str, default: int) -> int: + raw = _getenv(name, None) + if raw is None: + return default + try: + return int(str(raw).strip(), 10) + except (TypeError, ValueError): + return default + + + def _coerce_bool(value: Any, default: bool = True) -> bool: """Coerce bool-ish config values, preserving a caller-provided default.""" if value is None: @@ -1587,10 +1622,10 @@ def _enable_from_env(platform: Platform) -> PlatformConfig: config.platforms[Platform.HOMEASSISTANT].extra["url"] = hass_url # Email - email_addr = os.getenv("EMAIL_ADDRESS") - email_pwd = os.getenv("EMAIL_PASSWORD") - email_imap = os.getenv("EMAIL_IMAP_HOST") - email_smtp = os.getenv("EMAIL_SMTP_HOST") + email_addr = _getenv("EMAIL_ADDRESS") + email_pwd = _getenv("EMAIL_PASSWORD") + email_imap = _getenv("EMAIL_IMAP_HOST") + email_smtp = _getenv("EMAIL_SMTP_HOST") if all([email_addr, email_pwd, email_imap, email_smtp]): if Platform.EMAIL not in config.platforms: config.platforms[Platform.EMAIL] = PlatformConfig() @@ -1600,13 +1635,13 @@ def _enable_from_env(platform: Platform) -> PlatformConfig: "imap_host": email_imap, "smtp_host": email_smtp, }) - email_home = os.getenv("EMAIL_HOME_ADDRESS") + email_home = _getenv("EMAIL_HOME_ADDRESS") if email_home and Platform.EMAIL in config.platforms: config.platforms[Platform.EMAIL].home_channel = HomeChannel( platform=Platform.EMAIL, chat_id=email_home, - name=os.getenv("EMAIL_HOME_ADDRESS_NAME", "Home"), - thread_id=os.getenv("EMAIL_HOME_ADDRESS_THREAD_ID") or None, + name=_getenv_str("EMAIL_HOME_ADDRESS_NAME", "Home"), + thread_id=_getenv("EMAIL_HOME_ADDRESS_THREAD_ID") or None, ) # SMS (Twilio) @@ -1634,7 +1669,9 @@ def _enable_from_env(platform: Platform) -> PlatformConfig: if api_server_enabled or api_server_key: if Platform.API_SERVER not in config.platforms: config.platforms[Platform.API_SERVER] = PlatformConfig() - config.platforms[Platform.API_SERVER].enabled = True + # Honor explicit enabled:false under env key (multiplex fix, PR #51374) + if not config.platforms[Platform.API_SERVER].extra.get("_enabled_explicit", False): + config.platforms[Platform.API_SERVER].enabled = True if api_server_key: config.platforms[Platform.API_SERVER].extra["key"] = api_server_key if api_server_cors_origins: diff --git a/plugins/platforms/email/adapter.py b/plugins/platforms/email/adapter.py index 572b5c11455d3..3bc61cf787b3d 100644 --- a/plugins/platforms/email/adapter.py +++ b/plugins/platforms/email/adapter.py @@ -23,6 +23,9 @@ import re import smtplib import socket + +# Profile-scoped secret reader for multiplexing support (PR #50094) +from agent.secret_scope import get_secret as _get_secret import ssl import uuid from email.header import decode_header @@ -164,10 +167,10 @@ def check_email_requirements() -> bool: Treats blank/whitespace-only values as missing so an abandoned setup that left empty ``EMAIL_*`` keys in ``.env`` does not enable the platform (#40715). """ - addr = os.getenv("EMAIL_ADDRESS", "").strip() - pwd = os.getenv("EMAIL_PASSWORD", "").strip() - imap = os.getenv("EMAIL_IMAP_HOST", "").strip() - smtp = os.getenv("EMAIL_SMTP_HOST", "").strip() + addr = _get_secret("EMAIL_ADDRESS", "").strip() + pwd = _get_secret("EMAIL_PASSWORD", "").strip() + imap = _get_secret("EMAIL_IMAP_HOST", "").strip() + smtp = _get_secret("EMAIL_SMTP_HOST", "").strip() return all([addr, pwd, imap, smtp]) @@ -434,11 +437,11 @@ def __init__(self, config: PlatformConfig): # misleading ``[Errno 8] nodename nor servname`` (an unresolvable name) # instead of an obvious "host not set" error. extra = config.extra or {} - self._address = (os.getenv("EMAIL_ADDRESS", "") or extra.get("address", "")).strip() - self._password = os.getenv("EMAIL_PASSWORD", "") - self._imap_host = (os.getenv("EMAIL_IMAP_HOST", "") or extra.get("imap_host", "")).strip() + self._address = (_get_secret("EMAIL_ADDRESS", "") or extra.get("address", "")).strip() + self._password = _get_secret("EMAIL_PASSWORD", "") + self._imap_host = (_get_secret("EMAIL_IMAP_HOST", "") or extra.get("imap_host", "")).strip() self._imap_port = env_int("EMAIL_IMAP_PORT", 993) - self._smtp_host = (os.getenv("EMAIL_SMTP_HOST", "") or extra.get("smtp_host", "")).strip() + self._smtp_host = (_get_secret("EMAIL_SMTP_HOST", "") or extra.get("smtp_host", "")).strip() self._smtp_port = env_int("EMAIL_SMTP_PORT", 587) self._poll_interval = env_int("EMAIL_POLL_INTERVAL", 15) @@ -473,7 +476,7 @@ def __init__(self, config: PlatformConfig): # own receiving server (defends against an injected header that sorts # first). Defaults to the From-domain of the agent's own address. self._authserv_id = ( - extra.get("authserv_id", "") or os.getenv("EMAIL_AUTHSERV_ID", "") + extra.get("authserv_id", "") or _get_secret("EMAIL_AUTHSERV_ID", "") ).strip().lower() # Track message IDs we've already processed to avoid duplicates @@ -756,7 +759,7 @@ def _allow_all_senders() -> bool: """ truthy = {"true", "1", "yes"} return ( - os.getenv("EMAIL_ALLOW_ALL_USERS", "").strip().lower() in truthy + _get_secret("EMAIL_ALLOW_ALL_USERS", "").strip().lower() in truthy or os.getenv("GATEWAY_ALLOW_ALL_USERS", "").strip().lower() in truthy ) @@ -771,7 +774,7 @@ def _allowlist_in_effect() -> bool: and the authentication gate is unnecessary. """ return bool( - os.getenv("EMAIL_ALLOWED_USERS", "").strip() + _get_secret("EMAIL_ALLOWED_USERS", "").strip() or os.getenv("GATEWAY_ALLOWED_USERS", "").strip() ) @@ -793,9 +796,9 @@ async def _dispatch_message(self, msg_data: Dict[str, Any]) -> None: # that the gateway will never authorize. Without this early guard, # a race between dispatch and authorization can result in the adapter # sending a reply even though the handler returned None. - allowed_raw = os.getenv("EMAIL_ALLOWED_USERS", "").strip() + allowed_raw = _get_secret("EMAIL_ALLOWED_USERS", "").strip() if not allowed_raw: - if os.getenv("EMAIL_ALLOW_ALL_USERS", "").strip().lower() not in {"true", "1", "yes"} and ( + if _get_secret("EMAIL_ALLOW_ALL_USERS", "").strip().lower() not in {"true", "1", "yes"} and ( os.getenv("GATEWAY_ALLOW_ALL_USERS", "").strip().lower() not in {"true", "1", "yes"} ): logger.debug( @@ -1204,11 +1207,11 @@ async def _standalone_send( from email.utils import formatdate extra = getattr(pconfig, "extra", {}) or {} - address = extra.get("address") or os.getenv("EMAIL_ADDRESS", "") - password = os.getenv("EMAIL_PASSWORD", "") - smtp_host = extra.get("smtp_host") or os.getenv("EMAIL_SMTP_HOST", "") + address = extra.get("address") or _get_secret("EMAIL_ADDRESS", "") + password = _get_secret("EMAIL_PASSWORD", "") + smtp_host = extra.get("smtp_host") or _get_secret("EMAIL_SMTP_HOST", "") try: - smtp_port = int(os.getenv("EMAIL_SMTP_PORT", "587")) + smtp_port = int(_get_secret("EMAIL_SMTP_PORT", "587") or "587") except (ValueError, TypeError): smtp_port = 587 diff --git a/tests/gateway/test_email_secret_scope.py b/tests/gateway/test_email_secret_scope.py new file mode 100644 index 0000000000000..693f750f39007 --- /dev/null +++ b/tests/gateway/test_email_secret_scope.py @@ -0,0 +1,161 @@ +"""Tests for email adapter credential isolation under multiplexing. + +Verifies that the email adapter reads EMAIL_ADDRESS, EMAIL_PASSWORD, +EMAIL_IMAP_HOST, and EMAIL_SMTP_HOST from the profile-scoped secret +store (agent.secret_scope.get_secret) instead of os.getenv, so that +a secondary profile in a multiplexed gateway does not inherit the +default profile's email credentials via os.environ. + +Related issues: #50051, #52307 +Related PRs: #51374 (config.py api_server guard), #50094 (config.py scoped env reads) +""" + +import os +import unittest +from unittest.mock import patch, MagicMock + +from agent import secret_scope as ss + + +class TestEmailAdapterSecretScope(unittest.TestCase): + """Verify the email adapter honors the profile secret scope over os.environ.""" + + def setUp(self): + ss.set_multiplex_active(False) + + def tearDown(self): + ss.set_multiplex_active(False) + + @patch.dict(os.environ, { + "EMAIL_ADDRESS": "alpha@test.invalid", + "EMAIL_PASSWORD": "default-pw", + "EMAIL_IMAP_HOST": "imap.default.com", + "EMAIL_SMTP_HOST": "smtp.default.com", + }, clear=False) + def test_adapter_uses_scoped_credentials_not_environ(self): + """When a secret scope is installed, the adapter must read from it, + not from os.environ which may hold another profile's values.""" + from gateway.config import PlatformConfig, Platform + from plugins.platforms.email.adapter import EmailAdapter + + scoped = { + "EMAIL_ADDRESS": "beta@test.invalid", + "EMAIL_PASSWORD": "secondary-pw", + "EMAIL_IMAP_HOST": "imap.secondary.example", + "EMAIL_SMTP_HOST": "smtp.secondary.example", + } + ss.set_multiplex_active(True) + token = ss.set_secret_scope(scoped) + try: + cfg = PlatformConfig(enabled=True) + adapter = EmailAdapter(cfg) + self.assertEqual(adapter._address, "beta@test.invalid") + self.assertEqual(adapter._password, "secondary-pw") + self.assertEqual(adapter._imap_host, "imap.secondary.example") + self.assertEqual(adapter._smtp_host, "smtp.secondary.example") + finally: + ss.reset_secret_scope(token) + + @patch.dict(os.environ, { + "EMAIL_ADDRESS": "alpha@test.invalid", + "EMAIL_PASSWORD": "default-pw", + "EMAIL_IMAP_HOST": "imap.default.com", + "EMAIL_SMTP_HOST": "smtp.default.com", + }, clear=False) + def test_adapter_falls_back_to_environ_without_scope(self): + """Without a secret scope (single-profile mode), the adapter reads + from os.environ — backward-compatible with legacy behavior.""" + from gateway.config import PlatformConfig + from plugins.platforms.email.adapter import EmailAdapter + + cfg = PlatformConfig(enabled=True) + adapter = EmailAdapter(cfg) + self.assertEqual(adapter._address, "alpha@test.invalid") + self.assertEqual(adapter._password, "default-pw") + self.assertEqual(adapter._imap_host, "imap.default.com") + self.assertEqual(adapter._smtp_host, "smtp.default.com") + + @patch.dict(os.environ, { + "EMAIL_ADDRESS": "alpha@test.invalid", + "EMAIL_PASSWORD": "default-pw", + "EMAIL_IMAP_HOST": "imap.default.com", + "EMAIL_SMTP_HOST": "smtp.default.com", + }, clear=False) + def test_check_email_requirements_uses_scope(self): + """check_email_requirements must also honor the secret scope so that + a secondary profile with scoped email creds is detected as configured.""" + scoped = { + "EMAIL_ADDRESS": "beta@test.invalid", + "EMAIL_PASSWORD": "secondary-pw", + "EMAIL_IMAP_HOST": "imap.secondary.example", + "EMAIL_SMTP_HOST": "smtp.secondary.example", + } + ss.set_multiplex_active(True) + token = ss.set_secret_scope(scoped) + try: + from plugins.platforms.email.adapter import check_email_requirements + self.assertTrue(check_email_requirements()) + finally: + ss.reset_secret_scope(token) + + @patch.dict(os.environ, { + "EMAIL_ADDRESS": "alpha@test.invalid", + "EMAIL_PASSWORD": "default-pw", + "EMAIL_IMAP_HOST": "imap.default.com", + "EMAIL_SMTP_HOST": "smtp.default.com", + }, clear=False) + def test_adapter_scoped_missing_key_does_not_leak_environ(self): + """If a key is absent from the scope but present in os.environ, + the adapter must NOT fall through to os.environ (which would leak + the default profile's value).""" + from gateway.config import PlatformConfig + from plugins.platforms.email.adapter import EmailAdapter + + scoped = { + "EMAIL_ADDRESS": "beta@test.invalid", + # EMAIL_PASSWORD intentionally missing from scope + "EMAIL_IMAP_HOST": "imap.secondary.example", + "EMAIL_SMTP_HOST": "smtp.secondary.example", + } + ss.set_multiplex_active(True) + token = ss.set_secret_scope(scoped) + try: + cfg = PlatformConfig(enabled=True) + adapter = EmailAdapter(cfg) + self.assertEqual(adapter._address, "beta@test.invalid") + # Password must NOT be the default profile's "default-pw" + self.assertNotEqual(adapter._password, "default-pw") + self.assertEqual(adapter._password, "") + finally: + ss.reset_secret_scope(token) + + @patch.dict(os.environ, { + "EMAIL_ADDRESS": "alpha@test.invalid", + "EMAIL_PASSWORD": "default-pw", + "EMAIL_IMAP_HOST": "imap.default.com", + "EMAIL_SMTP_HOST": "smtp.default.com", + "EMAIL_ALLOWED_USERS": "gamma@test.invalid", + }, clear=False) + def test_allowed_users_uses_scope(self): + """EMAIL_ALLOWED_USERS must also be read from the secret scope + so a secondary profile gets its own allowlist, not the default's.""" + scoped = { + "EMAIL_ADDRESS": "beta@test.invalid", + "EMAIL_PASSWORD": "secondary-pw", + "EMAIL_IMAP_HOST": "imap.secondary.example", + "EMAIL_SMTP_HOST": "smtp.secondary.example", + "EMAIL_ALLOWED_USERS": "epsilon@test.invalid,delta@test.invalid", + } + ss.set_multiplex_active(True) + token = ss.set_secret_scope(scoped) + try: + # _allowlist_in_effect reads EMAIL_ALLOWED_USERS — verify it + # sees the scoped value, not the environ value + from plugins.platforms.email.adapter import EmailAdapter + self.assertTrue(EmailAdapter._allowlist_in_effect()) + finally: + ss.reset_secret_scope(token) + + +if __name__ == "__main__": + unittest.main()