fix(matrix): isolate room context and restore reliable inbound dispatch (#18505)

* fix(matrix): isolate room context and inbound dispatch

* test(matrix): cover room isolation and dispatch regressions

* docs(matrix): document room isolation and session scope

* fix(matrix): stabilize CI requirement checks

* test(matrix): isolate mautrix stubs in requirements tests

* fix(matrix): port room-scoped status and resume to slash commands mixin

Move Matrix /status scope output and /resume same-room guards from the
pre-refactor gateway/run.py into gateway/slash_commands.py so PR #18505
foundation behavior survives the upstream god-file decomposition.

Uses i18n keys for Matrix resume/status messages. Preserves upstream
session.py fixes (role_authorized, DM user_id isolation).

* docs(matrix): explain inbound dispatch via handle_sync loop

Document why Hermes uses an explicit sync loop with handle_sync() rather than
client.start(), aligning with upstream #7914 diagnostics while preserving
Hermes background maintenance tasks.

* fix(i18n): add Matrix resume/status keys to all locale catalogs

The Matrix /resume and /status slash-command keys added in the foundation
PR must exist in every supported locale file. tests/agent/test_i18n.py
asserts key and placeholder parity across catalogs.

Non-English locales use English strings as interim placeholders until
community translators can localize them.

* fix(matrix): restore gateway authz for allowed_users; honor config require_mention

Revert the early MATRIX_ALLOWED_USERS gate in _on_room_message so inbound
sender authorization stays in gateway authz like main. Parse require_mention
from config.extra (platforms.matrix / top-level matrix yaml) with env fallback,
matching thread_require_mention and fixing Forge when require_mention is set
only in profile config.yaml.

* fix(matrix): harden status scope and allowlisted DMs

* fix(matrix): use session store lookup for resume scope
This commit is contained in:
Chris 2026-06-11 07:41:43 -04:00 committed by GitHub
parent 73dd584995
commit 4717989c10
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 4087 additions and 332 deletions

View file

@ -1218,17 +1218,30 @@ def load_gateway_config() -> GatewayConfig:
if isinstance(matrix_cfg, dict):
if "require_mention" in matrix_cfg and not os.getenv("MATRIX_REQUIRE_MENTION"):
os.environ["MATRIX_REQUIRE_MENTION"] = str(matrix_cfg["require_mention"]).lower()
allowed_users = matrix_cfg.get("allowed_users")
if allowed_users is not None and not os.getenv("MATRIX_ALLOWED_USERS"):
if isinstance(allowed_users, list):
allowed_users = ",".join(str(v) for v in allowed_users)
os.environ["MATRIX_ALLOWED_USERS"] = str(allowed_users)
allowed_rooms = matrix_cfg.get("allowed_rooms")
if allowed_rooms is not None and not os.getenv("MATRIX_ALLOWED_ROOMS"):
if isinstance(allowed_rooms, list):
allowed_rooms = ",".join(str(v) for v in allowed_rooms)
os.environ["MATRIX_ALLOWED_ROOMS"] = str(allowed_rooms)
frc = matrix_cfg.get("free_response_rooms")
if frc is not None and not os.getenv("MATRIX_FREE_RESPONSE_ROOMS"):
if isinstance(frc, list):
frc = ",".join(str(v) for v in frc)
os.environ["MATRIX_FREE_RESPONSE_ROOMS"] = str(frc)
# allowed_rooms: if set, bot ONLY responds in these rooms (whitelist)
ar = matrix_cfg.get("allowed_rooms")
if ar is not None and not os.getenv("MATRIX_ALLOWED_ROOMS"):
if isinstance(ar, list):
ar = ",".join(str(v) for v in ar)
os.environ["MATRIX_ALLOWED_ROOMS"] = str(ar)
ignore_patterns = matrix_cfg.get("ignore_user_patterns")
if ignore_patterns is not None and not os.getenv("MATRIX_IGNORE_USER_PATTERNS"):
if isinstance(ignore_patterns, list):
ignore_patterns = ",".join(str(v) for v in ignore_patterns)
os.environ["MATRIX_IGNORE_USER_PATTERNS"] = str(ignore_patterns)
if "process_notices" in matrix_cfg and not os.getenv("MATRIX_PROCESS_NOTICES"):
os.environ["MATRIX_PROCESS_NOTICES"] = str(matrix_cfg["process_notices"]).lower()
if "session_scope" in matrix_cfg and not os.getenv("MATRIX_SESSION_SCOPE"):
os.environ["MATRIX_SESSION_SCOPE"] = str(matrix_cfg["session_scope"]).lower()
if "auto_thread" in matrix_cfg and not os.getenv("MATRIX_AUTO_THREAD"):
os.environ["MATRIX_AUTO_THREAD"] = str(matrix_cfg["auto_thread"]).lower()
if "dm_mention_threads" in matrix_cfg and not os.getenv("MATRIX_DM_MENTION_THREADS"):
@ -1497,8 +1510,14 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
matrix_password = os.getenv("MATRIX_PASSWORD", "")
if matrix_password:
matrix_config.extra["password"] = matrix_password
matrix_e2ee = os.getenv("MATRIX_ENCRYPTION", "").lower() in {"true", "1", "yes"}
matrix_e2ee_mode = os.getenv("MATRIX_E2EE_MODE", "").strip().lower()
matrix_e2ee = (
matrix_e2ee_mode in ("required", "require", "optional", "prefer", "preferred")
or os.getenv("MATRIX_ENCRYPTION", "").lower() in ("true", "1", "yes")
)
matrix_config.extra["encryption"] = matrix_e2ee
if matrix_e2ee_mode:
matrix_config.extra["e2ee_mode"] = matrix_e2ee_mode
matrix_device_id = os.getenv("MATRIX_DEVICE_ID", "")
if matrix_device_id:
matrix_config.extra["device_id"] = matrix_device_id

File diff suppressed because it is too large Load diff

View file

@ -294,6 +294,22 @@ def build_session_context_prompt(
if context.source.chat_topic:
lines.append(f"**Channel Topic:** {context.source.chat_topic}")
if context.source.platform == Platform.MATRIX:
src = context.source
room_name = src.chat_name or src.chat_id
room_id = _hash_chat_id(src.chat_id) if redact_pii else src.chat_id
lines.append("")
lines.append(f"**Matrix Room:** {room_name}")
lines.append(f"**Matrix Room ID:** {room_id}")
if src.thread_id:
thread_id = _hash_chat_id(src.thread_id) if redact_pii else src.thread_id
lines.append(f"**Matrix Thread:** {thread_id}")
lines.append(
"**Matrix room boundary:** Treat this turn as scoped to the current "
"Matrix room/thread only. Do not assume unresolved references are "
"about other Matrix rooms or projects unless the user explicitly says so."
)
# User identity.
# In shared multi-user sessions (shared threads OR shared non-thread groups
# when group_sessions_per_user=False), multiple users contribute to the same
@ -1264,6 +1280,17 @@ class SessionStore:
entries.sort(key=lambda e: e.updated_at, reverse=True)
return entries
def lookup_by_session_id(self, session_id: str) -> Optional[SessionEntry]:
"""Return the active session entry for a persisted session ID, if any."""
if not session_id:
return None
with self._lock:
self._ensure_loaded_locked()
for entry in self._entries.values():
if entry.session_id == session_id:
return entry
return None
def append_to_transcript(self, session_id: str, message: Dict[str, Any], skip_db: bool = False) -> None:
"""Append a message to a session's transcript (SQLite).

View file

@ -17,6 +17,7 @@ from __future__ import annotations
import asyncio
import dataclasses
import hashlib
import inspect
import logging
import os
@ -32,7 +33,7 @@ from agent.account_usage import fetch_account_usage, render_account_usage_lines
from agent.i18n import t
from gateway.config import HomeChannel, Platform, PlatformConfig
from gateway.platforms.base import EphemeralReply, MessageEvent, MessageType
from gateway.session import build_session_key
from gateway.session import SessionSource, build_session_key
from hermes_cli.config import cfg_get
from utils import (
atomic_json_write,
@ -447,6 +448,22 @@ class GatewaySlashCommandsMixin:
])
if queue_depth:
lines.append(t("gateway.status.queued", count=queue_depth))
if source.platform == Platform.MATRIX:
adapter = self.adapters.get(Platform.MATRIX)
scope = getattr(adapter, "_matrix_session_scope", os.getenv("MATRIX_SESSION_SCOPE", "auto"))
thread = source.thread_id or "none"
lines.extend([
"",
t("gateway.status.matrix_scope_header"),
t("gateway.status.matrix_scope_room", room=source.chat_name or source.chat_id),
t("gateway.status.matrix_scope_room_id", room_id=source.chat_id),
t("gateway.status.matrix_scope_thread", thread_id=thread),
t("gateway.status.matrix_scope_mode", scope=scope),
t(
"gateway.status.matrix_scope_key",
session_key=self._redact_matrix_session_key(session_key),
),
])
lines.extend([
"",
t("gateway.status.platforms", platforms=', '.join(connected_platforms)),
@ -454,6 +471,37 @@ class GatewaySlashCommandsMixin:
return "\n".join(lines)
@staticmethod
def _redact_matrix_session_key(session_key: str) -> str:
"""Return a stable Matrix session-key fingerprint for shared room status."""
text = str(session_key or "")
digest = hashlib.sha256(text.encode("utf-8")).hexdigest()[:12]
return f"sha256:{digest}"
def _gateway_session_origin_for_id(self, session_id: str) -> Optional[SessionSource]:
"""Best-effort origin lookup for gateway session IDs."""
lookup = getattr(type(self.session_store), "lookup_by_session_id", None)
if callable(lookup):
entry = lookup(self.session_store, session_id)
return getattr(entry, "origin", None) if entry is not None else None
# Test doubles and older stores may not expose the public lookup helper.
# Keep the Matrix resume guard fail-closed if no origin can be resolved.
entries = getattr(self.session_store, "_entries", {}) or {}
for entry in entries.values():
if getattr(entry, "session_id", None) == session_id:
return getattr(entry, "origin", None)
return None
@staticmethod
def _same_matrix_room(current: SessionSource, origin: Optional[SessionSource]) -> bool:
return (
origin is not None
and origin.platform == Platform.MATRIX
and current.platform == Platform.MATRIX
and origin.chat_id == current.chat_id
)
async def _handle_agents_command(self, event: MessageEvent) -> str:
"""Handle /agents command - list active agents and running tasks."""
from gateway.run import _AGENT_PENDING_SENTINEL
@ -2652,7 +2700,14 @@ class GatewaySlashCommandsMixin:
source = event.source
session_key = self._session_key_for_source(source)
name = event.get_command_args().strip()
raw_args = event.get_command_args().strip()
try:
parts = shlex.split(raw_args)
except ValueError as exc:
return t("gateway.resume.parse_error", error=exc)
allow_all = "--all" in parts
allow_cross_room = "--cross-room" in parts
name = " ".join(p for p in parts if p not in {"--all", "--cross-room"}).strip()
# Strip common outer brackets/quotes users may type literally from the
# usage hint (e.g. ``/resume <abc123>``). Mirrors the CLI behavior.
@ -2673,11 +2728,24 @@ class GatewaySlashCommandsMixin:
# List recent titled sessions for this user/platform
try:
titled = _list_titled_sessions()
if source.platform == Platform.MATRIX and not allow_all:
scoped = []
for s in titled:
origin = self._gateway_session_origin_for_id(str(s.get("id") or ""))
if self._same_matrix_room(source, origin):
scoped.append(s)
titled = scoped
if not titled:
if source.platform == Platform.MATRIX and not allow_all:
return t("gateway.resume.matrix_no_named_sessions")
return t("gateway.resume.no_named_sessions")
lines = [t("gateway.resume.list_header")]
for idx, s in enumerate(titled[:10], start=1):
title = s["title"]
if source.platform == Platform.MATRIX and allow_all:
origin = self._gateway_session_origin_for_id(str(s.get("id") or ""))
if origin:
title = f"{title}{origin.chat_name or origin.chat_id}"
preview = s.get("preview", "")[:40]
preview_part = t("gateway.resume.list_preview_suffix", preview=preview) if preview else ""
lines.append(t("gateway.resume.list_item_numbered", index=idx, title=title, preview_part=preview_part))
@ -2691,6 +2759,13 @@ class GatewaySlashCommandsMixin:
if name.isdigit():
try:
titled = _list_titled_sessions()
if source.platform == Platform.MATRIX and not allow_all:
scoped = []
for s in titled:
origin = self._gateway_session_origin_for_id(str(s.get("id") or ""))
if self._same_matrix_room(source, origin):
scoped.append(s)
titled = scoped
except Exception as e:
logger.debug("Failed to list titled sessions for numeric resume: %s", e)
return t("gateway.resume.list_failed", error=e)
@ -2717,6 +2792,17 @@ class GatewaySlashCommandsMixin:
except Exception as e:
logger.debug("Failed to resolve resume continuation for %s: %s", target_id, e)
if source.platform == Platform.MATRIX:
target_origin = self._gateway_session_origin_for_id(target_id)
if not self._same_matrix_room(source, target_origin) and not allow_cross_room:
if target_origin is None:
return t("gateway.resume.matrix_blocked_no_origin", name=name)
return t(
"gateway.resume.matrix_blocked_other_room",
room=target_origin.chat_name or target_origin.chat_id,
name=name,
)
# Check if already on that session
current_entry = self.session_store.get_or_create_session(source)
if current_entry.session_id == target_id:
@ -2744,6 +2830,15 @@ class GatewaySlashCommandsMixin:
# Count messages for context
history = self.session_store.load_transcript(target_id)
msg_count = len([m for m in history if m.get("role") == "user"]) if history else 0
msg_part = f" ({msg_count} message{'s' if msg_count != 1 else ''})" if msg_count else ""
if source.platform == Platform.MATRIX and allow_cross_room:
return t(
"gateway.resume.matrix_cross_room_success",
title=title,
room=source.chat_name or source.chat_id,
msg_part=msg_part,
)
if not msg_count:
return t("gateway.resume.resumed_no_count", title=title)
if msg_count == 1:

View file

@ -219,6 +219,14 @@ gateway:
resume:
db_unavailable: "Sessie-databasis is nie beskikbaar nie."
parse_error: "⚠️ Could not parse `/resume` arguments: {error}.
Use quotes around titles with spaces, for example: `/resume \"Project A Plan\"`."
matrix_no_named_sessions: "No named sessions found for this Matrix room.
Use `/title My Session` to name the current room session, `/resume --all` to list all Matrix sessions, or `/resume --cross-room <session name>` to explicitly cross room boundaries."
matrix_blocked_no_origin: "⚠️ Matrix /resume blocked: this named session has no recorded room origin, so Hermes will not resume it inside the current room by default. Use `/resume --cross-room {name}` if you intentionally want to cross room boundaries."
matrix_blocked_other_room: "⚠️ Matrix /resume blocked: that session belongs to a different Matrix room ({room}). Use `/resume --cross-room {name}` if you intentionally want to resume it here."
matrix_cross_room_success: "⚠️ Cross-room resume: resumed **{title}** inside Matrix room **{room}**.
Future messages in this room will use that transcript until `/reset` or another `/resume`.{msg_part}"
no_named_sessions: "Geen benoemde sessies gevind nie.\nGebruik `/title My Sessie` om jou huidige sessie 'n naam te gee, en dan `/resume My Sessie` om later daarheen terug te keer."
list_header: "📋 **Benoemde Sessies**\n"
list_item: "• **{title}**{preview_part}"
@ -251,6 +259,12 @@ gateway:
status:
header: "📊 **Hermes Gateway Status**"
matrix_scope_header: "**Matrix scope:**"
matrix_scope_room: " room: {room}"
matrix_scope_room_id: " room_id: {room_id}"
matrix_scope_thread: " thread_id: {thread_id}"
matrix_scope_mode: " session_scope: {scope}"
matrix_scope_key: " session_key: {session_key}"
session_id: "**Sessie-ID:** `{session_id}`"
title: "**Titel:** {title}"
created: "**Geskep:** {timestamp}"

View file

@ -219,6 +219,14 @@ gateway:
resume:
db_unavailable: "Sitzungsdatenbank nicht verfügbar."
parse_error: "⚠️ Could not parse `/resume` arguments: {error}.
Use quotes around titles with spaces, for example: `/resume \"Project A Plan\"`."
matrix_no_named_sessions: "No named sessions found for this Matrix room.
Use `/title My Session` to name the current room session, `/resume --all` to list all Matrix sessions, or `/resume --cross-room <session name>` to explicitly cross room boundaries."
matrix_blocked_no_origin: "⚠️ Matrix /resume blocked: this named session has no recorded room origin, so Hermes will not resume it inside the current room by default. Use `/resume --cross-room {name}` if you intentionally want to cross room boundaries."
matrix_blocked_other_room: "⚠️ Matrix /resume blocked: that session belongs to a different Matrix room ({room}). Use `/resume --cross-room {name}` if you intentionally want to resume it here."
matrix_cross_room_success: "⚠️ Cross-room resume: resumed **{title}** inside Matrix room **{room}**.
Future messages in this room will use that transcript until `/reset` or another `/resume`.{msg_part}"
no_named_sessions: "Keine benannten Sitzungen gefunden.\nVerwenden Sie `/title Meine Sitzung`, um die aktuelle Sitzung zu benennen, dann `/resume Meine Sitzung`, um später dorthin zurückzukehren."
list_header: "📋 **Benannte Sitzungen**\n"
list_item: "• **{title}**{preview_part}"
@ -251,6 +259,12 @@ gateway:
status:
header: "📊 **Hermes-Gateway-Status**"
matrix_scope_header: "**Matrix scope:**"
matrix_scope_room: " room: {room}"
matrix_scope_room_id: " room_id: {room_id}"
matrix_scope_thread: " thread_id: {thread_id}"
matrix_scope_mode: " session_scope: {scope}"
matrix_scope_key: " session_key: {session_key}"
session_id: "**Sitzungs-ID:** `{session_id}`"
title: "**Titel:** {title}"
created: "**Erstellt:** {timestamp}"

View file

@ -234,6 +234,11 @@ gateway:
resume:
db_unavailable: "Session database not available."
parse_error: "⚠️ Could not parse `/resume` arguments: {error}.\nUse quotes around titles with spaces, for example: `/resume \"Project A Plan\"`."
matrix_no_named_sessions: "No named sessions found for this Matrix room.\nUse `/title My Session` to name the current room session, `/resume --all` to list all Matrix sessions, or `/resume --cross-room <session name>` to explicitly cross room boundaries."
matrix_blocked_no_origin: "⚠️ Matrix /resume blocked: this named session has no recorded room origin, so Hermes will not resume it inside the current room by default. Use `/resume --cross-room {name}` if you intentionally want to cross room boundaries."
matrix_blocked_other_room: "⚠️ Matrix /resume blocked: that session belongs to a different Matrix room ({room}). Use `/resume --cross-room {name}` if you intentionally want to resume it here."
matrix_cross_room_success: "⚠️ Cross-room resume: resumed **{title}** inside Matrix room **{room}**.\nFuture messages in this room will use that transcript until `/reset` or another `/resume`.{msg_part}"
no_named_sessions: "No named sessions found.\nUse `/title My Session` to name your current session, then `/resume My Session` to return to it later."
list_header: "📋 **Named Sessions**\n"
list_item: "• **{title}**{preview_part}"
@ -266,6 +271,12 @@ gateway:
status:
header: "📊 **Hermes Gateway Status**"
matrix_scope_header: "**Matrix scope:**"
matrix_scope_room: " room: {room}"
matrix_scope_room_id: " room_id: {room_id}"
matrix_scope_thread: " thread_id: {thread_id}"
matrix_scope_mode: " session_scope: {scope}"
matrix_scope_key: " session_key: {session_key}"
session_id: "**Session ID:** `{session_id}`"
title: "**Title:** {title}"
created: "**Created:** {timestamp}"

View file

@ -219,6 +219,14 @@ gateway:
resume:
db_unavailable: "Base de datos de sesiones no disponible."
parse_error: "⚠️ Could not parse `/resume` arguments: {error}.
Use quotes around titles with spaces, for example: `/resume \"Project A Plan\"`."
matrix_no_named_sessions: "No named sessions found for this Matrix room.
Use `/title My Session` to name the current room session, `/resume --all` to list all Matrix sessions, or `/resume --cross-room <session name>` to explicitly cross room boundaries."
matrix_blocked_no_origin: "⚠️ Matrix /resume blocked: this named session has no recorded room origin, so Hermes will not resume it inside the current room by default. Use `/resume --cross-room {name}` if you intentionally want to cross room boundaries."
matrix_blocked_other_room: "⚠️ Matrix /resume blocked: that session belongs to a different Matrix room ({room}). Use `/resume --cross-room {name}` if you intentionally want to resume it here."
matrix_cross_room_success: "⚠️ Cross-room resume: resumed **{title}** inside Matrix room **{room}**.
Future messages in this room will use that transcript until `/reset` or another `/resume`.{msg_part}"
no_named_sessions: "No se encontraron sesiones con nombre.\nUsa `/title Mi sesión` para nombrar la sesión actual y luego `/resume Mi sesión` para volver a ella."
list_header: "📋 **Sesiones con nombre**\n"
list_item: "• **{title}**{preview_part}"
@ -251,6 +259,12 @@ gateway:
status:
header: "📊 **Estado de Hermes Gateway**"
matrix_scope_header: "**Matrix scope:**"
matrix_scope_room: " room: {room}"
matrix_scope_room_id: " room_id: {room_id}"
matrix_scope_thread: " thread_id: {thread_id}"
matrix_scope_mode: " session_scope: {scope}"
matrix_scope_key: " session_key: {session_key}"
session_id: "**ID de sesión:** `{session_id}`"
title: "**Título:** {title}"
created: "**Creado:** {timestamp}"

View file

@ -219,6 +219,14 @@ gateway:
resume:
db_unavailable: "Base de données des sessions indisponible."
parse_error: "⚠️ Could not parse `/resume` arguments: {error}.
Use quotes around titles with spaces, for example: `/resume \"Project A Plan\"`."
matrix_no_named_sessions: "No named sessions found for this Matrix room.
Use `/title My Session` to name the current room session, `/resume --all` to list all Matrix sessions, or `/resume --cross-room <session name>` to explicitly cross room boundaries."
matrix_blocked_no_origin: "⚠️ Matrix /resume blocked: this named session has no recorded room origin, so Hermes will not resume it inside the current room by default. Use `/resume --cross-room {name}` if you intentionally want to cross room boundaries."
matrix_blocked_other_room: "⚠️ Matrix /resume blocked: that session belongs to a different Matrix room ({room}). Use `/resume --cross-room {name}` if you intentionally want to resume it here."
matrix_cross_room_success: "⚠️ Cross-room resume: resumed **{title}** inside Matrix room **{room}**.
Future messages in this room will use that transcript until `/reset` or another `/resume`.{msg_part}"
no_named_sessions: "Aucune session nommée trouvée.\nUtilisez `/title Ma session` pour nommer la session actuelle, puis `/resume Ma session` pour y revenir plus tard."
list_header: "📋 **Sessions nommées**\n"
list_item: "• **{title}**{preview_part}"
@ -251,6 +259,12 @@ gateway:
status:
header: "📊 **État de Hermes Gateway**"
matrix_scope_header: "**Matrix scope:**"
matrix_scope_room: " room: {room}"
matrix_scope_room_id: " room_id: {room_id}"
matrix_scope_thread: " thread_id: {thread_id}"
matrix_scope_mode: " session_scope: {scope}"
matrix_scope_key: " session_key: {session_key}"
session_id: "**ID de session :** `{session_id}`"
title: "**Titre :** {title}"
created: "**Créé :** {timestamp}"

View file

@ -223,6 +223,14 @@ gateway:
resume:
db_unavailable: "Níl bunachar sonraí na seisiún ar fáil."
parse_error: "⚠️ Could not parse `/resume` arguments: {error}.
Use quotes around titles with spaces, for example: `/resume \"Project A Plan\"`."
matrix_no_named_sessions: "No named sessions found for this Matrix room.
Use `/title My Session` to name the current room session, `/resume --all` to list all Matrix sessions, or `/resume --cross-room <session name>` to explicitly cross room boundaries."
matrix_blocked_no_origin: "⚠️ Matrix /resume blocked: this named session has no recorded room origin, so Hermes will not resume it inside the current room by default. Use `/resume --cross-room {name}` if you intentionally want to cross room boundaries."
matrix_blocked_other_room: "⚠️ Matrix /resume blocked: that session belongs to a different Matrix room ({room}). Use `/resume --cross-room {name}` if you intentionally want to resume it here."
matrix_cross_room_success: "⚠️ Cross-room resume: resumed **{title}** inside Matrix room **{room}**.
Future messages in this room will use that transcript until `/reset` or another `/resume`.{msg_part}"
no_named_sessions: "Níor aimsíodh aon seisiún ainmnithe.\nÚsáid `/title M'Ainm Seisiúin` chun do sheisiún reatha a ainmniú, ansin `/resume M'Ainm Seisiúin` chun filleadh air níos déanaí."
list_header: "📋 **Seisiúin Ainmnithe**\n"
list_item: "• **{title}**{preview_part}"
@ -255,6 +263,12 @@ gateway:
status:
header: "📊 **Stádas Hermes Gateway**"
matrix_scope_header: "**Matrix scope:**"
matrix_scope_room: " room: {room}"
matrix_scope_room_id: " room_id: {room_id}"
matrix_scope_thread: " thread_id: {thread_id}"
matrix_scope_mode: " session_scope: {scope}"
matrix_scope_key: " session_key: {session_key}"
session_id: "**ID Seisiúin:** `{session_id}`"
title: "**Teideal:** {title}"
created: "**Cruthaithe:** {timestamp}"

View file

@ -219,6 +219,14 @@ gateway:
resume:
db_unavailable: "A munkamenet-adatbázis nem érhető el."
parse_error: "⚠️ Could not parse `/resume` arguments: {error}.
Use quotes around titles with spaces, for example: `/resume \"Project A Plan\"`."
matrix_no_named_sessions: "No named sessions found for this Matrix room.
Use `/title My Session` to name the current room session, `/resume --all` to list all Matrix sessions, or `/resume --cross-room <session name>` to explicitly cross room boundaries."
matrix_blocked_no_origin: "⚠️ Matrix /resume blocked: this named session has no recorded room origin, so Hermes will not resume it inside the current room by default. Use `/resume --cross-room {name}` if you intentionally want to cross room boundaries."
matrix_blocked_other_room: "⚠️ Matrix /resume blocked: that session belongs to a different Matrix room ({room}). Use `/resume --cross-room {name}` if you intentionally want to resume it here."
matrix_cross_room_success: "⚠️ Cross-room resume: resumed **{title}** inside Matrix room **{room}**.
Future messages in this room will use that transcript until `/reset` or another `/resume`.{msg_part}"
no_named_sessions: "Nem található elnevezett munkamenet.\nHasználd a `/title Saját munkamenet` parancsot a jelenlegi munkamenet elnevezéséhez, majd a `/resume Saját munkamenet` paranccsal térhetsz vissza hozzá."
list_header: "📋 **Elnevezett munkamenetek**\n"
list_item: "• **{title}**{preview_part}"
@ -251,6 +259,12 @@ gateway:
status:
header: "📊 **Hermes Gateway állapot**"
matrix_scope_header: "**Matrix scope:**"
matrix_scope_room: " room: {room}"
matrix_scope_room_id: " room_id: {room_id}"
matrix_scope_thread: " thread_id: {thread_id}"
matrix_scope_mode: " session_scope: {scope}"
matrix_scope_key: " session_key: {session_key}"
session_id: "**Munkamenet-azonosító:** `{session_id}`"
title: "**Cím:** {title}"
created: "**Létrehozva:** {timestamp}"

View file

@ -219,6 +219,14 @@ gateway:
resume:
db_unavailable: "Database delle sessioni non disponibile."
parse_error: "⚠️ Could not parse `/resume` arguments: {error}.
Use quotes around titles with spaces, for example: `/resume \"Project A Plan\"`."
matrix_no_named_sessions: "No named sessions found for this Matrix room.
Use `/title My Session` to name the current room session, `/resume --all` to list all Matrix sessions, or `/resume --cross-room <session name>` to explicitly cross room boundaries."
matrix_blocked_no_origin: "⚠️ Matrix /resume blocked: this named session has no recorded room origin, so Hermes will not resume it inside the current room by default. Use `/resume --cross-room {name}` if you intentionally want to cross room boundaries."
matrix_blocked_other_room: "⚠️ Matrix /resume blocked: that session belongs to a different Matrix room ({room}). Use `/resume --cross-room {name}` if you intentionally want to resume it here."
matrix_cross_room_success: "⚠️ Cross-room resume: resumed **{title}** inside Matrix room **{room}**.
Future messages in this room will use that transcript until `/reset` or another `/resume`.{msg_part}"
no_named_sessions: "Nessuna sessione con nome trovata.\nUsa `/title My Session` per dare un nome alla sessione attuale, poi `/resume My Session` per tornare a essa in seguito."
list_header: "📋 **Sessioni con nome**\n"
list_item: "• **{title}**{preview_part}"
@ -251,6 +259,12 @@ gateway:
status:
header: "📊 **Stato del Gateway Hermes**"
matrix_scope_header: "**Matrix scope:**"
matrix_scope_room: " room: {room}"
matrix_scope_room_id: " room_id: {room_id}"
matrix_scope_thread: " thread_id: {thread_id}"
matrix_scope_mode: " session_scope: {scope}"
matrix_scope_key: " session_key: {session_key}"
session_id: "**ID sessione:** `{session_id}`"
title: "**Titolo:** {title}"
created: "**Creata:** {timestamp}"

View file

@ -219,6 +219,14 @@ gateway:
resume:
db_unavailable: "セッションデータベースは利用できません。"
parse_error: "⚠️ Could not parse `/resume` arguments: {error}.
Use quotes around titles with spaces, for example: `/resume \"Project A Plan\"`."
matrix_no_named_sessions: "No named sessions found for this Matrix room.
Use `/title My Session` to name the current room session, `/resume --all` to list all Matrix sessions, or `/resume --cross-room <session name>` to explicitly cross room boundaries."
matrix_blocked_no_origin: "⚠️ Matrix /resume blocked: this named session has no recorded room origin, so Hermes will not resume it inside the current room by default. Use `/resume --cross-room {name}` if you intentionally want to cross room boundaries."
matrix_blocked_other_room: "⚠️ Matrix /resume blocked: that session belongs to a different Matrix room ({room}). Use `/resume --cross-room {name}` if you intentionally want to resume it here."
matrix_cross_room_success: "⚠️ Cross-room resume: resumed **{title}** inside Matrix room **{room}**.
Future messages in this room will use that transcript until `/reset` or another `/resume`.{msg_part}"
no_named_sessions: "名前付きセッションが見つかりません。\n`/title セッション名` で現在のセッションに名前を付けると、後で `/resume セッション名` で戻れます。"
list_header: "📋 **名前付きセッション**\n"
list_item: "• **{title}**{preview_part}"
@ -251,6 +259,12 @@ gateway:
status:
header: "📊 **Hermes ゲートウェイ状態**"
matrix_scope_header: "**Matrix scope:**"
matrix_scope_room: " room: {room}"
matrix_scope_room_id: " room_id: {room_id}"
matrix_scope_thread: " thread_id: {thread_id}"
matrix_scope_mode: " session_scope: {scope}"
matrix_scope_key: " session_key: {session_key}"
session_id: "**セッション ID:** `{session_id}`"
title: "**タイトル:** {title}"
created: "**作成日時:** {timestamp}"

View file

@ -219,6 +219,14 @@ gateway:
resume:
db_unavailable: "세션 데이터베이스를 사용할 수 없습니다."
parse_error: "⚠️ Could not parse `/resume` arguments: {error}.
Use quotes around titles with spaces, for example: `/resume \"Project A Plan\"`."
matrix_no_named_sessions: "No named sessions found for this Matrix room.
Use `/title My Session` to name the current room session, `/resume --all` to list all Matrix sessions, or `/resume --cross-room <session name>` to explicitly cross room boundaries."
matrix_blocked_no_origin: "⚠️ Matrix /resume blocked: this named session has no recorded room origin, so Hermes will not resume it inside the current room by default. Use `/resume --cross-room {name}` if you intentionally want to cross room boundaries."
matrix_blocked_other_room: "⚠️ Matrix /resume blocked: that session belongs to a different Matrix room ({room}). Use `/resume --cross-room {name}` if you intentionally want to resume it here."
matrix_cross_room_success: "⚠️ Cross-room resume: resumed **{title}** inside Matrix room **{room}**.
Future messages in this room will use that transcript until `/reset` or another `/resume`.{msg_part}"
no_named_sessions: "이름이 지정된 세션이 없습니다.\n현재 세션에 이름을 지정하려면 `/title 내 세션`을 사용하고, 나중에 `/resume 내 세션`으로 돌아오세요."
list_header: "📋 **이름이 지정된 세션**\n"
list_item: "• **{title}**{preview_part}"
@ -251,6 +259,12 @@ gateway:
status:
header: "📊 **Hermes 게이트웨이 상태**"
matrix_scope_header: "**Matrix scope:**"
matrix_scope_room: " room: {room}"
matrix_scope_room_id: " room_id: {room_id}"
matrix_scope_thread: " thread_id: {thread_id}"
matrix_scope_mode: " session_scope: {scope}"
matrix_scope_key: " session_key: {session_key}"
session_id: "**세션 ID:** `{session_id}`"
title: "**제목:** {title}"
created: "**생성됨:** {timestamp}"

View file

@ -219,6 +219,14 @@ gateway:
resume:
db_unavailable: "Base de dados de sessões indisponível."
parse_error: "⚠️ Could not parse `/resume` arguments: {error}.
Use quotes around titles with spaces, for example: `/resume \"Project A Plan\"`."
matrix_no_named_sessions: "No named sessions found for this Matrix room.
Use `/title My Session` to name the current room session, `/resume --all` to list all Matrix sessions, or `/resume --cross-room <session name>` to explicitly cross room boundaries."
matrix_blocked_no_origin: "⚠️ Matrix /resume blocked: this named session has no recorded room origin, so Hermes will not resume it inside the current room by default. Use `/resume --cross-room {name}` if you intentionally want to cross room boundaries."
matrix_blocked_other_room: "⚠️ Matrix /resume blocked: that session belongs to a different Matrix room ({room}). Use `/resume --cross-room {name}` if you intentionally want to resume it here."
matrix_cross_room_success: "⚠️ Cross-room resume: resumed **{title}** inside Matrix room **{room}**.
Future messages in this room will use that transcript until `/reset` or another `/resume`.{msg_part}"
no_named_sessions: "Não foram encontradas sessões com nome.\nUsa `/title A minha sessão` para nomear a sessão atual e depois `/resume A minha sessão` para voltar a ela."
list_header: "📋 **Sessões com nome**\n"
list_item: "• **{title}**{preview_part}"
@ -251,6 +259,12 @@ gateway:
status:
header: "📊 **Estado do Hermes Gateway**"
matrix_scope_header: "**Matrix scope:**"
matrix_scope_room: " room: {room}"
matrix_scope_room_id: " room_id: {room_id}"
matrix_scope_thread: " thread_id: {thread_id}"
matrix_scope_mode: " session_scope: {scope}"
matrix_scope_key: " session_key: {session_key}"
session_id: "**ID da sessão:** `{session_id}`"
title: "**Título:** {title}"
created: "**Criada:** {timestamp}"

View file

@ -219,6 +219,14 @@ gateway:
resume:
db_unavailable: "База данных сеансов недоступна."
parse_error: "⚠️ Could not parse `/resume` arguments: {error}.
Use quotes around titles with spaces, for example: `/resume \"Project A Plan\"`."
matrix_no_named_sessions: "No named sessions found for this Matrix room.
Use `/title My Session` to name the current room session, `/resume --all` to list all Matrix sessions, or `/resume --cross-room <session name>` to explicitly cross room boundaries."
matrix_blocked_no_origin: "⚠️ Matrix /resume blocked: this named session has no recorded room origin, so Hermes will not resume it inside the current room by default. Use `/resume --cross-room {name}` if you intentionally want to cross room boundaries."
matrix_blocked_other_room: "⚠️ Matrix /resume blocked: that session belongs to a different Matrix room ({room}). Use `/resume --cross-room {name}` if you intentionally want to resume it here."
matrix_cross_room_success: "⚠️ Cross-room resume: resumed **{title}** inside Matrix room **{room}**.
Future messages in this room will use that transcript until `/reset` or another `/resume`.{msg_part}"
no_named_sessions: "Именованных сеансов не найдено.\nИспользуйте `/title Мой сеанс`, чтобы назвать текущий сеанс, затем `/resume Мой сеанс`, чтобы вернуться к нему позже."
list_header: "📋 **Именованные сеансы**\n"
list_item: "• **{title}**{preview_part}"
@ -251,6 +259,12 @@ gateway:
status:
header: "📊 **Состояние Hermes Gateway**"
matrix_scope_header: "**Matrix scope:**"
matrix_scope_room: " room: {room}"
matrix_scope_room_id: " room_id: {room_id}"
matrix_scope_thread: " thread_id: {thread_id}"
matrix_scope_mode: " session_scope: {scope}"
matrix_scope_key: " session_key: {session_key}"
session_id: "**ID сеанса:** `{session_id}`"
title: "**Название:** {title}"
created: "**Создано:** {timestamp}"

View file

@ -219,6 +219,14 @@ gateway:
resume:
db_unavailable: "Oturum veritabanı kullanılamıyor."
parse_error: "⚠️ Could not parse `/resume` arguments: {error}.
Use quotes around titles with spaces, for example: `/resume \"Project A Plan\"`."
matrix_no_named_sessions: "No named sessions found for this Matrix room.
Use `/title My Session` to name the current room session, `/resume --all` to list all Matrix sessions, or `/resume --cross-room <session name>` to explicitly cross room boundaries."
matrix_blocked_no_origin: "⚠️ Matrix /resume blocked: this named session has no recorded room origin, so Hermes will not resume it inside the current room by default. Use `/resume --cross-room {name}` if you intentionally want to cross room boundaries."
matrix_blocked_other_room: "⚠️ Matrix /resume blocked: that session belongs to a different Matrix room ({room}). Use `/resume --cross-room {name}` if you intentionally want to resume it here."
matrix_cross_room_success: "⚠️ Cross-room resume: resumed **{title}** inside Matrix room **{room}**.
Future messages in this room will use that transcript until `/reset` or another `/resume`.{msg_part}"
no_named_sessions: "Adlandırılmış oturum bulunamadı.\nMevcut oturumu adlandırmak için `/title Oturumum`, daha sonra geri dönmek için `/resume Oturumum` kullanın."
list_header: "📋 **Adlandırılmış Oturumlar**\n"
list_item: "• **{title}**{preview_part}"
@ -251,6 +259,12 @@ gateway:
status:
header: "📊 **Hermes Gateway Durumu**"
matrix_scope_header: "**Matrix scope:**"
matrix_scope_room: " room: {room}"
matrix_scope_room_id: " room_id: {room_id}"
matrix_scope_thread: " thread_id: {thread_id}"
matrix_scope_mode: " session_scope: {scope}"
matrix_scope_key: " session_key: {session_key}"
session_id: "**Oturum kimliği:** `{session_id}`"
title: "**Başlık:** {title}"
created: "**Oluşturuldu:** {timestamp}"

View file

@ -219,6 +219,14 @@ gateway:
resume:
db_unavailable: "База даних сеансів недоступна."
parse_error: "⚠️ Could not parse `/resume` arguments: {error}.
Use quotes around titles with spaces, for example: `/resume \"Project A Plan\"`."
matrix_no_named_sessions: "No named sessions found for this Matrix room.
Use `/title My Session` to name the current room session, `/resume --all` to list all Matrix sessions, or `/resume --cross-room <session name>` to explicitly cross room boundaries."
matrix_blocked_no_origin: "⚠️ Matrix /resume blocked: this named session has no recorded room origin, so Hermes will not resume it inside the current room by default. Use `/resume --cross-room {name}` if you intentionally want to cross room boundaries."
matrix_blocked_other_room: "⚠️ Matrix /resume blocked: that session belongs to a different Matrix room ({room}). Use `/resume --cross-room {name}` if you intentionally want to resume it here."
matrix_cross_room_success: "⚠️ Cross-room resume: resumed **{title}** inside Matrix room **{room}**.
Future messages in this room will use that transcript until `/reset` or another `/resume`.{msg_part}"
no_named_sessions: "Іменованих сеансів не знайдено.\nВикористайте `/title Мій сеанс`, щоб назвати поточний сеанс, потім `/resume Мій сеанс`, щоб повернутися до нього."
list_header: "📋 **Іменовані сеанси**\n"
list_item: "• **{title}**{preview_part}"
@ -251,6 +259,12 @@ gateway:
status:
header: "📊 **Стан Hermes Gateway**"
matrix_scope_header: "**Matrix scope:**"
matrix_scope_room: " room: {room}"
matrix_scope_room_id: " room_id: {room_id}"
matrix_scope_thread: " thread_id: {thread_id}"
matrix_scope_mode: " session_scope: {scope}"
matrix_scope_key: " session_key: {session_key}"
session_id: "**ID сесії:** `{session_id}`"
title: "**Назва:** {title}"
created: "**Створено:** {timestamp}"

View file

@ -219,6 +219,14 @@ gateway:
resume:
db_unavailable: "工作階段資料庫無法使用。"
parse_error: "⚠️ Could not parse `/resume` arguments: {error}.
Use quotes around titles with spaces, for example: `/resume \"Project A Plan\"`."
matrix_no_named_sessions: "No named sessions found for this Matrix room.
Use `/title My Session` to name the current room session, `/resume --all` to list all Matrix sessions, or `/resume --cross-room <session name>` to explicitly cross room boundaries."
matrix_blocked_no_origin: "⚠️ Matrix /resume blocked: this named session has no recorded room origin, so Hermes will not resume it inside the current room by default. Use `/resume --cross-room {name}` if you intentionally want to cross room boundaries."
matrix_blocked_other_room: "⚠️ Matrix /resume blocked: that session belongs to a different Matrix room ({room}). Use `/resume --cross-room {name}` if you intentionally want to resume it here."
matrix_cross_room_success: "⚠️ Cross-room resume: resumed **{title}** inside Matrix room **{room}**.
Future messages in this room will use that transcript until `/reset` or another `/resume`.{msg_part}"
no_named_sessions: "找不到已命名的工作階段。\n使用 `/title 我的工作階段` 為目前工作階段命名,然後使用 `/resume 我的工作階段` 返回。"
list_header: "📋 **已命名工作階段**\n"
list_item: "• **{title}**{preview_part}"
@ -251,6 +259,12 @@ gateway:
status:
header: "📊 **Hermes 閘道狀態**"
matrix_scope_header: "**Matrix scope:**"
matrix_scope_room: " room: {room}"
matrix_scope_room_id: " room_id: {room_id}"
matrix_scope_thread: " thread_id: {thread_id}"
matrix_scope_mode: " session_scope: {scope}"
matrix_scope_key: " session_key: {session_key}"
session_id: "**工作階段 ID** `{session_id}`"
title: "**標題:** {title}"
created: "**建立時間:** {timestamp}"

View file

@ -219,6 +219,14 @@ gateway:
resume:
db_unavailable: "会话数据库不可用。"
parse_error: "⚠️ Could not parse `/resume` arguments: {error}.
Use quotes around titles with spaces, for example: `/resume \"Project A Plan\"`."
matrix_no_named_sessions: "No named sessions found for this Matrix room.
Use `/title My Session` to name the current room session, `/resume --all` to list all Matrix sessions, or `/resume --cross-room <session name>` to explicitly cross room boundaries."
matrix_blocked_no_origin: "⚠️ Matrix /resume blocked: this named session has no recorded room origin, so Hermes will not resume it inside the current room by default. Use `/resume --cross-room {name}` if you intentionally want to cross room boundaries."
matrix_blocked_other_room: "⚠️ Matrix /resume blocked: that session belongs to a different Matrix room ({room}). Use `/resume --cross-room {name}` if you intentionally want to resume it here."
matrix_cross_room_success: "⚠️ Cross-room resume: resumed **{title}** inside Matrix room **{room}**.
Future messages in this room will use that transcript until `/reset` or another `/resume`.{msg_part}"
no_named_sessions: "未找到已命名的会话。\n使用 `/title 我的会话` 为当前会话命名,然后用 `/resume 我的会话` 返回。"
list_header: "📋 **已命名会话**\n"
list_item: "• **{title}**{preview_part}"
@ -251,6 +259,12 @@ gateway:
status:
header: "📊 **Hermes 网关状态**"
matrix_scope_header: "**Matrix scope:**"
matrix_scope_room: " room: {room}"
matrix_scope_room_id: " room_id: {room_id}"
matrix_scope_thread: " thread_id: {thread_id}"
matrix_scope_mode: " session_scope: {scope}"
matrix_scope_key: " session_key: {session_key}"
session_id: "**会话 ID** `{session_id}`"
title: "**标题:** {title}"
created: "**创建时间:** {timestamp}"

File diff suppressed because it is too large Load diff

View file

@ -28,13 +28,38 @@ def _stub_mautrix():
sys.modules.setdefault(sub, types.ModuleType(sub))
sys.modules.setdefault("mautrix", stub)
m = sys.modules["mautrix.types"]
for attr in (
"ContentURI", "EventID", "EventType", "PaginationDirection",
"PresenceState", "RoomCreatePreset", "RoomID", "SyncToken",
"TrustState", "UserID",
):
if not hasattr(m, attr):
setattr(m, attr, str)
class EventType:
ROOM_MESSAGE = "m.room.message"
REACTION = "m.reaction"
ROOM_ENCRYPTED = "m.room.encrypted"
ROOM_NAME = "m.room.name"
class PaginationDirection:
BACKWARD = "b"
FORWARD = "f"
class PresenceState:
ONLINE = "online"
OFFLINE = "offline"
UNAVAILABLE = "unavailable"
class RoomCreatePreset:
PRIVATE = "private_chat"
PUBLIC = "public_chat"
TRUSTED_PRIVATE = "trusted_private_chat"
class TrustState:
UNVERIFIED = 0
VERIFIED = 1
for attr in ("ContentURI", "EventID", "RoomID", "SyncToken", "UserID"):
setattr(m, attr, str)
m.EventType = EventType
m.PaginationDirection = PaginationDirection
m.PresenceState = PresenceState
m.RoomCreatePreset = RoomCreatePreset
m.TrustState = TrustState
_stub_mautrix()

View file

@ -27,9 +27,9 @@ class TestMatrixExecApprovalReactions:
assert result.success is True
assert adapter._approval_prompt_by_session["sess-1"] == "$evt1"
assert adapter._approval_prompts_by_event["$evt1"].session_key == "sess-1"
assert adapter._send_reaction.await_count == 2
assert adapter._send_reaction.await_count == 3
emojis = [call.args[2] for call in adapter._send_reaction.await_args_list]
assert emojis == ["", ""]
assert emojis == ["", "♾️", ""]
@pytest.mark.asyncio
async def test_reaction_resolves_pending_approval(self, monkeypatch):

View file

@ -0,0 +1,510 @@
"""Matrix Project A / Project B context-isolation regressions."""
from __future__ import annotations
import asyncio
import time
from datetime import datetime
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from gateway.config import GatewayConfig, Platform, PlatformConfig
from gateway.platforms.base import MessageEvent
from gateway.session import (
SessionContext,
SessionEntry,
SessionSource,
build_session_context_prompt,
build_session_key,
)
PROJECT_A_ROOM_ID = "!projectA:example.org"
PROJECT_B_ROOM_ID = "!projectB:example.org"
PROJECT_A_NAME = "Project - Project A"
PROJECT_B_NAME = "Project - Project B"
PROJECT_A_TOPIC = "Architecture and deploy plan for Project A"
PROJECT_B_TOPIC = "Migration and branch plan for Project B"
PROJECT_A_ALIAS = "#project-a:example.org"
PROJECT_B_ALIAS = "#project-b:example.org"
SENDER = "@alice:example.org"
def _make_adapter():
from gateway.platforms.matrix import MatrixAdapter
adapter = MatrixAdapter(
PlatformConfig(
enabled=True,
token="test-token",
extra={"homeserver": "https://matrix.example.org", "user_id": "@bot:example.org"},
)
)
adapter._user_id = "@bot:example.org"
adapter._require_mention = False
adapter._auto_thread = False
adapter._matrix_session_scope = "room"
adapter._text_batch_delay_seconds = 0
adapter._background_read_receipt = MagicMock()
adapter._get_display_name = AsyncMock(return_value="Alice")
adapter._client = _FakeMatrixClient()
return adapter
class _FakeMatrixClient:
def __init__(self):
self.state_store = MagicMock()
self.state_store.get_members = AsyncMock(return_value=["@bot:example.org", SENDER])
async def get_state_event(self, room_id, event_type):
rid = str(room_id)
state = {
PROJECT_A_ROOM_ID: {
"m.room.name": {"content": {"name": PROJECT_A_NAME}},
"m.room.topic": {"content": {"topic": PROJECT_A_TOPIC}},
"m.room.canonical_alias": {"content": {"alias": PROJECT_A_ALIAS}},
},
PROJECT_B_ROOM_ID: {
"m.room.name": {"content": {"name": PROJECT_B_NAME}},
"m.room.topic": {"content": {"topic": PROJECT_B_TOPIC}},
"m.room.canonical_alias": {"content": {"alias": PROJECT_B_ALIAS}},
},
}
value = state.get(rid, {}).get(str(event_type))
if value is None:
raise KeyError((rid, event_type))
return value
async def _source_for(adapter, room_id: str, event_id: str = "$event"):
ctx = await adapter._resolve_message_context(
room_id=room_id,
sender=SENDER,
event_id=event_id,
body="What is next?",
source_content={"body": "What is next?"},
relates_to={},
)
assert ctx is not None
return ctx[-1]
def _matrix_event(room_id: str, event_id: str, body: str = "What is next?"):
event = MagicMock()
event.room_id = room_id
event.sender = SENDER
event.event_id = event_id
event.timestamp = int(time.time() * 1000)
event.server_timestamp = event.timestamp
event.content = {"msgtype": "m.text", "body": body}
return event
def _context_for(source: SessionSource) -> SessionContext:
return SessionContext(
source=source,
connected_platforms=[Platform.MATRIX],
home_channels={},
session_key=build_session_key(source),
session_id="session-test",
)
@pytest.mark.asyncio
async def test_matrix_source_includes_room_name_topic_and_message_id():
adapter = _make_adapter()
source = await _source_for(adapter, PROJECT_B_ROOM_ID, "$project-b-msg")
assert source.chat_id == PROJECT_B_ROOM_ID
assert source.chat_name == PROJECT_B_NAME
assert source.chat_topic == PROJECT_B_TOPIC
assert source.guild_id == "example.org"
assert source.message_id == "$project-b-msg"
assert source.parent_chat_id is None
@pytest.mark.asyncio
async def test_matrix_project_a_and_project_b_have_distinct_session_keys():
adapter = _make_adapter()
source_a = await _source_for(adapter, PROJECT_A_ROOM_ID, "$a")
source_b = await _source_for(adapter, PROJECT_B_ROOM_ID, "$b")
assert source_a.chat_id != source_b.chat_id
assert source_a.chat_name == PROJECT_A_NAME
assert source_b.chat_name == PROJECT_B_NAME
assert build_session_key(source_a) != build_session_key(source_b)
@pytest.mark.asyncio
async def test_matrix_project_b_prompt_contains_project_b_not_project_a():
adapter = _make_adapter()
source_b = await _source_for(adapter, PROJECT_B_ROOM_ID, "$b")
prompt = build_session_context_prompt(_context_for(source_b))
assert PROJECT_B_NAME in prompt
assert PROJECT_B_TOPIC in prompt
assert PROJECT_B_ROOM_ID in prompt
assert "Matrix room boundary" in prompt
assert PROJECT_A_NAME not in prompt
assert PROJECT_A_TOPIC not in prompt
@pytest.mark.asyncio
async def test_matrix_project_context_survives_sequential_messages():
adapter = _make_adapter()
adapter._matrix_session_scope = "room"
first = await _source_for(adapter, PROJECT_B_ROOM_ID, "$b1")
second = await _source_for(adapter, PROJECT_B_ROOM_ID, "$b2")
assert first.thread_id is None
assert second.thread_id is None
assert first.chat_name == PROJECT_B_NAME
assert second.chat_name == PROJECT_B_NAME
assert build_session_key(first) == build_session_key(second)
@pytest.mark.asyncio
async def test_matrix_session_scope_auto_and_thread_preserve_synthetic_threads():
adapter = _make_adapter()
adapter._auto_thread = True
adapter._matrix_session_scope = "auto"
auto_source = await _source_for(adapter, PROJECT_B_ROOM_ID, "$auto")
assert auto_source.thread_id == "$auto"
adapter._matrix_session_scope = "thread"
thread_source = await _source_for(adapter, PROJECT_B_ROOM_ID, "$thread")
assert thread_source.thread_id == "$thread"
real_thread = await adapter._resolve_message_context(
room_id=PROJECT_B_ROOM_ID,
sender=SENDER,
event_id="$reply",
body="thread reply",
source_content={"body": "thread reply"},
relates_to={"rel_type": "m.thread", "event_id": "$root"},
)
assert real_thread is not None
assert real_thread[-1].thread_id == "$root"
@pytest.mark.asyncio
async def test_matrix_project_context_survives_concurrent_messages():
from gateway.run import GatewayRunner
from gateway.session_context import get_session_env
async def observe(room_id: str):
adapter = _make_adapter()
source = await _source_for(adapter, room_id, f"${room_id}")
context = _context_for(source)
runner = object.__new__(GatewayRunner)
tokens = runner._set_session_env(context)
try:
await asyncio.sleep(0)
return SimpleNamespace(
chat_id=get_session_env("HERMES_SESSION_CHAT_ID"),
chat_name=get_session_env("HERMES_SESSION_CHAT_NAME"),
session_key=get_session_env("HERMES_SESSION_KEY"),
)
finally:
runner._clear_session_env(tokens)
observed_a, observed_b = await asyncio.gather(
observe(PROJECT_A_ROOM_ID),
observe(PROJECT_B_ROOM_ID),
)
assert observed_a.chat_id == PROJECT_A_ROOM_ID
assert observed_b.chat_id == PROJECT_B_ROOM_ID
assert observed_a.chat_name == PROJECT_A_NAME
assert observed_b.chat_name == PROJECT_B_NAME
assert observed_a.session_key != observed_b.session_key
@pytest.mark.asyncio
async def test_matrix_inbound_handler_emits_project_b_metadata_not_project_a():
adapter = _make_adapter()
captured = []
async def capture(event):
captured.append(event)
adapter.handle_message = capture
await adapter._on_room_message(_matrix_event(PROJECT_B_ROOM_ID, "$project-b"))
assert len(captured) == 1
source = captured[0].source
assert source.chat_id == PROJECT_B_ROOM_ID
assert source.chat_name == PROJECT_B_NAME
assert source.chat_topic == PROJECT_B_TOPIC
assert source.message_id == "$project-b"
assert PROJECT_A_NAME not in repr(source.to_dict())
@pytest.mark.asyncio
async def test_matrix_inbound_handler_keeps_project_a_and_b_distinct():
adapter = _make_adapter()
captured = []
async def capture(event):
captured.append(event)
adapter.handle_message = capture
await adapter._on_room_message(_matrix_event(PROJECT_A_ROOM_ID, "$project-a", "A"))
await adapter._on_room_message(_matrix_event(PROJECT_B_ROOM_ID, "$project-b", "B"))
assert [event.source.chat_id for event in captured] == [
PROJECT_A_ROOM_ID,
PROJECT_B_ROOM_ID,
]
assert [event.source.chat_name for event in captured] == [
PROJECT_A_NAME,
PROJECT_B_NAME,
]
assert build_session_key(captured[0].source) != build_session_key(captured[1].source)
def test_matrix_room_scope_group_sessions_per_user_true_separates_users():
alice = _make_matrix_source(PROJECT_B_ROOM_ID, PROJECT_B_NAME, PROJECT_B_TOPIC)
bob = _make_matrix_source(PROJECT_B_ROOM_ID, PROJECT_B_NAME, PROJECT_B_TOPIC)
bob.user_id = "@bob:example.org"
alice.thread_id = None
bob.thread_id = None
assert build_session_key(alice, group_sessions_per_user=True) != build_session_key(
bob,
group_sessions_per_user=True,
)
def test_matrix_room_scope_group_sessions_per_user_false_shares_room():
alice = _make_matrix_source(PROJECT_B_ROOM_ID, PROJECT_B_NAME, PROJECT_B_TOPIC)
bob = _make_matrix_source(PROJECT_B_ROOM_ID, PROJECT_B_NAME, PROJECT_B_TOPIC)
bob.user_id = "@bob:example.org"
alice.thread_id = None
bob.thread_id = None
assert build_session_key(alice, group_sessions_per_user=False) == build_session_key(
bob,
group_sessions_per_user=False,
)
def _make_matrix_source(room_id: str, room_name: str, topic: str) -> SessionSource:
return SessionSource(
platform=Platform.MATRIX,
chat_id=room_id,
chat_name=room_name,
chat_type="group",
user_id=SENDER,
user_name="Alice",
chat_topic=topic,
)
def _entry(source: SessionSource, session_id: str, title: str | None = None) -> SessionEntry:
return SessionEntry(
session_key=build_session_key(source),
session_id=session_id,
created_at=datetime.now(),
updated_at=datetime.now(),
origin=source,
display_name=title or source.chat_name,
platform=Platform.MATRIX,
chat_type="group",
)
def _make_runner(current_source: SessionSource, entries: list[SessionEntry]):
from gateway.run import GatewayRunner
runner = object.__new__(GatewayRunner)
runner.config = GatewayConfig(platforms={Platform.MATRIX: PlatformConfig(enabled=True)})
adapter = MagicMock()
adapter._matrix_session_scope = "room"
runner.adapters = {Platform.MATRIX: adapter}
runner.session_store = MagicMock()
runner.session_store._entries = {entry.session_key: entry for entry in entries}
current = next((e for e in entries if e.origin and e.origin.chat_id == current_source.chat_id), entries[0])
runner.session_store.get_or_create_session.return_value = current
runner.session_store.switch_session.return_value = current
runner.session_store.load_transcript.return_value = [{"role": "user", "content": "hello"}]
runner._running_agents = {}
runner._session_run_generation = {}
runner._pending_messages = {}
runner._pending_approvals = {}
runner._release_running_agent_state = MagicMock()
runner._clear_session_boundary_security_state = MagicMock()
runner._evict_cached_agent = MagicMock()
runner._queue_depth = MagicMock(return_value=0)
runner._session_db = MagicMock()
runner._session_db.list_sessions_rich.return_value = [
{"id": entry.session_id, "title": entry.display_name, "preview": ""}
for entry in entries
]
runner._session_db.resolve_resume_session_id.side_effect = lambda sid: sid
runner._session_db.get_session_title.side_effect = lambda sid: {
entry.session_id: entry.display_name for entry in entries
}.get(sid)
runner._session_db.get_session.return_value = None
return runner
def _event(text: str, source: SessionSource) -> MessageEvent:
return MessageEvent(text=text, source=source, message_id="$cmd")
@pytest.mark.asyncio
async def test_matrix_status_reports_current_matrix_room_scope():
source_a = _make_matrix_source(PROJECT_A_ROOM_ID, PROJECT_A_NAME, PROJECT_A_TOPIC)
source_b = _make_matrix_source(PROJECT_B_ROOM_ID, PROJECT_B_NAME, PROJECT_B_TOPIC)
entry_b = _entry(source_b, "session-b", "Project B Plan")
runner = _make_runner(source_b, [_entry(source_a, "session-a", "Project A Plan"), entry_b])
result = await runner._handle_status_command(_event("/status", source_b))
assert "Matrix scope:" in result
assert PROJECT_B_NAME in result
assert PROJECT_B_ROOM_ID in result
assert "session_scope: room" in result
session_key = build_session_key(source_b)
assert session_key not in result
assert session_key[:8] not in result
assert "session_key: sha256:" in result
assert PROJECT_A_NAME not in result
assert PROJECT_A_ROOM_ID not in result
@pytest.mark.asyncio
async def test_matrix_resume_does_not_cross_rooms_by_default():
source_a = _make_matrix_source(PROJECT_A_ROOM_ID, PROJECT_A_NAME, PROJECT_A_TOPIC)
source_b = _make_matrix_source(PROJECT_B_ROOM_ID, PROJECT_B_NAME, PROJECT_B_TOPIC)
entry_a = _entry(source_a, "session-a", "Project A Plan")
entry_b = _entry(source_b, "session-b", "Project B Plan")
runner = _make_runner(source_b, [entry_a, entry_b])
runner._session_db.resolve_session_by_title.return_value = "session-a"
result = await runner._handle_resume_command(_event("/resume Project A Plan", source_b))
assert "blocked" in result
assert PROJECT_A_NAME in result
runner.session_store.switch_session.assert_not_called()
@pytest.mark.asyncio
async def test_matrix_resume_allows_same_room_session():
source_b = _make_matrix_source(PROJECT_B_ROOM_ID, PROJECT_B_NAME, PROJECT_B_TOPIC)
entry_b = _entry(source_b, "session-b-old", "Project B Plan")
runner = _make_runner(source_b, [entry_b])
runner.session_store.get_or_create_session.return_value = _entry(
source_b, "session-b-current", "Current Project B"
)
runner.session_store.switch_session.return_value = entry_b
runner._session_db.resolve_session_by_title.return_value = "session-b-old"
result = await runner._handle_resume_command(_event("/resume Project B Plan", source_b))
assert "Resumed session" in result
runner.session_store.switch_session.assert_called_once()
@pytest.mark.asyncio
async def test_matrix_resume_quoted_title_same_room():
source_b = _make_matrix_source(PROJECT_B_ROOM_ID, PROJECT_B_NAME, PROJECT_B_TOPIC)
entry_b = _entry(source_b, "session-b-old", "Project B Plan")
runner = _make_runner(source_b, [entry_b])
runner.session_store.get_or_create_session.return_value = _entry(
source_b, "session-b-current", "Current Project B"
)
runner.session_store.switch_session.return_value = entry_b
runner._session_db.resolve_session_by_title.return_value = "session-b-old"
result = await runner._handle_resume_command(
_event('/resume "Project B Plan"', source_b)
)
assert "Resumed session" in result
runner._session_db.resolve_session_by_title.assert_called_once_with("Project B Plan")
@pytest.mark.asyncio
async def test_matrix_resume_quoted_title_cross_room_blocked():
source_a = _make_matrix_source(PROJECT_A_ROOM_ID, PROJECT_A_NAME, PROJECT_A_TOPIC)
source_b = _make_matrix_source(PROJECT_B_ROOM_ID, PROJECT_B_NAME, PROJECT_B_TOPIC)
entry_a = _entry(source_a, "session-a", "Project A Plan")
entry_b = _entry(source_b, "session-b", "Project B Plan")
runner = _make_runner(source_b, [entry_a, entry_b])
runner._session_db.resolve_session_by_title.return_value = "session-a"
result = await runner._handle_resume_command(
_event('/resume "Project A Plan"', source_b)
)
assert "blocked" in result
runner.session_store.switch_session.assert_not_called()
@pytest.mark.asyncio
async def test_matrix_resume_malformed_quote_returns_helpful_error():
source_b = _make_matrix_source(PROJECT_B_ROOM_ID, PROJECT_B_NAME, PROJECT_B_TOPIC)
runner = _make_runner(source_b, [_entry(source_b, "session-b", "Project B Plan")])
result = await runner._handle_resume_command(
_event('/resume "Project B Plan', source_b)
)
assert "Could not parse" in result
assert "quotes" in result
@pytest.mark.asyncio
async def test_matrix_resume_cross_room_requires_explicit_flag_and_warns():
source_a = _make_matrix_source(PROJECT_A_ROOM_ID, PROJECT_A_NAME, PROJECT_A_TOPIC)
source_b = _make_matrix_source(PROJECT_B_ROOM_ID, PROJECT_B_NAME, PROJECT_B_TOPIC)
entry_a = _entry(source_a, "session-a", "Project A Plan")
entry_b = _entry(source_b, "session-b", "Project B Plan")
runner = _make_runner(source_b, [entry_a, entry_b])
runner.session_store.switch_session.return_value = entry_a
runner._session_db.resolve_session_by_title.return_value = "session-a"
result = await runner._handle_resume_command(
_event("/resume --cross-room Project A Plan", source_b)
)
assert "Cross-room resume" in result
assert PROJECT_B_NAME in result
runner.session_store.switch_session.assert_called_once()
@pytest.mark.asyncio
async def test_matrix_resume_lists_only_current_room_by_default():
source_a = _make_matrix_source(PROJECT_A_ROOM_ID, PROJECT_A_NAME, PROJECT_A_TOPIC)
source_b = _make_matrix_source(PROJECT_B_ROOM_ID, PROJECT_B_NAME, PROJECT_B_TOPIC)
runner = _make_runner(
source_b,
[_entry(source_a, "session-a", "Project A Plan"), _entry(source_b, "session-b", "Project B Plan")],
)
result = await runner._handle_resume_command(_event("/resume", source_b))
assert "Project B Plan" in result
assert "Project A Plan" not in result
@pytest.mark.asyncio
async def test_matrix_resume_all_lists_room_names():
source_a = _make_matrix_source(PROJECT_A_ROOM_ID, PROJECT_A_NAME, PROJECT_A_TOPIC)
source_b = _make_matrix_source(PROJECT_B_ROOM_ID, PROJECT_B_NAME, PROJECT_B_TOPIC)
runner = _make_runner(
source_b,
[_entry(source_a, "session-a", "Project A Plan"), _entry(source_b, "session-b", "Project B Plan")],
)
result = await runner._handle_resume_command(_event("/resume --all", source_b))
assert "Project A Plan" in result
assert PROJECT_A_NAME in result
assert "Project B Plan" in result

View file

@ -611,6 +611,30 @@ class TestSessionStoreSwitchSession:
db.close()
class TestSessionStoreLookupBySessionId:
@pytest.fixture()
def store(self, tmp_path):
config = GatewayConfig()
with patch("gateway.session.SessionStore._ensure_loaded"):
s = SessionStore(sessions_dir=tmp_path, config=config)
s._db = None
s._loaded = True
return s
def test_returns_active_entry_for_persisted_session_id(self, store):
source = SessionSource(
platform=Platform.MATRIX,
chat_id="!room:example.org",
chat_type="group",
user_id="@alice:example.org",
)
entry = store.get_or_create_session(source)
assert store.lookup_by_session_id(entry.session_id) is entry
assert store.lookup_by_session_id("missing") is None
assert store.lookup_by_session_id("") is None
class TestWhatsAppSessionKeyConsistency:
"""Regression: WhatsApp session keys must collapse JID/LID aliases to a
single stable identity for both DM chat_ids and group participant_ids."""

View file

@ -397,15 +397,31 @@ For cloud sandbox backends, persistence is filesystem-oriented. `TERMINAL_LIFETI
| `MATRIX_USER_ID` | Matrix user ID (e.g. `@hermes:matrix.org`) — required for password login, optional with access token |
| `MATRIX_PASSWORD` | Matrix password (alternative to access token) |
| `MATRIX_ALLOWED_USERS` | Comma-separated Matrix user IDs allowed to message the bot (e.g. `@alice:matrix.org`) |
| `MATRIX_ALLOWED_ROOMS` | Comma-separated Matrix room IDs allowed to trigger bot responses |
| `MATRIX_HOME_ROOM` | Room ID for proactive message delivery (e.g. `!abc123:matrix.org`) |
| `MATRIX_ENCRYPTION` | Enable end-to-end encryption (`true`/`false`, default: `false`) |
| `MATRIX_E2EE_MODE` | Matrix E2EE behavior: `off`, `optional`, or `required`. Overrides `MATRIX_ENCRYPTION` when set. |
| `MATRIX_DEVICE_ID` | Stable Matrix device ID for E2EE persistence across restarts (e.g. `HERMES_BOT`). Without this, E2EE keys rotate every startup and historic-room decrypt breaks. |
| `MATRIX_REACTIONS` | Enable processing-lifecycle emoji reactions on inbound messages (default: `true`). Set to `false` to disable. |
| `MATRIX_REQUIRE_MENTION` | Require `@mention` in rooms (default: `true`). Set to `false` to respond to all messages. |
| `MATRIX_FREE_RESPONSE_ROOMS` | Comma-separated room IDs where bot responds without `@mention` |
| `MATRIX_IGNORE_USER_PATTERNS` | Comma-separated regular expressions for Matrix bridge/appservice ghost user IDs to ignore |
| `MATRIX_PROCESS_NOTICES` | Process inbound Matrix `m.notice` events (default: `false`) |
| `MATRIX_SESSION_SCOPE` | Matrix session scope for project rooms: `auto`, `room`, or `thread` (default: `auto`) |
| `MATRIX_TOOLS_ALLOW_CROSS_ROOM` | Allow Matrix tools to target explicit rooms other than the current room (default: `false`) |
| `MATRIX_TOOLS_ALLOW_CROSS_ROOM_DESTRUCTIVE` | Allow cross-room Matrix redaction/invite-like tools; requires `MATRIX_TOOLS_ALLOW_CROSS_ROOM=true` (default: `false`) |
| `MATRIX_TOOLS_ALLOW_REDACTION` | Allow Matrix message redaction tool execution (default: `false`) |
| `MATRIX_TOOLS_ALLOW_INVITES` | Allow Matrix invite tool execution (default: `false`) |
| `MATRIX_TOOLS_ALLOW_ROOM_CREATE` | Allow Matrix room creation tool execution (default: `false`) |
| `MATRIX_ALLOW_ROOM_MENTIONS` | Allow outbound `@room` mentions to notify all room members (default: `false`) |
| `MATRIX_AUTO_THREAD` | Auto-create threads for room messages (default: `true`) |
| `MATRIX_DM_MENTION_THREADS` | Create a thread when bot is `@mentioned` in a DM (default: `false`) |
| `MATRIX_APPROVAL_REQUIRE_SENDER` | Require approval/model-picker reactions to come from the original requester when known (default: `true`) |
| `MATRIX_APPROVAL_TIMEOUT_SECONDS` | Timeout for Matrix reaction approval/model-picker prompts (default: `300`) |
| `MATRIX_ALLOW_PUBLIC_ROOMS` | Allow Matrix room-creation tools to create public rooms (default: `false`) |
| `MATRIX_MAX_MEDIA_BYTES` | Maximum Matrix media upload/download size in bytes (default: `104857600`) |
| `MATRIX_RECOVERY_KEY` | Recovery key for cross-signing verification after device key rotation. Recommended for E2EE setups with cross-signing enabled. |
| `MATRIX_RECOVERY_KEY_OUTPUT_FILE` | Optional one-time path for a generated Matrix recovery key. Created with mode `0600` and never overwritten. |
| `HASS_TOKEN` | Home Assistant Long-Lived Access Token (enables HA platform + tools) |
| `HASS_URL` | Home Assistant URL (default: `http://homeassistant.local:8123`) |
| `WEBHOOK_ENABLED` | Enable the webhook platform adapter (`true`/`false`) |

View file

@ -21,12 +21,36 @@ Before setup, here's the part most people want to know: how Hermes behaves once
| **Threads** | Hermes supports Matrix threads (MSC3440). If you reply in a thread, Hermes keeps the thread context isolated from the main room timeline. Threads where the bot has already participated do not require a mention. |
| **Auto-threading** | By default, Hermes auto-creates a thread for each message it responds to in a room. This keeps conversations isolated. Set `MATRIX_AUTO_THREAD=false` to disable. Set `MATRIX_DM_AUTO_THREAD=true` (default false) to also auto-create threads for DM messages — this is distinct from `MATRIX_DM_MENTION_THREADS`, which only starts a thread when the bot is `@mentioned` in a DM. |
| **Commands** | Hermes accepts normal `/commands` when your Matrix client sends them. If your client reserves `/` for local commands, use `!commands` instead; Hermes normalizes known `!command` aliases to `/command`. |
| **Interactive controls** | Dangerous-command approval and `/model` selection can use Matrix reactions. Approval reactions can be limited to the user who requested the action. |
| **Thinking and tool activity** | Matrix uses threaded, editable thinking/tool-activity panes when gateway progress is enabled, so updates do not flood the main room timeline. |
| **Shared rooms with multiple users** | By default, Hermes isolates session history per user inside the room. Two people talking in the same room do not share one transcript unless you explicitly disable that. |
:::tip
The bot automatically joins rooms when invited. Just invite the bot's Matrix user to any room and it will join and start responding.
:::
## Capability Matrix
This table is backed by the Matrix adapter capability declaration and Matrix test
coverage. E2EE is mode-based because deployments choose whether encrypted rooms
are disabled, opportunistic, or required.
| Capability | Matrix |
|------------|--------|
| text | yes |
| threads | yes |
| reactions | yes |
| approvals | yes |
| model picker | yes |
| thinking panes | yes |
| images | yes |
| multiple images | yes |
| files | yes |
| voice/audio | yes |
| video | yes |
| E2EE | off / optional / required |
| diagnostics | yes |
### Session Model in Matrix
By default:
@ -60,8 +84,17 @@ You can configure mention and auto-threading behavior via environment variables
```yaml
matrix:
require_mention: true # Require @mention in rooms (default: true)
allowed_users: # Matrix users allowed to trigger agent turns
- "@alice:matrix.org"
allowed_rooms: # Matrix rooms allowed to trigger agent turns
- "!abc123:matrix.org"
free_response_rooms: # Rooms exempt from mention requirement
- "!abc123:matrix.org"
ignore_user_patterns: # Bridge/appservice ghost users to ignore
- "^@telegram_"
- "^@whatsapp_"
process_notices: false # Ignore m.notice by default
session_scope: room # auto|room|thread; room is recommended for project rooms
auto_thread: true # Auto-create threads for responses (default: true)
dm_mention_threads: false # Create thread when @mentioned in DM (default: false)
```
@ -70,20 +103,60 @@ Or via environment variables:
```bash
MATRIX_REQUIRE_MENTION=true
MATRIX_ALLOWED_USERS=@alice:matrix.org
MATRIX_ALLOWED_ROOMS=!abc123:matrix.org
MATRIX_FREE_RESPONSE_ROOMS=!abc123:matrix.org,!def456:matrix.org
MATRIX_IGNORE_USER_PATTERNS='^@telegram_,^@whatsapp_'
MATRIX_PROCESS_NOTICES=false
MATRIX_SESSION_SCOPE=room # recommended for stable project-room context
MATRIX_AUTO_THREAD=true
MATRIX_DM_MENTION_THREADS=false
MATRIX_REACTIONS=true # default: true — emoji reactions during processing
MATRIX_ALLOW_ROOM_MENTIONS=false
```
:::tip Disabling reactions
`MATRIX_REACTIONS=false` turns off the processing-lifecycle emoji reactions (👀/✅/❌) the bot posts on inbound messages. Useful for rooms where reaction events are noisy or aren't supported by all participating clients.
:::
:::tip Room-wide mentions
Hermes sends structured Matrix user mentions for explicit Matrix IDs such as `@alice:example.org`. Room-wide `@room` notifications are disabled by default; set `MATRIX_ALLOW_ROOM_MENTIONS=true` only in rooms where the bot is allowed to notify everyone.
:::
:::note
If you are upgrading from a version that did not have `MATRIX_REQUIRE_MENTION`, the bot previously responded to all messages in rooms. To preserve that behavior, set `MATRIX_REQUIRE_MENTION=false`.
:::
### Project Room Isolation
If you use the same Matrix bot in multiple project rooms, configure stable
room-scoped sessions:
```bash
MATRIX_SESSION_SCOPE=room
MATRIX_AUTO_THREAD=false
```
`MATRIX_SESSION_SCOPE` accepts:
| Scope | Behavior |
|-------|----------|
| `auto` | Backward-compatible default. Existing `MATRIX_AUTO_THREAD` behavior controls synthetic threads. |
| `room` | Unthreaded room messages stay in one stable room session. Real Matrix threads still use their thread root. |
| `thread` | Unthreaded room messages synthesize a thread/session from the triggering event ID. |
Hermes now includes the current Matrix room name, room ID, topic, message ID,
and a Matrix room-boundary note in the agent prompt. `/status` also shows the
current Matrix room/session scope, and `/resume` will not silently resume a
named session from another Matrix room unless you explicitly use
`/resume --cross-room <session name>`.
`MATRIX_SESSION_SCOPE=room` controls the room/thread lane. The existing
`group_sessions_per_user` setting still controls whether users inside that room
share the lane. With `group_sessions_per_user: true` (default), Alice and Bob get
separate Project B sessions. With `group_sessions_per_user: false`, the room has
one shared Project B transcript.
This guide walks you through the full setup process — from creating your bot account to sending your first message.
## Step 1: Create a Bot Account
@ -196,6 +269,9 @@ MATRIX_ACCESS_TOKEN=***
# Security: restrict who can interact with the bot
MATRIX_ALLOWED_USERS=@alice:matrix.example.org
# Optional: restrict which rooms can trigger the bot
MATRIX_ALLOWED_ROOMS=!abc123:matrix.example.org
# Multiple allowed users (comma-separated)
# MATRIX_ALLOWED_USERS=@alice:matrix.example.org,@bob:matrix.example.org
```
@ -212,6 +288,45 @@ MATRIX_PASSWORD=***
MATRIX_ALLOWED_USERS=@alice:matrix.example.org
```
## Private Deployment Hardening
For private Matrix deployments, set both user and room allowlists. If
`MATRIX_ALLOWED_USERS` is unset, any sender who can reach the bot in a joined
room can trigger an agent turn. If `MATRIX_ALLOWED_ROOMS` is unset, any room the
bot joins can trigger an agent turn. A locked-down deployment should set both:
```bash
MATRIX_ALLOWED_USERS=@alice:matrix.example.org,@bob:matrix.example.org
MATRIX_ALLOWED_ROOMS=!ops:matrix.example.org,!dmroom:matrix.example.org
```
Bridge and appservice deployments need extra loop protection. Hermes always
ignores its own events, Matrix appservice-style users whose localpart starts
with `_`, duplicate event IDs, old startup events, edit replacement events, and
`m.notice` events by default. Add deployment-specific bridge ghost patterns when
your bridge uses a different naming convention:
```bash
MATRIX_IGNORE_USER_PATTERNS='^@telegram_,^@slack_,^@whatsapp_'
```
Only enable notices when a trusted human workflow really sends `m.notice`:
```bash
MATRIX_PROCESS_NOTICES=true
```
Outbound whole-room notifications are disabled by default. Keep
`MATRIX_ALLOW_ROOM_MENTIONS=false` unless the bot is explicitly allowed to wake
the whole room with `@room`.
Diagnostics and debug payloads redact Matrix access tokens, recovery keys,
device identifiers, and message bodies. Media downloads are limited to Matrix
`mxc://` content URIs and rejected when they exceed `MATRIX_MAX_MEDIA_BYTES`.
Treat federated rooms and untrusted homeservers as untrusted input: keep room
allowlists tight, prefer DMs or private rooms for tool-heavy work, and avoid
authorizing bridge ghosts or appservice puppets as allowed users.
Optional behavior settings in `~/.hermes/config.yaml`:
```yaml
@ -268,9 +383,21 @@ sudo dnf install libolm-devel
Add to your `~/.hermes/.env`:
```bash
MATRIX_ENCRYPTION=true
MATRIX_E2EE_MODE=required
```
`MATRIX_E2EE_MODE` accepts:
| Mode | Behavior |
|------|----------|
| `off` | Do not initialize Matrix E2EE. |
| `optional` | Try E2EE when dependencies are available, but keep unencrypted rooms working if crypto cannot initialize. |
| `required` | Fail closed if E2EE dependencies or crypto setup are not available. |
Optional mode may fall back to non-E2EE operation when crypto setup is unavailable. Required mode fails closed instead of silently downgrading.
For backwards compatibility, `MATRIX_ENCRYPTION=true` still enables required E2EE behavior.
When E2EE is enabled, Hermes:
- Stores encryption keys in `~/.hermes/platforms/matrix/store/` (legacy installs: `~/.hermes/matrix/store/`)
@ -278,6 +405,65 @@ When E2EE is enabled, Hermes:
- Decrypts incoming messages and encrypts outgoing messages automatically
- Auto-joins encrypted rooms when invited
### Matrix Tools and Controls
In Matrix conversations, Hermes exposes Matrix-specific tools to the agent:
- `matrix_send_reaction`
- `matrix_redact_message`
- `matrix_create_room`
- `matrix_invite_user`
- `matrix_fetch_history`
- `matrix_set_presence`
These tools are scoped to Matrix contexts and are not available in non-Matrix toolsets. Admin-style tools are disabled by default: redaction requires `MATRIX_TOOLS_ALLOW_REDACTION=true`, invites require `MATRIX_TOOLS_ALLOW_INVITES=true`, and room creation requires `MATRIX_TOOLS_ALLOW_ROOM_CREATE=true`. Public room creation also requires `MATRIX_ALLOW_PUBLIC_ROOMS=true`.
Matrix tools are limited to the current Matrix room by default. Explicit
cross-room targets require `MATRIX_TOOLS_ALLOW_CROSS_ROOM=true`; redaction and
invite-like cross-room actions additionally require
`MATRIX_TOOLS_ALLOW_CROSS_ROOM_DESTRUCTIVE=true`. If `MATRIX_ALLOWED_ROOMS` is
set, Matrix tools may only target those rooms.
Reaction controls use:
- ✅ approve once
- ♾️ approve always
- ❌ deny
- number reactions for `/model` choices
Set `MATRIX_APPROVAL_REQUIRE_SENDER=false` if you intentionally want any authorized Matrix user in the room to operate an approval/model picker prompt. The default is requester-bound when Hermes knows who requested the action.
### Media Limits
Hermes uploads and downloads Matrix images, files, audio, and video through Matrix media APIs. Multiple generated images are sent as one ordered logical batch, preserving captions and thread context across the batch.
By default, Matrix media over 100 MB is rejected before upload/download. Override with:
```bash
MATRIX_MAX_MEDIA_BYTES=104857600
```
Inbound media must use Matrix `mxc://` content URIs. Hermes rejects arbitrary
HTTP(S) media URLs in Matrix events to avoid turning a federated room into an
unrestricted downloader.
## Synapse Integration Tests
Hermes includes an opt-in Synapse harness for local validation:
```bash
docker compose -f tests/e2e/matrix_synapse_gateway/docker-compose.yml up -d
HERMES_MATRIX_SYNAPSE_INTEGRATION=1 \
scripts/run_tests.sh -m "integration and matrix_synapse" \
tests/e2e/matrix_synapse_gateway/test_gateway.py
docker compose -f tests/e2e/matrix_synapse_gateway/docker-compose.yml down -v
```
The harness creates temporary users through Synapse shared-secret registration
and covers private-room send/receive, named-room invite/join, media
upload/download, bot response delivery, and startup old-event filtering. E2EE
smoke coverage is separately marked with `matrix_e2ee` so it can stay opt-in on
developer machines.
### Cross-Signing Verification (Recommended)
If your Matrix account has cross-signing enabled (the default in Element), set the recovery key so the bot can self-sign its device on startup. Without this, other Matrix clients may refuse to share encryption sessions with the bot after a device key rotation.
@ -290,6 +476,11 @@ MATRIX_RECOVERY_KEY=EsT... your recovery key here
On each startup, if `MATRIX_RECOVERY_KEY` is set, Hermes imports cross-signing keys from the homeserver's secure secret storage and signs the current device. This is idempotent and safe to leave enabled permanently.
If Hermes bootstraps a new Matrix recovery key, it never logs the raw key. Set
`MATRIX_RECOVERY_KEY_OUTPUT_FILE=/secure/path/matrix-recovery-key.txt` before
startup to write a generated key once with file mode `0600`; the file is not
overwritten if it already exists.
:::warning[Deleting the crypto store]
If you delete `~/.hermes/platforms/matrix/store/crypto.db`, the bot loses its encryption identity. Simply restarting with the same device ID will **not** fully recover — the homeserver still holds one-time keys signed with the old identity key, and peers cannot establish new Olm sessions.
@ -406,9 +597,9 @@ such as `!important` remain normal chat messages.
### Bot is not responding to messages
**Cause**: The bot hasn't joined the room, or `MATRIX_ALLOWED_USERS` doesn't include your User ID.
**Cause**: The bot hasn't joined the room, `MATRIX_ALLOWED_USERS` doesn't include your User ID, `MATRIX_ALLOWED_ROOMS` doesn't include the room, or a room message did not mention the bot.
**Fix**: Invite the bot to the room — it auto-joins on invite. Verify your User ID is in `MATRIX_ALLOWED_USERS` (use the full `@user:server` format). Restart the gateway.
**Fix**: Invite the bot to the room — it auto-joins on invite. Verify your User ID is in `MATRIX_ALLOWED_USERS` (use the full `@user:server` format) and the room ID is in `MATRIX_ALLOWED_ROOMS` if that allowlist is configured. In rooms, mention the bot or add the room to `MATRIX_FREE_RESPONSE_ROOMS`. Restart the gateway.
### Bot joins rooms but silently drops every message (clock skew)
@ -685,6 +876,21 @@ Session continuity is maintained via the `X-Hermes-Session-Id` header. The host'
**Limitations (v1):** Tool progress messages from the remote agent are not relayed back — the user sees the streamed final response only, not individual tool calls. Dangerous command approval prompts are handled on the host side, not relayed to the Matrix user. These can be addressed in future updates.
:::
### Bot connects and sends, but ignores inbound messages
**Cause**: Matrix event handlers only fire when sync payloads are dispatched through
mautrix's `handle_sync()` machinery. A raw `client.sync()` poll that never calls
`handle_sync()` can leave the adapter connected (send works) while inbound
messages never reach `_on_room_message`.
**Fix**: Hermes uses an explicit sync loop that calls `client.handle_sync()` on
both the initial sync and every incremental sync response. This matches the
diagnosis in upstream issue #7914 and closed PR #37807, but keeps Hermes's own
background maintenance tasks (joined-room tracking, invite handling, E2EE key
share) instead of delegating the full lifecycle to `client.start()`. If inbound
messages still fail after a gateway restart, verify handlers are registered before
the first sync and check logs for `sync event dispatch error`.
### Sync issues / bot falls behind
**Cause**: Long-running tool executions can delay the sync loop, or the homeserver is slow.
@ -703,10 +909,22 @@ Session continuity is maintained via the `X-Hermes-Session-Id` header. The host'
**Fix**: Add your User ID to `MATRIX_ALLOWED_USERS` in `~/.hermes/.env` and restart the gateway. Use the full `@user:server` format.
### Bot ignores an entire room
**Cause**: `MATRIX_ALLOWED_ROOMS` is set and the current room ID is not listed, or the room requires a mention and the message did not mention the bot.
**Fix**: Add the room ID to `MATRIX_ALLOWED_ROOMS`, or remove the room allowlist if this is a personal deployment. To find a Room ID in Element, open room settings and check **Advanced**.
### Bridge messages loop or echo
**Cause**: A bridge/appservice puppet is relaying bot output back as a new user message, or a bridge uses non-standard ghost user IDs.
**Fix**: Keep bridge ghosts out of `MATRIX_ALLOWED_USERS`, add a matching `MATRIX_IGNORE_USER_PATTERNS` entry, and leave `MATRIX_PROCESS_NOTICES=false` unless notices are part of a trusted workflow.
## Security
:::warning
Always set `MATRIX_ALLOWED_USERS` to restrict who can interact with the bot. Without it, the gateway denies all users by default as a safety measure. Only add User IDs of people you trust — authorized users have full access to the agent's capabilities, including tool use and system access.
Always set `MATRIX_ALLOWED_USERS` and, for shared/private deployments, `MATRIX_ALLOWED_ROOMS`. Without them, anyone who can message the bot in a joined room may trigger the agent. Only authorize people and rooms you trust — authorized users have full access to the agent's capabilities, including tool use and system access.
:::
For more information on securing your Hermes Agent deployment, see the [Security Guide](../security.md).