Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 45 additions & 8 deletions gateway/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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()
Expand All @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
37 changes: 20 additions & 17 deletions plugins/platforms/email/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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])


Expand Down Expand Up @@ -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", "")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please complete the scope migration for the adjacent per-profile Email settings too: EMAIL_IMAP_PORT, EMAIL_SMTP_PORT, and EMAIL_POLL_INTERVAL still use env_int, and EMAIL_TRUST_FROM_HEADER still uses env_bool. In multiplex mode those helpers read process-global environment values rather than the profile scope.

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)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
)

Expand All @@ -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()
)

Expand All @@ -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(
Expand Down Expand Up @@ -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

Expand Down
161 changes: 161 additions & 0 deletions tests/gateway/test_email_secret_scope.py
Original file line number Diff line number Diff line change
@@ -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()