From 2bcbdb61a7fb24829b37567ea602ea28dcf4a045 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 03:35:18 -0700 Subject: [PATCH] fix(simplex): scope SIMPLEX_* reads to the active profile under multiplexing SimplexAdapter.__init__ (auto_accept, group_allowed), the registry gates check_requirements/validate_config/is_connected, _env_enablement and _standalone_send all read SIMPLEX_* via raw os.getenv. Under gateway.multiplex_profiles those paths run inside a secondary profile's scope where os.environ holds the DEFAULT profile's YAML-to-env bridge output -- so a secondary profile that never configured SimpleX was auto-enabled on the default's daemon URL and inherited its group allowlist / auto-accept setting. Route every read through the module-local `_get_scoped_secret` wrapper (get_secret; UnscopedSecretError -> os.getenv for the default profile, which constructs unscoped) -- the same helper the IRC/ntfy/Photon/ Mattermost siblings use. Unlike the extra-only `_scoped_platform_setting` shape proposed in #100241, this honors BOTH the secondary profile's own .env (the scope) and its config.yaml extra, and needs no config.yaml re-read in check_requirements. Rewrite of #100241. Co-authored-by: nftpoetrist <264138787+nftpoetrist@users.noreply.github.com> --- plugins/platforms/simplex/adapter.py | 44 ++++++++++++----- tests/gateway/test_simplex_plugin.py | 70 ++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 11 deletions(-) diff --git a/plugins/platforms/simplex/adapter.py b/plugins/platforms/simplex/adapter.py index b4f493e456..979c1e6ea3 100644 --- a/plugins/platforms/simplex/adapter.py +++ b/plugins/platforms/simplex/adapter.py @@ -56,6 +56,28 @@ from datetime import datetime, timezone from pathlib import Path 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 env 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 holds the DEFAULT + profile's YAML-to-env bridge output under multiplexing). The default + profile's adapter constructs *unscoped*, where a bare ``get_secret`` + would raise ``UnscopedSecretError``; there ``os.environ`` is that + profile's own value, so fall back to it. Same helper as the IRC/ntfy/ + Mattermost plugins. + """ + try: + val = _scoped_get_secret(name, default) + except _UnscopedSecretError: + val = os.getenv(name) + return val if val is not None else default + # Lazy import: BasePlatformAdapter and friends live in the main repo. # Imported at module top because they're stdlib-only inside Hermes — no # external dependency that would block the plugin from loading. @@ -153,7 +175,7 @@ class SimplexAdapter(BasePlatformAdapter): # Contact-request auto-accept (on by default — matches the way most # bot deployments expect to behave). Read from env first, then fall # back to the value seeded by ``_env_enablement``. - env_auto = os.getenv("SIMPLEX_AUTO_ACCEPT") + env_auto = _get_scoped_secret("SIMPLEX_AUTO_ACCEPT") if env_auto is not None: self.auto_accept = env_auto.strip().lower() not in {"0", "false", "no", ""} else: @@ -162,7 +184,7 @@ class SimplexAdapter(BasePlatformAdapter): # Group allowlist. Without ``SIMPLEX_GROUP_ALLOWED``, group messages # are ignored entirely (safer default — a bot in a group otherwise # processes every member's traffic). Use ``*`` to accept any group. - group_allowed_str = os.getenv("SIMPLEX_GROUP_ALLOWED", "") or extra.get( + group_allowed_str = _get_scoped_secret("SIMPLEX_GROUP_ALLOWED", "") or extra.get( "group_allowed", "" ) self.group_allow_from = set(_parse_comma_list(group_allowed_str)) @@ -1172,7 +1194,7 @@ def check_requirements() -> bool: so the gateway never instantiates the adapter when the dependency is missing or no daemon URL is configured. """ - if not os.getenv("SIMPLEX_WS_URL"): + if not _get_scoped_secret("SIMPLEX_WS_URL"): return False try: import websockets # noqa: F401 @@ -1184,14 +1206,14 @@ def check_requirements() -> bool: def validate_config(config) -> bool: """Validate that the platform config has enough info to connect.""" extra = getattr(config, "extra", {}) or {} - ws_url = os.getenv("SIMPLEX_WS_URL") or extra.get("ws_url", "") + ws_url = _get_scoped_secret("SIMPLEX_WS_URL") or extra.get("ws_url", "") return bool(ws_url) def is_connected(config) -> bool: """Check whether SimpleX is configured (env or config.yaml).""" extra = getattr(config, "extra", {}) or {} - ws_url = os.getenv("SIMPLEX_WS_URL") or extra.get("ws_url", "") + ws_url = _get_scoped_secret("SIMPLEX_WS_URL") or extra.get("ws_url", "") return bool(ws_url) @@ -1207,24 +1229,24 @@ def _env_enablement() -> Optional[dict]: becomes a proper ``HomeChannel`` dataclass on the ``PlatformConfig`` rather than being merged into ``extra``. """ - ws_url = os.getenv("SIMPLEX_WS_URL", "").strip() + ws_url = _get_scoped_secret("SIMPLEX_WS_URL", "").strip() if not ws_url: return None seed: dict = {"ws_url": ws_url} - auto_accept = os.getenv("SIMPLEX_AUTO_ACCEPT", "").strip().lower() + auto_accept = _get_scoped_secret("SIMPLEX_AUTO_ACCEPT", "").strip().lower() if auto_accept: seed["auto_accept"] = auto_accept not in {"0", "false", "no"} - group_allowed = os.getenv("SIMPLEX_GROUP_ALLOWED", "").strip() + group_allowed = _get_scoped_secret("SIMPLEX_GROUP_ALLOWED", "").strip() if group_allowed: seed["group_allowed"] = group_allowed - home = os.getenv("SIMPLEX_HOME_CHANNEL", "").strip() + home = _get_scoped_secret("SIMPLEX_HOME_CHANNEL", "").strip() if home: seed["home_channel"] = { "chat_id": home, - "name": os.getenv("SIMPLEX_HOME_CHANNEL_NAME", "").strip() or home, + "name": _get_scoped_secret("SIMPLEX_HOME_CHANNEL_NAME", "").strip() or home, } return seed @@ -1257,7 +1279,7 @@ async def _standalone_send( return {"error": "websockets not installed. Run: pip install websockets"} extra = getattr(pconfig, "extra", {}) or {} - ws_url = os.getenv("SIMPLEX_WS_URL") or extra.get( + ws_url = _get_scoped_secret("SIMPLEX_WS_URL") or extra.get( "ws_url", "ws://127.0.0.1:5225" ) if not ws_url: diff --git a/tests/gateway/test_simplex_plugin.py b/tests/gateway/test_simplex_plugin.py index 1a88d56513..90d3aa8ed1 100644 --- a/tests/gateway/test_simplex_plugin.py +++ b/tests/gateway/test_simplex_plugin.py @@ -388,3 +388,73 @@ def _make_file_chat_item(file_path: str, file_name: str) -> dict: } + + +# --------------------------------------------------------------------------- +# Multiplex secondary-profile scope +# --------------------------------------------------------------------------- +# +# Every SIMPLEX_* read (auto_accept / group_allowed in __init__, ws_url in the +# registry gates, everything in _env_enablement) went through raw os.getenv, +# which under multiplexing holds the DEFAULT profile's YAML-to-env bridge +# output -- a secondary profile silently borrowed the default's daemon URL, +# group allowlist and auto-accept setting. Reads now go through the module's +# ``_get_scoped_secret`` (profile .env AND extra both honored; scoped miss +# fails closed; unscoped default profile keeps env precedence). + + +@pytest.fixture +def multiplex_scope(): + """Install multiplex + a secondary-profile secret scope; restore after.""" + from agent.secret_scope import ( + reset_secret_scope, + set_multiplex_active, + set_secret_scope, + ) + + tokens = [] + + def install(scope=None): + set_multiplex_active(True) + tokens.append(set_secret_scope(scope or {})) + + yield install + for token in reversed(tokens): + reset_secret_scope(token) + set_multiplex_active(False) + + +@pytest.fixture +def default_profile_env(monkeypatch): + """The default profile's YAML-to-env bridge output in os.environ.""" + monkeypatch.setenv("SIMPLEX_WS_URL", "ws://default:5225") + monkeypatch.setenv("SIMPLEX_GROUP_ALLOWED", "*") + monkeypatch.setenv("SIMPLEX_AUTO_ACCEPT", "true") + + +def test_multiplex_scoped_miss_does_not_borrow_default_profile_env( + multiplex_scope, default_profile_env +): + """A secondary profile with no SimpleX config of its own must not be + auto-enabled off the default's daemon URL, nor inherit its wide-open + group allowlist.""" + from gateway.config import PlatformConfig + + multiplex_scope({"SOMETHING_ELSE": "x"}) + assert _env_enablement() is None + assert check_requirements() is False + assert is_connected(PlatformConfig(enabled=True, extra={})) is False + adapter = SimplexAdapter(PlatformConfig(enabled=True, extra={"auto_accept": False})) + assert adapter.group_allow_from == set() + assert adapter.auto_accept is False + + +def test_multiplex_scope_reads_profile_own_env_not_default( + multiplex_scope, default_profile_env +): + """A secondary profile's own .env (installed as the scope) is honored -- + the extra-only shape would have ignored it.""" + multiplex_scope({"SIMPLEX_WS_URL": "ws://profile:5225", "SIMPLEX_GROUP_ALLOWED": "g1"}) + seeded = _env_enablement() + assert seeded == {"ws_url": "ws://profile:5225", "group_allowed": "g1"} + assert check_requirements() is True