diff --git a/plugins/platforms/photon/adapter.py b/plugins/platforms/photon/adapter.py index ddc32c569d..0d07a15009 100644 --- a/plugins/platforms/photon/adapter.py +++ b/plugins/platforms/photon/adapter.py @@ -423,10 +423,10 @@ def check_requirements() -> bool: if not HTTPX_AVAILABLE: logger.warning("photon: httpx not installed — pip install httpx") return False - if not shutil.which(os.getenv("PHOTON_NODE_BIN") or "node"): + if not shutil.which(_get_scoped_secret("PHOTON_NODE_BIN") or "node"): logger.warning( "photon: node binary '%s' not found on PATH", - os.getenv("PHOTON_NODE_BIN") or "node", + _get_scoped_secret("PHOTON_NODE_BIN") or "node", ) return False if not sidecar_deps_installed(): @@ -551,7 +551,7 @@ def _reinstall_sidecar_deps() -> None: def validate_config(cfg: PlatformConfig) -> bool: extra = cfg.extra or {} - project_id = extra.get("project_id") or os.getenv("PHOTON_PROJECT_ID") + project_id = extra.get("project_id") or _get_scoped_secret("PHOTON_PROJECT_ID") project_secret = extra.get("project_secret") or _get_scoped_secret("PHOTON_PROJECT_SECRET") if not project_id or not project_secret: # Fall back to auth.json @@ -574,11 +574,11 @@ def _env_enablement() -> Optional[dict]: if not (project_id and project_secret): return None seed: dict = {"project_id": project_id, "project_secret": project_secret} - home = os.getenv("PHOTON_HOME_CHANNEL", "").strip() + home = _get_scoped_secret("PHOTON_HOME_CHANNEL", "").strip() if home: seed["home_channel"] = { "chat_id": home, - "name": os.getenv("PHOTON_HOME_CHANNEL_NAME", "Home"), + "name": _get_scoped_secret("PHOTON_HOME_CHANNEL_NAME", "Home"), } return seed @@ -591,7 +591,7 @@ def _markdown_enabled() -> bool: ``PHOTON_MARKDOWN=false`` is the kill-switch back to stripped plain text without a release. """ - return os.getenv("PHOTON_MARKDOWN", "true").strip().lower() not in { + return _get_scoped_secret("PHOTON_MARKDOWN", "true").strip().lower() not in { "false", "0", "no", } @@ -729,7 +729,7 @@ class PhotonAdapter(BasePlatformAdapter): # the spectrum-ts SDK authenticates with. stored_id, stored_sec = load_project_credentials() self._project_id: str = ( - os.getenv("PHOTON_PROJECT_ID") + _get_scoped_secret("PHOTON_PROJECT_ID") or extra.get("project_id") or stored_id or "" @@ -743,7 +743,7 @@ class PhotonAdapter(BasePlatformAdapter): # Sidecar self._sidecar_port = _coerce_port( - extra.get("sidecar_port") or os.getenv("PHOTON_SIDECAR_PORT"), + extra.get("sidecar_port") or _get_scoped_secret("PHOTON_SIDECAR_PORT"), _DEFAULT_SIDECAR_PORT, ) self._sidecar_bind = _DEFAULT_SIDECAR_BIND @@ -751,9 +751,9 @@ class PhotonAdapter(BasePlatformAdapter): _get_scoped_secret("PHOTON_SIDECAR_TOKEN") or secrets.token_hex(16) ) self._autostart_sidecar = str( - os.getenv("PHOTON_SIDECAR_AUTOSTART", "true") + _get_scoped_secret("PHOTON_SIDECAR_AUTOSTART", "true") ).lower() not in ("0", "false", "no") - self._node_bin = os.getenv("PHOTON_NODE_BIN") or shutil.which("node") or "node" + self._node_bin = _get_scoped_secret("PHOTON_NODE_BIN") or shutil.which("node") or "node" # Presence watchdog. spectrum-ts only reconnects when its inbound # iterator throws or ends; a half-open ("zombie") gRPC socket makes the @@ -776,21 +776,21 @@ class PhotonAdapter(BasePlatformAdapter): self._probe_interval = _coerce_float( _first_set( extra.get("probe_interval_seconds"), - os.getenv("PHOTON_PROBE_INTERVAL_SECONDS"), + _get_scoped_secret("PHOTON_PROBE_INTERVAL_SECONDS"), ), 600.0, ) self._probe_timeout = _coerce_float( _first_set( extra.get("probe_timeout_seconds"), - os.getenv("PHOTON_PROBE_TIMEOUT_SECONDS"), + _get_scoped_secret("PHOTON_PROBE_TIMEOUT_SECONDS"), ), 10.0, ) self._probe_max_failures = _coerce_int( _first_set( extra.get("probe_max_failures"), - os.getenv("PHOTON_PROBE_MAX_FAILURES"), + _get_scoped_secret("PHOTON_PROBE_MAX_FAILURES"), ), 3, ) @@ -843,14 +843,14 @@ class PhotonAdapter(BasePlatformAdapter): # always processed. Config key wins, then env var. _require_mention = extra.get("require_mention") if _require_mention is None: - _require_mention = os.getenv("PHOTON_REQUIRE_MENTION") + _require_mention = _get_scoped_secret("PHOTON_REQUIRE_MENTION") self.require_mention = str(_require_mention).strip().lower() in { "true", "1", "yes", "on", } self._mention_patterns = self._compile_mention_patterns( extra["mention_patterns"] if "mention_patterns" in extra - else os.getenv("PHOTON_MENTION_PATTERNS") + else _get_scoped_secret("PHOTON_MENTION_PATTERNS") ) # -- Group-mention gating (parity with BlueBubbles) ------------------- @@ -2274,7 +2274,7 @@ class PhotonAdapter(BasePlatformAdapter): return True def _reactions_enabled(self) -> bool: - return os.getenv("PHOTON_REACTIONS", "false").strip().lower() in { + return _get_scoped_secret("PHOTON_REACTIONS", "false").strip().lower() in { "true", "1", "yes", "on", } @@ -2805,7 +2805,7 @@ async def _standalone_send( if not HTTPX_AVAILABLE: return {"error": "httpx not installed"} port = _coerce_port( - (pconfig.extra or {}).get("sidecar_port") or os.getenv("PHOTON_SIDECAR_PORT"), + (pconfig.extra or {}).get("sidecar_port") or _get_scoped_secret("PHOTON_SIDECAR_PORT"), _DEFAULT_SIDECAR_PORT, ) token = _get_scoped_secret("PHOTON_SIDECAR_TOKEN") diff --git a/plugins/platforms/photon/auth.py b/plugins/platforms/photon/auth.py index 34b573a2d8..14fecee80f 100644 --- a/plugins/platforms/photon/auth.py +++ b/plugins/platforms/photon/auth.py @@ -254,7 +254,7 @@ def load_project_credentials() -> Tuple[Optional[str], Optional[str]]: use. This is the pair the Node sidecar feeds to ``spectrum-ts``; the id is the unified project id (dashboard id == spectrumProjectId). """ - env_id = os.getenv("PHOTON_PROJECT_ID") + env_id = _get_scoped_secret("PHOTON_PROJECT_ID") env_sec = _get_scoped_secret("PHOTON_PROJECT_SECRET") if env_id and env_sec: return env_id, env_sec @@ -277,7 +277,7 @@ def load_dashboard_project_id() -> Optional[str]: rewrote (it now 404s), while the Spectrum id always matches the live row. Falls back to the legacy keys for older records. """ - env_id = os.getenv("PHOTON_DASHBOARD_PROJECT_ID") + env_id = _get_scoped_secret("PHOTON_DASHBOARD_PROJECT_ID") if env_id: return env_id auth = _load_auth() diff --git a/tests/plugins/platforms/photon/test_multiplex_profile_scope.py b/tests/plugins/platforms/photon/test_multiplex_profile_scope.py new file mode 100644 index 0000000000..5b32327b1e --- /dev/null +++ b/tests/plugins/platforms/photon/test_multiplex_profile_scope.py @@ -0,0 +1,122 @@ +"""Multiplex secondary-profile scope tests for the Photon adapter + auth module. + +__init__'s project_id, check_requirements'/validate_config's node_bin/ +project_id, _env_enablement's home_channel, _reactions_enabled's +PHOTON_REACTIONS, __init__'s require_mention, and _standalone_send's +sidecar_port, plus auth.py's load_project_credentials/ +load_dashboard_project_id, all previously read raw os.getenv +unconditionally (only PHOTON_PROJECT_SECRET/PHOTON_SIDECAR_TOKEN were +already scoped via _get_scoped_secret). Under gateway.multiplex_profiles, +os.environ holds the DEFAULT profile's YAML-to-env bridge output -- a +secondary profile with its own (different or absent) Photon config could +silently authenticate against the default profile's Spectrum project, or +have its mention-gating/reaction behavior driven by the default profile's +settings. + +Notably project_id was a stronger variant of the bug (like the IRC fix in +this series): __init__'s original +`os.getenv("PHOTON_PROJECT_ID") or extra.get("project_id") or stored_id` +ordering let a raw env read override even an explicitly configured +config.yaml extra. + +Mirrors the LINE/DingTalk/IRC/Mattermost fix for #98738. +""" +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +from gateway.config import PlatformConfig +from plugins.platforms.photon import auth as photon_auth +from plugins.platforms.photon.adapter import PhotonAdapter + +_PHOTON_ENV = ( + "PHOTON_PROJECT_ID", + "PHOTON_PROJECT_SECRET", + "PHOTON_DASHBOARD_PROJECT_ID", + "PHOTON_REQUIRE_MENTION", + "PHOTON_REACTIONS", + "PHOTON_HOME_CHANNEL", + "PHOTON_HOME_CHANNEL_NAME", + "PHOTON_SIDECAR_PORT", +) + + +@pytest.fixture +def tmp_hermes_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + """Isolate from the real ~/.hermes/auth.json fallback in load_project_credentials().""" + home = tmp_path / "hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + for key in _PHOTON_ENV: + monkeypatch.delenv(key, raising=False) + yield home + for key in _PHOTON_ENV: + os.environ.pop(key, None) + + +@pytest.fixture +def multiplex_scope(): + """Install multiplex + a secondary-profile secret scope; restore after.""" + tokens = [] + + def install(scope=None): + from agent.secret_scope import set_multiplex_active, set_secret_scope + + set_multiplex_active(True) + tokens.append(set_secret_scope(scope or {})) + return tokens[-1] + + yield install + + from agent.secret_scope import reset_secret_scope, set_multiplex_active + + 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("PHOTON_PROJECT_ID", "default-project-id") + monkeypatch.setenv("PHOTON_PROJECT_SECRET", "default-project-secret") + monkeypatch.setenv("PHOTON_REQUIRE_MENTION", "true") + monkeypatch.setenv("PHOTON_REACTIONS", "true") + + +class TestAuthMultiplexProfileScope: + """load_project_credentials / load_dashboard_project_id (auth.py).""" + + def test_scoped_miss_does_not_leak_default_project_id( + self, tmp_hermes_home, multiplex_scope, default_profile_env + ): + multiplex_scope({"SOMETHING_ELSE": "x"}) + sid, secret = photon_auth.load_project_credentials() + assert sid is None + assert secret is None + adapter = PhotonAdapter(PlatformConfig(enabled=True, extra={})) + assert adapter._project_id == "" + assert adapter.require_mention is False + assert adapter._reactions_enabled() is False + +class TestAdapterMultiplexProfileScope: + """PhotonAdapter.__init__ / _env_enablement / _reactions_enabled (adapter.py).""" + + def test_secondary_extra_wins_over_default_profile_env( + self, tmp_hermes_home, multiplex_scope, default_profile_env + ): + """A secondary profile's own config.yaml extra project_id must be + authoritative -- not the default profile's bridged env value. The + pre-fix ordering (raw os.getenv checked BEFORE extra) meant even an + explicit extra config was silently overridden.""" + multiplex_scope({"PHOTON_PROJECT_SECRET": "profile-secret"}) + cfg = PlatformConfig( + enabled=True, + extra={"project_id": "profile-project-id"}, + ) + adapter = PhotonAdapter(cfg) + assert adapter._project_id == "profile-project-id" +