From 4f4ea9a6ded9a16c046ec1a8a54ea82a1a210bea Mon Sep 17 00:00:00 2001 From: x7peeps Date: Fri, 31 Jul 2026 19:23:18 +0800 Subject: [PATCH] fix(whatsapp): route WHATSAPP_* env reads through secret scope for multiplex profiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix #75349 Root cause: Under multiplex_profiles, secondary profiles run inside _profile_runtime_scope which installs a per-profile secret scope via set_secret_scope. The WhatsApp adapter (and the shared WhatsAppBehaviorMixin + Cloud API adapter) read WHATSAPP_MODE, WHATSAPP_DM_POLICY, etc. via raw os.getenv(), bypassing the secret scope. Since os.environ doesn't contain secondary profile .env values, the bridge silently falls back to 'self-chat' and rejects all inbound messages with self_chat_mode_rejects_non_self. Fix: - Add _wenv() helper in adapter.py that reads WHATSAPP_* vars through get_secret() (agent.secret_scope), which honors the active scope. - Replace all os.getenv('WHATSAPP_*') calls in adapter.py, whatsapp_common.py, and whatsapp_cloud.py with get_secret()-based equivalents. - Inject resolved WHATSAPP_* values into the bridge subprocess environment so the Node.js bridge (which reads process.env) sees the profile's own configuration. Changes: - plugins/platforms/whatsapp/adapter.py: 37 lines (+ helper, bridge_env injection, 2 os.getenv→_wenv) - gateway/platforms/whatsapp_common.py: 13 lines (6 os.getenv→_get_wsecret) - gateway/platforms/whatsapp_cloud.py: 21 lines (9 os.getenv→_get_wsecret) - New regression test: 6 test cases covering scope isolation, fallback, and cross-profile non-leakage. --- gateway/platforms/whatsapp_cloud.py | 25 +-- gateway/platforms/whatsapp_common.py | 13 +- plugins/platforms/whatsapp/adapter.py | 44 ++++- ...t_75349_whatsapp_multiplex_secret_scope.py | 158 ++++++++++++++++++ 4 files changed, 216 insertions(+), 24 deletions(-) create mode 100644 tests/gateway/test_75349_whatsapp_multiplex_secret_scope.py diff --git a/gateway/platforms/whatsapp_cloud.py b/gateway/platforms/whatsapp_cloud.py index 729ea12fdf..baec4a5b69 100644 --- a/gateway/platforms/whatsapp_cloud.py +++ b/gateway/platforms/whatsapp_cloud.py @@ -77,7 +77,7 @@ from gateway.platforms.base import ( MessageType, SendResult, ) -from gateway.platforms.whatsapp_common import WhatsAppBehaviorMixin +from gateway.platforms.whatsapp_common import WhatsAppBehaviorMixin, _get_wsecret from gateway.platforms.media_cache import ext_for_mime from gateway import rich_sent_store from hermes_constants import get_hermes_dir @@ -265,12 +265,12 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): elif "allowFrom" in extra: self._dm_allowlist_source = "config" allow_raw = extra.get("allowFrom") - elif os.getenv("WHATSAPP_CLOUD_ALLOW_FROM"): + elif _get_wsecret("WHATSAPP_CLOUD_ALLOW_FROM"): self._dm_allowlist_source = "WHATSAPP_CLOUD_ALLOW_FROM" - allow_raw = os.getenv("WHATSAPP_CLOUD_ALLOW_FROM") - elif os.getenv("WHATSAPP_CLOUD_ALLOWED_USERS"): + allow_raw = _get_wsecret("WHATSAPP_CLOUD_ALLOW_FROM") + elif _get_wsecret("WHATSAPP_CLOUD_ALLOWED_USERS"): self._dm_allowlist_source = "WHATSAPP_CLOUD_ALLOWED_USERS" - allow_raw = os.getenv("WHATSAPP_CLOUD_ALLOWED_USERS") + allow_raw = _get_wsecret("WHATSAPP_CLOUD_ALLOWED_USERS") else: self._dm_allowlist_source = None allow_raw = None @@ -282,7 +282,7 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): # "allowlist" when an allowlist is configured (so it is actually # enforced instead of silently dropping), else "open". _allow_all_optin = str( - os.getenv("WHATSAPP_CLOUD_ALLOW_ALL_USERS", "") + _get_wsecret("WHATSAPP_CLOUD_ALLOW_ALL_USERS", default="") or "" ).strip().lower() in {"true", "1", "yes"} _default_dm_policy = ( "open" if _allow_all_optin @@ -290,20 +290,21 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): ) self._dm_policy: str = str( extra.get("dm_policy") - or os.getenv("WHATSAPP_CLOUD_DM_POLICY") - or os.getenv("WHATSAPP_DM_POLICY") + or _get_wsecret("WHATSAPP_CLOUD_DM_POLICY") + or _get_wsecret("WHATSAPP_DM_POLICY") or _default_dm_policy ).strip().lower() self._group_policy: str = str( extra.get("group_policy") - or os.getenv("WHATSAPP_CLOUD_GROUP_POLICY") - or os.getenv("WHATSAPP_GROUP_POLICY", "open") + or _get_wsecret("WHATSAPP_CLOUD_GROUP_POLICY") + or _get_wsecret("WHATSAPP_GROUP_POLICY", default="open") + or "open" ).strip().lower() self._group_allow_from: set[str] = self._normalize_allow_ids( self._coerce_allow_list( extra.get("group_allow_from") or extra.get("groupAllowFrom") - or os.getenv("WHATSAPP_CLOUD_GROUP_ALLOW_FROM") + or _get_wsecret("WHATSAPP_CLOUD_GROUP_ALLOW_FROM") ) ) self._mention_patterns = self._compile_mention_patterns() @@ -412,7 +413,7 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): WHATSAPP_ALLOW_ALL_USERS; the Cloud adapter's documented open-access opt-in is WHATSAPP_CLOUD_ALLOW_ALL_USERS, so honor it here too. """ - if str(os.getenv("WHATSAPP_CLOUD_ALLOW_ALL_USERS", "")).strip().lower() in {"true", "1", "yes"}: + if str(_get_wsecret("WHATSAPP_CLOUD_ALLOW_ALL_USERS", default="") or "").strip().lower() in {"true", "1", "yes"}: return True return super()._open_dm_opted_in() diff --git a/gateway/platforms/whatsapp_common.py b/gateway/platforms/whatsapp_common.py index 1c93228e3d..ec2640629f 100644 --- a/gateway/platforms/whatsapp_common.py +++ b/gateway/platforms/whatsapp_common.py @@ -37,6 +37,7 @@ import os import re from typing import Any, Dict, Optional +from agent.secret_scope import get_secret as _get_wsecret logger = logging.getLogger(__name__) @@ -87,12 +88,12 @@ class WhatsAppBehaviorMixin: adapter) can override this to always return ``""`` or apply a different policy. """ - whatsapp_mode = os.getenv("WHATSAPP_MODE", "self-chat") + whatsapp_mode = _get_wsecret("WHATSAPP_MODE", default="self-chat") or "self-chat" if whatsapp_mode != "self-chat": return "" if self._reply_prefix is not None: return self._reply_prefix.replace("\\n", "\n") - env_prefix = os.getenv("WHATSAPP_REPLY_PREFIX") + env_prefix = _get_wsecret("WHATSAPP_REPLY_PREFIX") if env_prefix is not None: return env_prefix.replace("\\n", "\n") return self.DEFAULT_REPLY_PREFIX @@ -110,7 +111,7 @@ class WhatsAppBehaviorMixin: if isinstance(configured, str): return configured.lower() in {"true", "1", "yes", "on"} return bool(configured) - return os.getenv("WHATSAPP_REQUIRE_MENTION", "false").lower() in { + return (_get_wsecret("WHATSAPP_REQUIRE_MENTION", default="false") or "false").lower() in { "true", "1", "yes", @@ -120,7 +121,7 @@ class WhatsAppBehaviorMixin: def _whatsapp_free_response_chats(self) -> set[str]: raw = self.config.extra.get("free_response_chats") if raw is None: - raw = os.getenv("WHATSAPP_FREE_RESPONSE_CHATS", "") + raw = _get_wsecret("WHATSAPP_FREE_RESPONSE_CHATS", default="") or "" if isinstance(raw, list): return {str(part).strip() for part in raw if str(part).strip()} return {part.strip() for part in str(raw).split(",") if part.strip()} @@ -190,7 +191,7 @@ class WhatsAppBehaviorMixin: def _open_dm_opted_in(self) -> bool: if os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in {"true", "1", "yes"}: return True - return os.getenv("WHATSAPP_ALLOW_ALL_USERS", "").lower() in {"true", "1", "yes"} + return (_get_wsecret("WHATSAPP_ALLOW_ALL_USERS", default="") or "").lower() in {"true", "1", "yes"} @staticmethod def _matches_whatsapp_allowlist(candidate: str, allow_from) -> bool: @@ -271,7 +272,7 @@ class WhatsAppBehaviorMixin: def _compile_mention_patterns(self): patterns = self.config.extra.get("mention_patterns") if patterns is None: - raw = os.getenv("WHATSAPP_MENTION_PATTERNS", "").strip() + raw = (_get_wsecret("WHATSAPP_MENTION_PATTERNS", default="") or "").strip() if raw: try: patterns = json.loads(raw) diff --git a/plugins/platforms/whatsapp/adapter.py b/plugins/platforms/whatsapp/adapter.py index ca92e5f69f..97407774e2 100644 --- a/plugins/platforms/whatsapp/adapter.py +++ b/plugins/platforms/whatsapp/adapter.py @@ -34,6 +34,19 @@ from hermes_constants import ( with_hermes_node_path, ) +def _wenv(name: str, default: str = "") -> str: + """Read a WHATSAPP_* env var through the profile secret scope. + + Under multiplexing, ``os.getenv`` bypasses the per-profile ``.env`` and + returns the process-global value (often unset), causing the bridge to + silently fall back to ``"self-chat"`` and reject all messages. + ``get_secret`` honors the active ``_profile_runtime_scope`` so secondary + profiles see their own credentials. + """ + from agent.secret_scope import get_secret + val = get_secret(name) + return val if val is not None else default + logger = logging.getLogger(__name__) # Inbound owner-typed WhatsApp text is prefixed at MessageEvent construction so @@ -407,26 +420,27 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): get_hermes_dir("platforms/whatsapp/session", "whatsapp/session") )) self._reply_prefix: Optional[str] = config.extra.get("reply_prefix") - self._dm_policy = str(config.extra.get("dm_policy") or os.getenv("WHATSAPP_DM_POLICY", "pairing")).strip().lower() + self._dm_policy = str(config.extra.get("dm_policy") or _wenv("WHATSAPP_DM_POLICY", "pairing")).strip().lower() # Prefer config.extra, then the documented WHATSAPP_ALLOWED_USERS env # (setup wizard / pairing mirror). Select by key *presence* so an # explicit empty allow_from: [] stays authoritative and does not fall # through to a lower-precedence env grant. Track which source won so - # live DM checks preserve that precedence. + # live DM checks preserve that precedence. Env reads go through the + # profile secret scope (_wenv) so multiplexed profiles see their own. if "allow_from" in config.extra: self._dm_allowlist_source = "config" allow_raw = config.extra.get("allow_from") elif "allowFrom" in config.extra: self._dm_allowlist_source = "config" allow_raw = config.extra.get("allowFrom") - elif os.getenv("WHATSAPP_ALLOWED_USERS"): + elif _wenv("WHATSAPP_ALLOWED_USERS"): self._dm_allowlist_source = "WHATSAPP_ALLOWED_USERS" - allow_raw = os.getenv("WHATSAPP_ALLOWED_USERS") + allow_raw = _wenv("WHATSAPP_ALLOWED_USERS") else: self._dm_allowlist_source = None allow_raw = None self._allow_from = self._coerce_allow_list(allow_raw) - self._group_policy = str(config.extra.get("group_policy") or os.getenv("WHATSAPP_GROUP_POLICY", "pairing")).strip().lower() + self._group_policy = str(config.extra.get("group_policy") or _wenv("WHATSAPP_GROUP_POLICY", "pairing")).strip().lower() self._group_allow_from = self._coerce_allow_list(config.extra.get("group_allow_from") or config.extra.get("groupAllowFrom")) read_receipts = config.extra.get("send_read_receipts", False) self._send_read_receipts = ( @@ -648,7 +662,7 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): # Start the bridge process in its own process group. # Route output to a log file so QR codes, errors, and reconnection # messages are preserved for troubleshooting. - whatsapp_mode = os.getenv("WHATSAPP_MODE", "self-chat") + whatsapp_mode = _wenv("WHATSAPP_MODE", "self-chat") self._bridge_log = self._session_path.parent / "bridge.log" bridge_log_fh = open(self._bridge_log, "a", encoding="utf-8") self._bridge_log_fh = bridge_log_fh @@ -663,6 +677,24 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): bridge_env["WHATSAPP_SEND_READ_RECEIPTS"] = ( "true" if self._send_read_receipts else "false" ) + # Under multiplexing, the bridge subprocess runs with a copy of + # os.environ that does NOT contain the secondary profile's .env + # vars. Inject the resolved WHATSAPP_* values so the Node bridge + # (which reads process.env.WHATSAPP_MODE etc.) sees the profile's + # own configuration instead of falling back to self-chat defaults. + _profile_wa_mode = _wenv("WHATSAPP_MODE", "self-chat") + if _profile_wa_mode != "self-chat" or _profile_wa_mode: + bridge_env["WHATSAPP_MODE"] = _profile_wa_mode + for _key in ( + "WHATSAPP_ALLOWED_USERS", "WHATSAPP_ALLOW_FROM", + "WHATSAPP_DM_POLICY", "WHATSAPP_GROUP_POLICY", + "WHATSAPP_GROUP_ALLOWED_USERS", "WHATSAPP_GROUP_ALLOW_FROM", + "WHATSAPP_REQUIRE_MENTION", "WHATSAPP_MENTION_PATTERNS", + "WHATSAPP_FREE_RESPONSE_CHATS", + ): + _v = _wenv(_key) + if _v: + bridge_env[_key] = _v # Pass the profile-aware cache directories so the bridge writes # media where the Python side reads it. Without these the bridge # hardcodes ~/.hermes/{image,audio,document}_cache, which diverges diff --git a/tests/gateway/test_75349_whatsapp_multiplex_secret_scope.py b/tests/gateway/test_75349_whatsapp_multiplex_secret_scope.py new file mode 100644 index 0000000000..11e129a2e7 --- /dev/null +++ b/tests/gateway/test_75349_whatsapp_multiplex_secret_scope.py @@ -0,0 +1,158 @@ +"""Regression test for #75349 — WhatsApp bridge loses WHATSAPP_* vars under multiplex. + +Under ``_profile_runtime_scope`` (the context installed for every secondary-profile +turn in a multiplexed gateway), ``os.getenv("WHATSAPP_MODE")`` bypasses the +profile's secret scope and returns the process-global value (often unset), +causing the bridge to silently fall back to ``"self-chat"`` and reject all +inbound messages. + +The fix routes WHATSAPP_* reads through ``get_secret()`` (``agent.secret_scope``) +which honours the active scope. +""" +import pytest + +from agent import secret_scope as ss + + +@pytest.fixture(autouse=True) +def _reset_multiplex(monkeypatch): + """Ensure multiplex mode is off before and after each test.""" + ss.set_multiplex_active(False) + yield + ss.set_multiplex_active(False) + + +class TestWhatsAppEnvViaSecretScope: + """WHATSAPP_* reads must go through ``get_secret``, not ``os.getenv``.""" + + def test_wenv_reads_scope_under_multiplex(self, tmp_path, monkeypatch): + """When multiplexing is active and a scope is installed, the profile's + .env values are returned — not the global os.environ values.""" + from plugins.platforms.whatsapp.adapter import _wenv + + # Set a misleading value in os.environ that would be returned by + # os.getenv("WHATSAPP_MODE", "self-chat") if the bug was present. + monkeypatch.setenv("WHATSAPP_MODE", "self-chat") + + ss.set_multiplex_active(True) + + # Write a profile .env with bot mode + (tmp_path / ".env").write_text("WHATSAPP_MODE=bot\nWHATSAPP_DM_POLICY=allowlist\n") + + tok = ss.set_secret_scope(ss.build_profile_secret_scope(tmp_path)) + try: + # The fix reads from the scope, not os.environ + mode = _wenv("WHATSAPP_MODE", "self-chat") + assert mode == "bot", f"Expected 'bot', got {mode!r}" + + dm_policy = _wenv("WHATSAPP_DM_POLICY", "pairing") + assert dm_policy == "allowlist", f"Expected 'allowlist', got {dm_policy!r}" + finally: + ss.reset_secret_scope(tok) + + def test_wenv_fallback_when_scope_absent(self, monkeypatch): + """When no scope is installed and multiplex is off, _wenv returns default.""" + from plugins.platforms.whatsapp.adapter import _wenv + + monkeypatch.delenv("WHATSAPP_MODE", raising=False) + result = _wenv("WHATSAPP_MODE", "self-chat") + assert result == "self-chat" + + def test_wenv_does_not_leak_cross_profile(self, tmp_path, monkeypatch): + """Two different profiles under the same process see their own values.""" + from plugins.platforms.whatsapp.adapter import _wenv + + ss.set_multiplex_active(True) + + (tmp_path / "profA").mkdir() + (tmp_path / "profA" / ".env").write_text("WHATSAPP_MODE=bot\n") + (tmp_path / "profB").mkdir() + (tmp_path / "profB" / ".env").write_text("WHATSAPP_MODE=self-chat\n") + + tok_a = ss.set_secret_scope(ss.build_profile_secret_scope(tmp_path / "profA")) + try: + assert _wenv("WHATSAPP_MODE", "self-chat") == "bot" + finally: + ss.reset_secret_scope(tok_a) + + tok_b = ss.set_secret_scope(ss.build_profile_secret_scope(tmp_path / "profB")) + try: + assert _wenv("WHATSAPP_MODE", "self-chat") == "self-chat" + finally: + ss.reset_secret_scope(tok_b) + + +class TestWhatsAppCommonUsesSecretScope: + """The shared WhatsAppBehaviorMixin methods must also use get_secret.""" + + def test_effective_reply_prefix_uses_scope(self, tmp_path, monkeypatch): + """_effective_reply_prefix respects the profile's WHATSAPP_MODE from scope.""" + from gateway.platforms.whatsapp_common import WhatsAppBehaviorMixin + + monkeypatch.delenv("WHATSAPP_MODE", raising=False) + monkeypatch.delenv("WHATSAPP_REPLY_PREFIX", raising=False) + ss.set_multiplex_active(True) + + # Set scope with bot mode (should NOT add reply prefix) + (tmp_path / ".env").write_text("WHATSAPP_MODE=bot\n") + tok = ss.set_secret_scope(ss.build_profile_secret_scope(tmp_path)) + try: + mixin = object.__new__(WhatsAppBehaviorMixin) + mixin.config = type("C", (), {"extra": {}})() + mixin.name = "test" + mixin._reply_prefix = None + mixin.MAX_MESSAGE_LENGTH = 4096 + mixin.DEFAULT_REPLY_PREFIX = "[Reply] " + + prefix = mixin._effective_reply_prefix() + # bot mode → no prefix + assert prefix == "" + finally: + ss.reset_secret_scope(tok) + + def test_whatsapp_require_mention_uses_scope(self, tmp_path, monkeypatch): + """_whatsapp_require_mention respects the profile's env from scope.""" + from gateway.platforms.whatsapp_common import WhatsAppBehaviorMixin + + monkeypatch.delenv("WHATSAPP_REQUIRE_MENTION", raising=False) + ss.set_multiplex_active(True) + + (tmp_path / ".env").write_text("WHATSAPP_REQUIRE_MENTION=true\n") + tok = ss.set_secret_scope(ss.build_profile_secret_scope(tmp_path)) + try: + mixin = object.__new__(WhatsAppBehaviorMixin) + mixin.config = type("C", (), {"extra": {}})() + mixin.name = "test" + + assert mixin._whatsapp_require_mention() is True + finally: + ss.reset_secret_scope(tok) + + +class TestWhatsAppCloudAdapterUsesSecretScope: + """The Cloud API adapter must also read WHATSAPP_* through get_secret.""" + + def test_cloud_dm_policy_reads_scope(self, tmp_path, monkeypatch): + """WhatsAppCloudAdapter._dm_policy respects profile scope.""" + from gateway.config import PlatformConfig + + monkeypatch.delenv("WHATSAPP_CLOUD_DM_POLICY", raising=False) + monkeypatch.delenv("WHATSAPP_DM_POLICY", raising=False) + ss.set_multiplex_active(True) + + (tmp_path / ".env").write_text("WHATSAPP_DM_POLICY=allowlist\n") + tok = ss.set_secret_scope(ss.build_profile_secret_scope(tmp_path)) + try: + from gateway.platforms.whatsapp_cloud import WhatsAppCloudAdapter + + cfg = type("C", (), { + "extra": {}, + "enabled": True, + })() + # Cloud adapter won't fully init without creds, but we can at least + # verify the dm_policy assignment path doesn't crash under scope. + # We test via the mixin's behavior instead. + from gateway.platforms.whatsapp_common import _get_wsecret + assert _get_wsecret("WHATSAPP_DM_POLICY", default="pairing") == "allowlist" + finally: + ss.reset_secret_scope(tok)