fix(whatsapp): route WHATSAPP_* env reads through secret scope for multiplex profiles

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.
This commit is contained in:
x7peeps
2026-07-31 19:23:18 +08:00
committed by Teknium
parent 6ab390a476
commit 4f4ea9a6de
4 changed files with 216 additions and 24 deletions
+13 -12
View File
@@ -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()
+7 -6
View File
@@ -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)
+38 -6
View File
@@ -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
@@ -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)