Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 25 additions & 24 deletions plugins/platforms/irc/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,16 +130,17 @@ def __init__(self, config, **kwargs):
extra = getattr(config, "extra", {}) or {}

# Connection settings (env vars override config.yaml)
self.server = os.getenv("IRC_SERVER") or extra.get("server", "")
self.server = _get_scoped_secret("IRC_SERVER") or extra.get("server", "")
try:
self.port = int(os.getenv("IRC_PORT") or extra.get("port", 6697))
self.port = int(_get_scoped_secret("IRC_PORT") or extra.get("port", 6697))
except (ValueError, TypeError):
self.port = 6697
self.nickname = os.getenv("IRC_NICKNAME") or extra.get("nickname", "hermes-bot")
self.channel = os.getenv("IRC_CHANNEL") or extra.get("channel", "")
self.nickname = _get_scoped_secret("IRC_NICKNAME") or extra.get("nickname", "hermes-bot")
self.channel = _get_scoped_secret("IRC_CHANNEL") or extra.get("channel", "")
_use_tls_raw = _get_scoped_secret("IRC_USE_TLS")
self.use_tls = (
os.getenv("IRC_USE_TLS", "").lower() in {"1", "true", "yes"}
if os.getenv("IRC_USE_TLS")
_use_tls_raw.lower() in {"1", "true", "yes"}
if _use_tls_raw
else extra.get("use_tls", True)
)
self.server_password = _get_scoped_secret("IRC_SERVER_PASSWORD") or extra.get("server_password", "")
Expand Down Expand Up @@ -545,8 +546,8 @@ def check_requirements() -> bool:

Only requires the server and channel — no external pip packages needed.
"""
server = os.getenv("IRC_SERVER", "")
channel = os.getenv("IRC_CHANNEL", "")
server = _get_scoped_secret("IRC_SERVER", "")
channel = _get_scoped_secret("IRC_CHANNEL", "")
# Also accept config.yaml-only configuration (no env vars).
# The gateway passes PlatformConfig; we just check env for the
# hermes setup / requirements check path.
Expand All @@ -556,8 +557,8 @@ def check_requirements() -> bool:
def validate_config(config) -> bool:
"""Validate that the platform config has enough info to connect."""
extra = getattr(config, "extra", {}) or {}
server = os.getenv("IRC_SERVER") or extra.get("server", "")
channel = os.getenv("IRC_CHANNEL") or extra.get("channel", "")
server = _get_scoped_secret("IRC_SERVER") or extra.get("server", "")
channel = _get_scoped_secret("IRC_CHANNEL") or extra.get("channel", "")
return bool(server and channel)


Expand Down Expand Up @@ -671,8 +672,8 @@ def interactive_setup() -> None:
def is_connected(config) -> bool:
"""Check whether IRC is configured (env or config.yaml)."""
extra = getattr(config, "extra", {}) or {}
server = os.getenv("IRC_SERVER") or extra.get("server", "")
channel = os.getenv("IRC_CHANNEL") or extra.get("channel", "")
server = _get_scoped_secret("IRC_SERVER") or extra.get("server", "")
channel = _get_scoped_secret("IRC_CHANNEL") or extra.get("channel", "")
return bool(server and channel)


Expand All @@ -689,24 +690,24 @@ def _env_enablement() -> dict | None:
the core hook — it becomes a proper ``HomeChannel`` dataclass on the
``PlatformConfig`` rather than being merged into ``extra``.
"""
server = os.getenv("IRC_SERVER", "").strip()
channel = os.getenv("IRC_CHANNEL", "").strip()
server = _get_scoped_secret("IRC_SERVER", "").strip()
channel = _get_scoped_secret("IRC_CHANNEL", "").strip()
if not (server and channel):
return None
seed: dict = {
"server": server,
"channel": channel,
}
port = os.getenv("IRC_PORT", "").strip()
port = _get_scoped_secret("IRC_PORT", "").strip()
if port:
try:
seed["port"] = int(port)
except ValueError:
pass
nickname = os.getenv("IRC_NICKNAME", "").strip()
nickname = _get_scoped_secret("IRC_NICKNAME", "").strip()
if nickname:
seed["nickname"] = nickname
use_tls = os.getenv("IRC_USE_TLS", "").strip().lower()
use_tls = _get_scoped_secret("IRC_USE_TLS", "").strip().lower()
if use_tls:
seed["use_tls"] = use_tls in {"1", "true", "yes"}
# Passwords live in PlatformConfig.extra as well for back-compat with
Expand All @@ -718,11 +719,11 @@ def _env_enablement() -> dict | None:
# Optional home-channel (usually the same as IRC_CHANNEL, but can be a
# dedicated reports channel). Defaults to IRC_CHANNEL so cron jobs
# with ``deliver=irc`` have a sensible target without extra config.
home = os.getenv("IRC_HOME_CHANNEL") or channel
home = _get_scoped_secret("IRC_HOME_CHANNEL") or channel
if home:
seed["home_channel"] = {
"chat_id": home,
"name": os.getenv("IRC_HOME_CHANNEL_NAME", home),
"name": _get_scoped_secret("IRC_HOME_CHANNEL_NAME", home),
}
return seed

Expand Down Expand Up @@ -770,19 +771,19 @@ async def _standalone_send(
primitive.
"""
extra = getattr(pconfig, "extra", {}) or {}
server = os.getenv("IRC_SERVER") or extra.get("server", "")
channel = os.getenv("IRC_CHANNEL") or extra.get("channel", "")
server = _get_scoped_secret("IRC_SERVER") or extra.get("server", "")
channel = _get_scoped_secret("IRC_CHANNEL") or extra.get("channel", "")
if not server or not channel:
return {"error": "IRC standalone send: IRC_SERVER and IRC_CHANNEL must be configured"}

port_value = os.getenv("IRC_PORT") or extra.get("port", 6697)
port_value = _get_scoped_secret("IRC_PORT") or extra.get("port", 6697)
try:
port = int(port_value)
except (TypeError, ValueError):
return {"error": f"IRC standalone send: invalid port {port_value!r}"}

nickname = os.getenv("IRC_NICKNAME") or extra.get("nickname", "hermes-bot")
use_tls_env = os.getenv("IRC_USE_TLS")
nickname = _get_scoped_secret("IRC_NICKNAME") or extra.get("nickname", "hermes-bot")
use_tls_env = _get_scoped_secret("IRC_USE_TLS")
if use_tls_env is not None:
use_tls = use_tls_env.lower() in {"1", "true", "yes"}
else:
Expand Down
141 changes: 141 additions & 0 deletions tests/gateway/test_irc_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
validate_config = _irc_mod.validate_config
register = _irc_mod.register
_standalone_send = _irc_mod._standalone_send
is_connected = _irc_mod.is_connected
_env_enablement = _irc_mod._env_enablement


class TestIRCProtocolHelpers:
Expand Down Expand Up @@ -406,3 +408,142 @@ async def _fast_timeout(coro, timeout):
assert "registration" in result["error"].lower() or "timeout" in result["error"].lower()


# ---------------------------------------------------------------------------
# Multiplex secondary-profile scope
# ---------------------------------------------------------------------------
#
# __init__'s server/port/nickname/channel/use_tls, check_requirements/
# validate_config/is_connected's server/channel, and _env_enablement's
# server/channel/port/nickname/use_tls/home_channel, all previously read raw
# os.getenv unconditionally (only IRC_SERVER_PASSWORD/IRC_NICKSERV_PASSWORD
# were already scoped). Under multiplex, os.environ holds the DEFAULT
# profile's YAML-to-env bridge output -- a secondary profile with its own
# (different or absent) IRC config would silently connect to the default
# profile's server/channel, or (for _env_enablement) get auto-enabled using
# the default's channel as its cron home_channel -- a real message-
# misdelivery risk, not just cosmetic. Mirrors the LINE/Buzz/SimpleX fix for
# #98738.

@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("IRC_SERVER", "default.example.net")
monkeypatch.setenv("IRC_CHANNEL", "#default")
monkeypatch.setenv("IRC_PORT", "6667")
monkeypatch.setenv("IRC_NICKNAME", "default-bot")
monkeypatch.setenv("IRC_USE_TLS", "false")


class TestMultiplexProfileScope:

def test_secondary_extra_wins_over_default_profile_env(
self, multiplex_scope, default_profile_env
):
"""The secondary profile's own config.yaml extra is authoritative,
not the default profile's bridged server/channel/port/nick/tls."""
from gateway.config import PlatformConfig

multiplex_scope()
cfg = PlatformConfig(
enabled=True,
extra={
"server": "profile.example.net",
"channel": "#profile",
"port": 6697,
"nickname": "profile-bot",
"use_tls": True,
},
)
adapter = IRCAdapter(cfg)
assert adapter.server == "profile.example.net"
assert adapter.channel == "#profile"
assert adapter.port == 6697
assert adapter.nickname == "profile-bot"
assert adapter.use_tls is True

def test_secondary_missing_keys_fail_closed(
self, multiplex_scope, default_profile_env
):
"""Keys absent from the profile's own scope must NOT borrow the
default profile's bridged env values -- that would silently connect
the secondary profile's bot to the wrong IRC server/channel."""
from gateway.config import PlatformConfig

multiplex_scope()
adapter = IRCAdapter(PlatformConfig(enabled=True, extra={}))
assert adapter.server == ""
assert adapter.channel == ""
assert adapter.port == 6697 # falls through to the hardcoded default
assert adapter.nickname == "hermes-bot"
assert adapter.use_tls is True # extra.get("use_tls", True) default

def test_default_profile_unscoped_keeps_env_precedence(
self, monkeypatch, default_profile_env
):
"""Multiplex ON but no scope (the DEFAULT profile constructs
unscoped): env is its own bridge output and still wins."""
from agent.secret_scope import set_multiplex_active
from gateway.config import PlatformConfig

set_multiplex_active(True)
try:
adapter = IRCAdapter(PlatformConfig(enabled=True, extra={}))
finally:
set_multiplex_active(False)
assert adapter.server == "default.example.net"
assert adapter.channel == "#default"
assert adapter.port == 6667
assert adapter.nickname == "default-bot"
assert adapter.use_tls is False

def test_env_enablement_scoped_reads_own_channel_not_default(
self, multiplex_scope, default_profile_env
):
"""A secondary profile's own .env (via the scope) seeds its own
server/channel; the default profile's bridged values must not leak
in."""
multiplex_scope({"IRC_SERVER": "profile.example.net", "IRC_CHANNEL": "#profile"})
seeded = _env_enablement()
assert seeded["server"] == "profile.example.net"
assert seeded["channel"] == "#profile"
assert seeded["home_channel"]["chat_id"] == "#profile"

def test_env_enablement_scoped_without_own_config_returns_none(
self, multiplex_scope, default_profile_env
):
"""A scope with no IRC_SERVER/IRC_CHANNEL of its own must not
auto-enable IRC using the default profile's server/channel."""
multiplex_scope({"SOMETHING_ELSE": "x"})
assert _env_enablement() is None

def test_check_requirements_and_is_connected_scoped_miss_ignore_default(
self, multiplex_scope, default_profile_env
):
from gateway.config import PlatformConfig

multiplex_scope({"SOMETHING_ELSE": "x"})
assert check_requirements() is False
assert is_connected(PlatformConfig(enabled=True, extra={})) is False


Loading