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>
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user