Skip to content
Merged
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
1 change: 1 addition & 0 deletions contributors/emails/joel.taylor@ccmschools.edu.au
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
JoelMTaylor
1 change: 1 addition & 0 deletions gateway/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,7 @@ def _scan_bundled_plugin_platforms(cls) -> set:
"sms",
"whatsapp_cloud",
"line",
"teams",
})

# Platforms whose port-binding status depends on connection mode. Feishu in
Expand Down
13 changes: 9 additions & 4 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -17043,12 +17043,17 @@ async def _start_one_profile_adapters(
platform.value,
)
continue
# Relay is shared process-level ingress in multiplex mode. The
# active profile owns the one connection; connector-stamped
# source.profile routes inbound turns to secondary profiles.
# Relay and WhatsApp are shared process-level ingress in multiplex
# mode: one connection owned by the active profile, with
# route-stamped source.profile fanning inbound turns out to
# secondary profiles. The WhatsApp bridge is a single authenticated
# session tied to one phone number -- a secondary profile has no
# credential of its own to bring, so constructing an adapter for it
# only yields a connect/retry loop that stalls startup for every
# profile queued behind it.
if (
getattr(self.config, "multiplex_profiles", False)
and platform is Platform.RELAY
and platform in (Platform.RELAY, Platform.WHATSAPP)
):
continue
try:
Expand Down
1 change: 1 addition & 0 deletions hermes_cli/web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -3414,6 +3414,7 @@ def _git_path(path: str) -> str:
"sms": ("webhook_port", 8080),
"whatsapp_cloud": ("webhook_port", 8090),
"line": ("port", 8646),
"teams": ("port", 3978),
}

# Platform states that mean the adapter is NOT serving its port right now.
Expand Down
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
48 changes: 34 additions & 14 deletions plugins/platforms/matrix/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -594,13 +594,14 @@ def _resolve_max_message_length(config) -> int:
# Back-compat alias for callers/tests that import the module constant.
MAX_MESSAGE_LENGTH = DEFAULT_MAX_MESSAGE_LENGTH

# Store directory for E2EE keys and sync state.
# Uses get_hermes_home() so each profile gets its own Matrix store.
# Store directory for E2EE keys and sync state. Resolved per adapter in
# ``connect()`` (see ``_resolve_store_dir``), NOT at module scope: the
# multiplex gateway imports this module once, so a module-level constant
# would pin the root HERMES_HOME for every profile and all bots' Olm
# identities would collide in one crypto.db (#89168). Mirrors the
# pairing-store fix (a6397c379).
from hermes_constants import get_hermes_dir as _get_hermes_dir

_STORE_DIR = _get_hermes_dir("platforms/matrix/store", "matrix/store")
_CRYPTO_DB_PATH = _STORE_DIR / "crypto.db"

# Grace period: ignore messages older than this many seconds before startup.
_STARTUP_GRACE_SECONDS = 5

Expand Down Expand Up @@ -1019,7 +1020,7 @@ def check_matrix_requirements() -> bool:
"""
token = _startup_env_secret("MATRIX_ACCESS_TOKEN")
password = _startup_env_secret("MATRIX_PASSWORD")
homeserver = os.getenv("MATRIX_HOMESERVER", "")
homeserver = _startup_env_secret("MATRIX_HOMESERVER")

if not token and not password:
logger.debug("Matrix: neither MATRIX_ACCESS_TOKEN nor MATRIX_PASSWORD set")
Expand Down Expand Up @@ -1188,6 +1189,23 @@ class MatrixAdapter(BasePlatformAdapter):
max_message_length = DEFAULT_MAX_MESSAGE_LENGTH
_split_threshold = DEFAULT_MAX_MESSAGE_LENGTH - 100

def _resolve_store_dir(self) -> Path:
"""Pin this adapter's crypto-store directory to the active profile.

Called from ``connect()``, which the multiplex gateway runs inside
``_profile_runtime_scope`` -- ``get_hermes_dir`` honors that
context-local HERMES_HOME, so each profile's adapter gets its own
store. Cached on the instance so later reads (diagnostics, error
logs) outside the scope still report the store actually in use.
"""
self._store_dir = _get_hermes_dir("platforms/matrix/store", "matrix/store")
return self._store_dir

@property
def _crypto_db_path(self) -> Path:
store_dir = self._store_dir or _get_hermes_dir("platforms/matrix/store", "matrix/store")
return store_dir / "crypto.db"

def __init__(self, config: PlatformConfig):
super().__init__(config, Platform.MATRIX)

Expand Down Expand Up @@ -1218,6 +1236,7 @@ def __init__(self, config: PlatformConfig):

self._client: Any = None # mautrix.client.Client
self._crypto_db: Any = None # mautrix.util.async_db.Database
self._store_dir: Optional[Path] = None # pinned per profile in connect()
self._sync_task: Optional[asyncio.Task] = None
self._invite_join_tasks: Dict[str, asyncio.Task] = {}
self._closing = False
Expand Down Expand Up @@ -1673,7 +1692,7 @@ async def _verify_device_keys_on_server(self, client: Any, olm: Any) -> bool:
"Matrix: server has different identity keys for device %s — "
"local crypto state is stale. Delete %s and restart.",
client.device_id,
_CRYPTO_DB_PATH,
str(self._crypto_db_path),
)
return False

Expand Down Expand Up @@ -1729,8 +1748,9 @@ async def connect(self, *, is_reconnect: bool = False) -> bool:
logger.error("Matrix: homeserver URL not configured")
return False

# Ensure store dir exists for E2EE key persistence.
_STORE_DIR.mkdir(parents=True, exist_ok=True)
# Ensure store dir exists for E2EE key persistence (resolved here,
# inside the profile scope, so multiplexed profiles never share it).
self._resolve_store_dir().mkdir(parents=True, exist_ok=True)

# Create the HTTP API layer.
client_session = _create_matrix_session(self._proxy_url)
Expand Down Expand Up @@ -1887,7 +1907,7 @@ async def connect(self, *, is_reconnect: bool = False) -> bool:
from mautrix.crypto.store.asyncpg import PgCryptoStore
from mautrix.util.async_db import Database

_STORE_DIR.mkdir(parents=True, exist_ok=True)
self._store_dir.mkdir(parents=True, exist_ok=True)
except Exception as exc:
if self._e2ee_mode == "optional":
logger.warning(
Expand All @@ -1908,15 +1928,15 @@ async def connect(self, *, is_reconnect: bool = False) -> bool:
if self._encryption:
try:
# Remove legacy pickle file from pre-SQLite era.
legacy_pickle = _STORE_DIR / "crypto_store.pickle"
legacy_pickle = self._store_dir / "crypto_store.pickle"
if legacy_pickle.exists():
logger.info(
"Matrix: removing legacy crypto_store.pickle (migrated to SQLite)"
)
legacy_pickle.unlink()

crypto_db = Database.create(
f"sqlite:///{_CRYPTO_DB_PATH}",
f"sqlite:///{self._crypto_db_path}",
upgrade_table=PgCryptoStore.upgrade_table,
)
await crypto_db.start()
Expand Down Expand Up @@ -2044,7 +2064,7 @@ async def connect(self, *, is_reconnect: bool = False) -> bool:
client.crypto = olm
logger.info(
"Matrix: E2EE enabled (store: %s%s)",
str(_CRYPTO_DB_PATH),
str(self._crypto_db_path),
f", device_id={client.device_id}" if client.device_id else "",
)
except Exception as exc:
Expand Down Expand Up @@ -2288,7 +2308,7 @@ def get_diagnostics(self) -> Dict[str, Any]:
"mode": self._e2ee_mode,
"enabled": bool(self._encryption),
"deps_available": _check_e2ee_deps(),
"crypto_store_path": str(_CRYPTO_DB_PATH),
"crypto_store_path": str(self._crypto_db_path),
"recovery_key_configured": bool(
_scoped_recovery_key().strip()
),
Expand Down
Loading
Loading