fix(photon): scope project_id/node_bin/require_mention/reactions/sidecar config to the active profile under multiplexing
PhotonAdapter.__init__, check_requirements, validate_config,
_env_enablement, _markdown_enabled, _reactions_enabled, and
_standalone_send in adapter.py, plus load_project_credentials and
load_dashboard_project_id in auth.py, all read PHOTON_PROJECT_ID/
PHOTON_NODE_BIN/PHOTON_SIDECAR_PORT/PHOTON_SIDECAR_AUTOSTART/
PHOTON_PROBE_*/PHOTON_REQUIRE_MENTION/PHOTON_MENTION_PATTERNS/
PHOTON_REACTIONS/PHOTON_MARKDOWN/PHOTON_HOME_CHANNEL(_NAME)/
PHOTON_DASHBOARD_PROJECT_ID via raw os.getenv -- only
PHOTON_PROJECT_SECRET and PHOTON_SIDECAR_TOKEN were already scoped via
_get_scoped_secret.
Notably __init__'s project_id read was a stronger variant of the bug
(like the IRC fix in this series, item 11): the 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 -- a secondary profile that set its own project_id via
extra would still silently authenticate against the default profile's
Spectrum project, because the default profile's project id is always
bridged to os.environ under multiplex and env was checked first.
_reactions_enabled() and the require_mention/mention_patterns reads in
__init__ are exercised on every live inbound message / tapback, not just
at construction, so a secondary profile's reaction/mention-gating
behavior would be driven by the default profile's settings for the
adapter's entire runtime lifetime.
Switch every raw PHOTON_* read (except the two already scoped) to
_get_scoped_secret(), matching the module's existing helper (already
defined identically in both adapter.py and auth.py). Left
_dashboard_host()/_spectrum_host() and the interactive device-login flow
functions in auth.py untouched -- these are CLI-only management-plane
calls (`hermes photon login`/`setup`), not part of the gateway's
per-profile adapter construction/connection lifecycle, so they are not
reachable under a multiplexed secondary profile's scope; noted as a
"Scope note" in the PR body rather than silently expanding scope to
unreachable call sites.
Adds a new tests/plugins/platforms/photon/test_multiplex_profile_scope.py
(9 tests, two classes covering auth.py and adapter.py separately)
mirroring the fixture/assertion style established in
tests/gateway/test_line_plugin.py's TestMultiplexProfileScope, reusing
test_auth.py's tmp_hermes_home isolation pattern so tests don't depend on
the real ~/.hermes/auth.json fallback. Mutation-verified: stashed the
production fix and confirmed 7 of 9 new tests fail against pre-fix code
(the other 2 are non-differentiating regression guards -- unscoped-
default-profile-precedence, one per class -- which correctly pass either
way). Restored the fix; all 140 tests in tests/plugins/platforms/photon/,
the 10 photon-related parametrized tests in
test_adapter_startup_secret_scope.py, and the broader
test_multiplex_adapter_registry.py / test_adapter_connect_classification.py
suites (45 tests) pass.
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user